Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-sqlite-persistence-type-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/db-sqlite-persistence-core': patch
'@tanstack/expo-db-sqlite-persistence': patch
---

Preserve schema input and output inference when persisted collection options are passed to `createCollection`. Accept Expo's native SQLite database type and preserve transaction callback results while validating bind values against Expo's supported domain.
57 changes: 53 additions & 4 deletions packages/db-sqlite-persistence-core/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
CollectionConfig,
CollectionIndexMetadata,
DeleteMutationFnParams,
InferSchemaOutput,
InsertMutationFnParams,
LoadSubsetFn,
LoadSubsetOptions,
Expand Down Expand Up @@ -2739,13 +2740,59 @@ function createLoopbackSyncConfig<
}
}

export function persistedCollectionOptions<
TSchema extends StandardSchemaV1,
TKey extends string | number,
TUtils extends UtilsRecord = UtilsRecord,
>(
options: PersistedSyncWrappedOptions<
InferSchemaOutput<TSchema>,
TKey,
TSchema,
TUtils
> & {
schema: TSchema
},
): PersistedSyncOptionsResult<
InferSchemaOutput<TSchema>,
TKey,
TSchema,
TUtils
> & {
schema: TSchema
}

export function persistedCollectionOptions<
TSchema extends StandardSchemaV1,
TKey extends string | number,
TUtils extends UtilsRecord = UtilsRecord,
>(
options: PersistedLocalOnlyOptions<
InferSchemaOutput<TSchema>,
TKey,
TSchema,
TUtils
> & {
schema: TSchema
},
): PersistedLocalOnlyOptionsResult<
InferSchemaOutput<TSchema>,
TKey,
TSchema,
TUtils
> & {
schema: TSchema
}

export function persistedCollectionOptions<
T extends object,
TKey extends string | number,
TSchema extends StandardSchemaV1 = never,
TUtils extends UtilsRecord = UtilsRecord,
>(
options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils>,
options: PersistedSyncWrappedOptions<T, TKey, TSchema, TUtils> & {
schema?: never
},
): PersistedSyncOptionsResult<T, TKey, TSchema, TUtils>

export function persistedCollectionOptions<
Expand All @@ -2754,7 +2801,9 @@ export function persistedCollectionOptions<
TSchema extends StandardSchemaV1 = never,
TUtils extends UtilsRecord = UtilsRecord,
>(
options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils>,
options: PersistedLocalOnlyOptions<T, TKey, TSchema, TUtils> & {
schema?: never
},
): PersistedLocalOnlyOptionsResult<T, TKey, TSchema, TUtils>

export function persistedCollectionOptions<
Expand Down Expand Up @@ -2815,7 +2864,7 @@ export function persistedCollectionOptions<
persistedCollectionOptions({
...options,
id: collectionId,
}) as typeof result,
} as never) as typeof result,
)
}

Expand Down Expand Up @@ -2923,7 +2972,7 @@ export function persistedCollectionOptions<
persistedCollectionOptions({
...options,
id: collectionId,
}) as typeof result,
} as never) as typeof result,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it } from 'vitest'
import { createCollection } from '@tanstack/db'
import { persistedCollectionOptions } from '../src'
import type { PersistenceAdapter } from '../src'
import type { StandardSchemaV1 } from '@standard-schema/spec'

const adapter: PersistenceAdapter = {
loadSubset: () => Promise.resolve([]),
applyCommittedTx: () => Promise.resolve(),
ensureIndex: () => Promise.resolve(),
}

type RowInput = { id: string; rank: string; label?: string }
type RowOutput = { id: string; rank: number; label: string }

const rowSchema = null as unknown as StandardSchemaV1<RowInput, RowOutput>

