diff --git a/.changeset/fix-sqlite-persistence-type-contracts.md b/.changeset/fix-sqlite-persistence-type-contracts.md new file mode 100644 index 0000000000..3266f71d72 --- /dev/null +++ b/.changeset/fix-sqlite-persistence-type-contracts.md @@ -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. diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 0f1e4112ac..9ee0c810f7 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -20,6 +20,7 @@ import type { CollectionConfig, CollectionIndexMetadata, DeleteMutationFnParams, + InferSchemaOutput, InsertMutationFnParams, LoadSubsetFn, LoadSubsetOptions, @@ -2739,13 +2740,59 @@ function createLoopbackSyncConfig< } } +export function persistedCollectionOptions< + TSchema extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord = UtilsRecord, +>( + options: PersistedSyncWrappedOptions< + InferSchemaOutput, + TKey, + TSchema, + TUtils + > & { + schema: TSchema + }, +): PersistedSyncOptionsResult< + InferSchemaOutput, + TKey, + TSchema, + TUtils +> & { + schema: TSchema +} + +export function persistedCollectionOptions< + TSchema extends StandardSchemaV1, + TKey extends string | number, + TUtils extends UtilsRecord = UtilsRecord, +>( + options: PersistedLocalOnlyOptions< + InferSchemaOutput, + TKey, + TSchema, + TUtils + > & { + schema: TSchema + }, +): PersistedLocalOnlyOptionsResult< + InferSchemaOutput, + 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, + options: PersistedSyncWrappedOptions & { + schema?: never + }, ): PersistedSyncOptionsResult export function persistedCollectionOptions< @@ -2754,7 +2801,9 @@ export function persistedCollectionOptions< TSchema extends StandardSchemaV1 = never, TUtils extends UtilsRecord = UtilsRecord, >( - options: PersistedLocalOnlyOptions, + options: PersistedLocalOnlyOptions & { + schema?: never + }, ): PersistedLocalOnlyOptionsResult export function persistedCollectionOptions< @@ -2815,7 +2864,7 @@ export function persistedCollectionOptions< persistedCollectionOptions({ ...options, id: collectionId, - }) as typeof result, + } as never) as typeof result, ) } @@ -2923,7 +2972,7 @@ export function persistedCollectionOptions< persistedCollectionOptions({ ...options, id: collectionId, - }) as typeof result, + } as never) as typeof result, ) } diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts new file mode 100644 index 0000000000..673ebd0c9d --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts @@ -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 + +/** + * # 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 }, + }) + }) +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test-d.ts b/packages/db-sqlite-persistence-core/tests/persisted.test-d.ts index b07f9f787d..9019334c39 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test-d.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test-d.ts @@ -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() diff --git a/packages/expo-db-sqlite-persistence/src/expo-sqlite-driver.ts b/packages/expo-db-sqlite-persistence/src/expo-sqlite-driver.ts index e85df20aac..71e06b4766 100644 --- a/packages/expo-db-sqlite-persistence/src/expo-sqlite-driver.ts +++ b/packages/expo-db-sqlite-persistence/src/expo-sqlite-driver.ts @@ -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 - | Record +export type ExpoSQLiteBindParams = SQLiteBindParams -export type ExpoSQLiteRunResult = { - changes: number - lastInsertRowId: number -} +export type ExpoSQLiteRunResult = SQLiteRunResult export type ExpoSQLiteQueryable = { execAsync: (sql: string) => Promise - getAllAsync: ( - sql: string, - params?: ExpoSQLiteBindParams, - ) => Promise> - runAsync: ( - sql: string, - params?: ExpoSQLiteBindParams, - ) => Promise + getAllAsync: { + (sql: string, params: SQLiteBindParams): Promise> + (sql: string): Promise> + } + runAsync: { + (sql: string, params: SQLiteBindParams): Promise + (sql: string): Promise + } } export type ExpoSQLiteTransaction = ExpoSQLiteQueryable export type ExpoSQLiteDatabaseLike = ExpoSQLiteQueryable & { - withExclusiveTransactionAsync: ( - task: (transaction: ExpoSQLiteTransaction) => Promise, - ) => Promise + withExclusiveTransactionAsync: ( + task: (transaction: ExpoSQLiteTransaction) => Promise, + ) => Promise closeAsync?: () => Promise } @@ -144,10 +144,12 @@ export class ExpoSQLiteDriver implements SQLiteDriver { ): Promise { 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 }) } @@ -225,10 +227,22 @@ export class ExpoSQLiteDriver implements SQLiteDriver { } } -function normalizeParams( - params: ReadonlyArray, -): ExpoSQLiteBindParams | undefined { - return params.length > 0 ? [...params] : undefined +function normalizeParams(params: ReadonlyArray): 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( diff --git a/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test-d.ts b/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test-d.ts new file mode 100644 index 0000000000..761c235a5f --- /dev/null +++ b/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test-d.ts @@ -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` result even though Expo's native + * `withExclusiveTransactionAsync` boundary returns `Promise`. + * + * These compile-time checkpoints cover structural database compatibility and + * the returned `Promise`. 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: (_sql: string) => Promise.resolve([] as Array), + runAsync: (_sql: string) => + Promise.resolve({ changes: 0, lastInsertRowId: 0 }), + } + + createExpoSQLitePersistence({ + // @ts-expect-error persistence requires exclusive transactions + database, + }) + }) +}) diff --git a/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test.ts b/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test.ts index cff37d7c56..65b8c1c11c 100644 --- a/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test.ts +++ b/packages/expo-db-sqlite-persistence/tests/expo-sqlite-driver.test.ts @@ -5,6 +5,10 @@ import { afterEach, expect, it } from 'vitest' import { ExpoSQLiteDriver } from '../src/expo-sqlite-driver' import { InvalidPersistedCollectionConfigError } from '../../db-sqlite-persistence-core/src' import { createExpoSQLiteTestDatabase } from './helpers/expo-sqlite-test-db' +import type { + ExpoSQLiteDatabaseLike, + ExpoSQLiteTransaction, +} from '../src/expo-sqlite-driver' const activeCleanupFns: Array<() => void | Promise> = [] @@ -193,6 +197,61 @@ it(`throws when transaction callback omits transaction driver argument`, async ( ).rejects.toThrow(`transaction driver argument`) }) +/** + * Runtime checkpoint for the paired type assertion: `transaction` resolves + * to the callback's value after Expo's void-returning exclusive transaction + * boundary settles. This test does not claim an intermediate publication or + * independently recheck commit and rollback behavior. + */ +it(`returns callback values when Expo's transaction boundary returns void`, async () => { + const transaction: ExpoSQLiteTransaction = { + execAsync: () => Promise.resolve(), + getAllAsync: () => Promise.resolve([] as Array), + runAsync: () => + Promise.resolve({ + changes: 0, + lastInsertRowId: 0, + }), + } + const database: ExpoSQLiteDatabaseLike = { + ...transaction, + withExclusiveTransactionAsync: async (task) => { + await task(transaction) + }, + } + const driver = new ExpoSQLiteDriver({ database }) + + await expect( + driver.transaction((transactionDriver) => { + void transactionDriver + return Promise.resolve({ status: `committed` as const }) + }), + ).resolves.toEqual({ status: `committed` }) +}) + +it(`rejects values outside Expo's SQLite bind domain`, async () => { + const transaction: ExpoSQLiteTransaction = { + execAsync: () => Promise.resolve(), + getAllAsync: () => Promise.resolve([] as Array), + runAsync: () => + Promise.resolve({ + changes: 0, + lastInsertRowId: 0, + }), + } + const database: ExpoSQLiteDatabaseLike = { + ...transaction, + withExclusiveTransactionAsync: async (task) => { + await task(transaction) + }, + } + const driver = new ExpoSQLiteDriver({ database }) + + await expect(driver.run(`SELECT ?`, [{ unsupported: true }])).rejects.toThrow( + `Expo SQLite bind parameters`, + ) +}) + it(`throws config error when expo database methods are missing`, () => { expect(() => new ExpoSQLiteDriver({ database: {} as never })).toThrowError( InvalidPersistedCollectionConfigError, diff --git a/packages/expo-db-sqlite-persistence/tests/helpers/expo-emulator-database-factory.ts b/packages/expo-db-sqlite-persistence/tests/helpers/expo-emulator-database-factory.ts index de347ee076..f93983aca2 100644 --- a/packages/expo-db-sqlite-persistence/tests/helpers/expo-emulator-database-factory.ts +++ b/packages/expo-db-sqlite-persistence/tests/helpers/expo-emulator-database-factory.ts @@ -3,10 +3,8 @@ import type { ExpoSQLiteTestDatabase, ExpoSQLiteTestDatabaseFactory, } from './expo-sqlite-test-db' -import type { - ExpoSQLiteBindParams, - ExpoSQLiteTransaction, -} from '../../src/expo-sqlite-driver' +import type { ExpoSQLiteTransaction } from '../../src/expo-sqlite-driver' +import type { SQLiteBindParams } from 'expo-sqlite' function resolvePlatform(): `ios` | `android` { const platform = process.env.TANSTACK_DB_EXPO_RUNTIME_PLATFORM?.trim() @@ -46,13 +44,17 @@ export function createMobileSQLiteTestDatabaseFactory(): ExpoSQLiteTestDatabaseF execAsync: async (sql: string) => { await (await getDatabase()).execAsync(sql) }, - getAllAsync: async (sql: string, params?: ExpoSQLiteBindParams) => - (await getDatabase()).getAllAsync(sql, params), - runAsync: async (sql: string, params?: ExpoSQLiteBindParams) => - (await getDatabase()).runAsync(sql, params), - withExclusiveTransactionAsync: async ( - task: (transaction: ExpoSQLiteTransaction) => Promise, - ): Promise => + getAllAsync: async (sql: string, params?: SQLiteBindParams) => + params === undefined + ? (await getDatabase()).getAllAsync(sql) + : (await getDatabase()).getAllAsync(sql, params), + runAsync: async (sql: string, params?: SQLiteBindParams) => + params === undefined + ? (await getDatabase()).runAsync(sql) + : (await getDatabase()).runAsync(sql, params), + withExclusiveTransactionAsync: async ( + task: (transaction: ExpoSQLiteTransaction) => Promise, + ): Promise => (await getDatabase()).withExclusiveTransactionAsync(task), closeAsync: async () => { if (!databasePromise) { diff --git a/packages/expo-db-sqlite-persistence/tests/helpers/expo-sqlite-test-db.ts b/packages/expo-db-sqlite-persistence/tests/helpers/expo-sqlite-test-db.ts index 70cc5ede58..0690d8630c 100644 --- a/packages/expo-db-sqlite-persistence/tests/helpers/expo-sqlite-test-db.ts +++ b/packages/expo-db-sqlite-persistence/tests/helpers/expo-sqlite-test-db.ts @@ -5,6 +5,7 @@ import type { ExpoSQLiteRunResult, ExpoSQLiteTransaction, } from '../../src/expo-sqlite-driver' +import type { SQLiteBindValue } from 'expo-sqlite' export type ExpoSQLiteTestDatabase = ExpoSQLiteDatabaseLike & { closeAsync: () => Promise @@ -35,7 +36,7 @@ function normalizeRunResult( function hasNamedParameters( params: ExpoSQLiteBindParams | undefined, -): params is Record { +): params is Record { return params !== undefined && !Array.isArray(params) }