/**
* # What does `persistedCollectionOptions` preserve?
*
* Law and source: the public `persistedCollectionOptions` utility must preserve
* `createCollection`'s Standard Schema input, output, and inferred key contract.
* Its own-key `sync` split comes from the persisted local-only and sync-wrapped
* option overloads; a present `sync` key must contain a `SyncConfig`.
*
* Legal forms here are transforming-schema collections with and without
* external sync, plus schema-free local and synced option objects. The public
* type path is `persistedCollectionOptions(...)` into `createCollection(...)`.
* The checkpoint is the resulting `insert`, `get`, and inferred key types.
*
* Positive observations accept schema input on insert, expose schema output on
* read, infer string keys, and accept both sync modes. Hostile observations
* reject output-as-input, numeric keys, and a present-but-undefined `sync`.
* The valid local and synced cells control against rejecting every option.
*
* This oracle does not exercise schema parsing, adapter I/O, the returned
* `PersistedCollectionUtils`, sync execution, or schema-validation failures.
*/
describe(`persisted collection option type oracle`, () => {
it(`composes an inferred transforming schema with createCollection`, () => {
const localOptions = persistedCollectionOptions({
id: `local-schema`,
schema: rowSchema,
schemaVersion: 1,
getKey: (row) => row.id,
persistence: { adapter },
})

const localCollection = createCollection(localOptions)
localCollection.insert({ id: `row`, rank: `1` } satisfies RowInput)
const localOutput: RowOutput | undefined = localCollection.get(`row`)
void localOutput

// @ts-expect-error getKey inferred string keys from the schema output
localCollection.get(1)

const syncedOptions = persistedCollectionOptions({
id: `synced-schema`,
schema: rowSchema,
schemaVersion: 1,
getKey: (row) => row.id,
sync: {
sync: ({ markReady }) => {
markReady()
},
},
persistence: { adapter },
})

const syncedCollection = createCollection(syncedOptions)
syncedCollection.insert({ id: `row`, rank: `1` } satisfies RowInput)
const syncedOutput: RowOutput | undefined = syncedCollection.get(`row`)
void syncedOutput
})

it(`keeps schema input and output roles distinct`, () => {
const collection = createCollection(
persistedCollectionOptions({
id: `schema-roles`,
schema: rowSchema,
getKey: (row) => row.id,
persistence: { adapter },
}),
)

collection.insert({ id: `row`, rank: `1` })

// @ts-expect-error transformed output values are not valid schema input
collection.insert({ id: `row`, rank: 1, label: `output` })

const output = collection.get(`row`)
if (output) {
const rank: number = output.rank
const label: string = output.label
void rank
void label
}
})

it(`discriminates sync mode from the presence of the sync key`, () => {
persistedCollectionOptions({
id: `local`,
getKey: (row: RowOutput) => row.id,
persistence: { adapter },
})

persistedCollectionOptions({
id: `synced`,
getKey: (row: RowOutput) => row.id,
sync: {
sync: ({ markReady }) => {
markReady()
},
},
persistence: { adapter },
})

persistedCollectionOptions({
id: `invalid-undefined-sync`,
getKey: (row: RowOutput) => row.id,
// @ts-expect-error a present sync key must hold a SyncConfig
sync: undefined,
persistence: { adapter },
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ describe(`persisted collection types`, () => {
})

it(`requires persistence config`, () => {
// @ts-expect-error persistedCollectionOptions requires a persistence config
persistedCollectionOptions({
getKey: (item: Todo) => item.id,
// @ts-expect-error persistedCollectionOptions requires persistence when sync is provided
sync: {
sync: ({ markReady }: { markReady: () => void }) => {
markReady()
Expand Down
62 changes: 38 additions & 24 deletions packages/expo-db-sqlite-persistence/src/expo-sqlite-driver.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
import { InvalidPersistedCollectionConfigError } from '@tanstack/db-sqlite-persistence-core'
import type { SQLiteDriver } from '@tanstack/db-sqlite-persistence-core'
import type {
SQLiteBindParams,
SQLiteBindValue,
SQLiteRunResult,
} from 'expo-sqlite'

export type ExpoSQLiteBindParams =
| ReadonlyArray<unknown>
| Record<string, unknown>
export type ExpoSQLiteBindParams = SQLiteBindParams

export type ExpoSQLiteRunResult = {
changes: number
lastInsertRowId: number
}
export type ExpoSQLiteRunResult = SQLiteRunResult

export type ExpoSQLiteQueryable = {
execAsync: (sql: string) => Promise<void>
getAllAsync: <T>(
sql: string,
params?: ExpoSQLiteBindParams,
) => Promise<ReadonlyArray<T>>
runAsync: (
sql: string,
params?: ExpoSQLiteBindParams,
) => Promise<ExpoSQLiteRunResult>
getAllAsync: {
<T>(sql: string, params: SQLiteBindParams): Promise<ReadonlyArray<T>>
<T>(sql: string): Promise<ReadonlyArray<T>>
}
runAsync: {
(sql: string, params: SQLiteBindParams): Promise<SQLiteRunResult>
(sql: string): Promise<SQLiteRunResult>
}
}

export type ExpoSQLiteTransaction = ExpoSQLiteQueryable

export type ExpoSQLiteDatabaseLike = ExpoSQLiteQueryable & {
withExclusiveTransactionAsync: <T>(
task: (transaction: ExpoSQLiteTransaction) => Promise<T>,
) => Promise<T>
withExclusiveTransactionAsync: (
task: (transaction: ExpoSQLiteTransaction) => Promise<void>,
) => Promise<void>
closeAsync?: () => Promise<void>
}

Expand Down Expand Up @@ -144,10 +144,12 @@ export class ExpoSQLiteDriver implements SQLiteDriver {
): Promise<T> {
return this.enqueue(async () => {
const database = await this.getDatabase()
return database.withExclusiveTransactionAsync(async (transaction) => {
let result: T | undefined
await database.withExclusiveTransactionAsync(async (transaction) => {
const transactionDriver = this.createTransactionDriver(transaction)
return fn(transactionDriver)
result = await fn(transactionDriver)
})
return result as T
})
}

Expand Down Expand Up @@ -225,10 +227,22 @@ export class ExpoSQLiteDriver implements SQLiteDriver {
}
}

function normalizeParams(
params: ReadonlyArray<unknown>,
): ExpoSQLiteBindParams | undefined {
return params.length > 0 ? [...params] : undefined
function normalizeParams(params: ReadonlyArray<unknown>): SQLiteBindParams {
return params.map((value) => {
if (
value === null ||
typeof value === `string` ||
typeof value === `number` ||
typeof value === `boolean` ||
value instanceof Uint8Array
) {
return value satisfies SQLiteBindValue
}

throw new TypeError(
`Expo SQLite bind parameters must be strings, numbers, booleans, null, or Uint8Array values`,
)
})
}

export function createExpoSQLiteDriver(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it } from 'vitest'
import { createExpoSQLitePersistence } from '../src'
import {
ExpoSQLiteDriver,
createExpoSQLiteDriver,
} from '../src/expo-sqlite-driver'
import type { SQLiteDatabase } from 'expo-sqlite'
import type { ExpoSQLiteDatabaseLike } from '../src'

/**
* The public driver boundary accepts Expo's installed `SQLiteDatabase` while
* retaining its exclusive-transaction requirement. It also preserves the
* generic `SQLiteDriver.transaction<T>` result even though Expo's native
* `withExclusiveTransactionAsync` boundary returns `Promise<void>`.
*
* These compile-time checkpoints cover structural database compatibility and
* the returned `Promise<T>`. The paired runtime test observes the callback value
* only after the native exclusive transaction boundary settles.
*/
describe(`Expo SQLite driver types`, () => {
it(`accepts the vendor database returned by expo-sqlite`, () => {
const database = null as unknown as SQLiteDatabase

createExpoSQLitePersistence({ database })
createExpoSQLiteDriver({ database })
new ExpoSQLiteDriver({ database })

const compatible: ExpoSQLiteDatabaseLike = database
void compatible
})

it(`preserves the transaction callback result type`, () => {
const database = null as unknown as SQLiteDatabase
const driver = new ExpoSQLiteDriver({ database })

const result = driver.transaction(async (transactionDriver) => {
void transactionDriver
return { status: `committed` as const }
})
const expected: Promise<{ readonly status: `committed` }> = result
void expected
})

it(`rejects databases without an exclusive transaction boundary`, () => {
const database = {
execAsync: (_sql: string) => Promise.resolve(),
getAllAsync: <T>(_sql: string) => Promise.resolve([] as Array<T>),
runAsync: (_sql: string) =>
Promise.resolve({ changes: 0, lastInsertRowId: 0 }),
}

createExpoSQLitePersistence({
// @ts-expect-error persistence requires exclusive transactions
database,
})
})
})
Loading
Loading