diff --git a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts new file mode 100644 index 0000000000..a8961be4a8 --- /dev/null +++ b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts @@ -0,0 +1,188 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { createCollection } from '../src/index' +import { localOnlyCollectionOptions } from '../src/local-only' +import { localStorageCollectionOptions } from '../src/local-storage' +import type { ChangeMessageOrDeleteKeyMessage } from '../src/types' +import type { OutputWithVirtual } from './utils' + +const rowSchema = z.object({ + id: z.string().brand<`AdapterRowId`>(), + createdAt: z + .union([z.string(), z.date()]) + .transform((value) => + typeof value === `string` ? new Date(value) : value, + ), + score: z + .union([z.string(), z.number()]) + .transform((value) => (typeof value === `string` ? Number(value) : value)), + label: z.string().default(`untitled`), + note: z.string().nullish(), +}) + +type RowInput = z.input +type RowOutput = z.output +type RowId = RowOutput[`id`] + +const rawInput = { + id: `row-1`, + createdAt: `2026-09-18T12:00:00.000Z`, + score: `42`, + note: null, +} satisfies RowInput + +const synchronizedOutput = { + id: `row-1` as RowId, + createdAt: new Date(`2026-09-18T12:00:00.000Z`), + score: 42, + label: `untitled`, + note: null, +} satisfies RowOutput + +type ItemOf = T extends Array ? U : T + +/** + * Which side of a Standard Schema transform belongs at each Collection + * boundary? + * + * Shared law: + * - Mutation entry points accept schema input. An update draft is schema input. + * - A Collection stores and exposes schema output. `getKey`, `compare`, and + * sync change messages therefore use schema output. + * + * Type relation and domain: + * `RowInput` and `RowOutput` come from one schema, but deliberately differ in + * transformed Date and number fields, a branded key, a defaulted field, and a + * nullish field. The compile-time oracle requires every production type path + * to choose the correct side of that relation. + * + * Production paths and observation cut: + * The driver passes the schema through `localOnlyCollectionOptions` and + * `localStorageCollectionOptions`, then creates the public Collection. + * `expectTypeOf` observes callbacks, mutation parameters, Collection rows, and + * sync change messages after TypeScript resolves each public adapter type. + * + * Fault controls and omissions: + * `@ts-expect-error` controls send an input-only row through the sync boundary + * or an invalid value through the mutation boundary. This partial oracle does + * not execute schema parsing, local storage, change publication, or provider + * I/O. Package-specific files map the same law to their production paths. Each + * package owns its fixture so the oracle crosses that package's real compile + * boundary without importing test types from a sibling package. + */ +describe(`local adapter schema transform conformance`, () => { + it(`keeps local-only synchronized rows on the output side`, () => { + const options = localOnlyCollectionOptions({ + schema: rowSchema, + getKey: (row) => { + expectTypeOf(row).toEqualTypeOf() + expectTypeOf(row.id).toEqualTypeOf() + expectTypeOf(row.createdAt).toEqualTypeOf() + expectTypeOf(row.score).toEqualTypeOf() + expectTypeOf(row.label).toEqualTypeOf() + expectTypeOf(row.note).toEqualTypeOf() + return row.id + }, + initialData: [synchronizedOutput], + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1` as RowId, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0] + type SyncMessage = Parameters[0] + expectTypeOf().toEqualTypeOf< + ChangeMessageOrDeleteKeyMessage + >() + + const assertBoundary = (write: SyncParams[`write`]) => { + const wrongDate = { + ...synchronizedOutput, + createdAt: rawInput.createdAt, + } + + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error one untransformed field cannot cross the sync boundary + write({ type: `insert`, value: wrongDate }) + // @ts-expect-error input-only rows have not crossed the schema boundary + write({ type: `insert`, value: rawInput }) + } + expectTypeOf(assertBoundary).toBeFunction() + + const assertMutationInput = () => { + collection.insert(rawInput) + // @ts-expect-error transformed fields reject unrelated values + collection.insert({ ...rawInput, score: false }) + } + expectTypeOf(assertMutationInput).toBeFunction() + }) + + it(`keeps local-storage synchronized rows on the output side`, () => { + const options = localStorageCollectionOptions({ + storageKey: `adapter-schema-transform-conformance`, + schema: rowSchema, + getKey: (row) => { + expectTypeOf(row).toEqualTypeOf() + return row.id + }, + compare: (left, right) => { + expectTypeOf(left).toEqualTypeOf() + expectTypeOf(right).toEqualTypeOf() + return left.score - right.score + }, + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1` as RowId, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0] + type SyncMessage = Parameters[0] + expectTypeOf().toEqualTypeOf< + ChangeMessageOrDeleteKeyMessage + >() + + const assertBoundary = (write: SyncParams[`write`]) => { + const wrongDate = { + ...synchronizedOutput, + createdAt: rawInput.createdAt, + } + + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error one untransformed field cannot cross the sync boundary + write({ type: `insert`, value: wrongDate }) + // @ts-expect-error storage parsing must produce schema output rows + write({ type: `insert`, value: rawInput }) + } + expectTypeOf(assertBoundary).toBeFunction() + + type LocalStorageOutput = ItemOf + const assertOutput = (row: LocalStorageOutput) => { + row.createdAt.getTime() + row.score.toFixed() + expectTypeOf(row.label).toEqualTypeOf() + // @ts-expect-error output dates do not expose string methods + row.createdAt.toUpperCase() + } + expectTypeOf(assertOutput).toBeFunction() + }) +}) diff --git a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts new file mode 100644 index 0000000000..ec62d0399d --- /dev/null +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,120 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { createCollection } from '@tanstack/db' +import { electricCollectionOptions } from '../src/electric' +import type { + ChangeMessageOrDeleteKeyMessage, + WithVirtualProps, +} from '@tanstack/db' + +const rowSchema = z.object({ + id: z.string().brand<`ElectricRowId`>(), + createdAt: z + .union([z.string(), z.date()]) + .transform((value) => + typeof value === `string` ? new Date(value) : value, + ), + score: z + .union([z.string(), z.number()]) + .transform((value) => (typeof value === `string` ? Number(value) : value)), + label: z.string().default(`untitled`), + note: z.string().nullish(), +}) + +type RowInput = z.input +type RowOutput = z.output +type RowId = RowOutput[`id`] +type ItemOf = T extends Array ? U : T + +const rawInput = { + id: `row-1`, + createdAt: `2026-09-18T12:00:00.000Z`, + score: `42`, + note: null, +} satisfies RowInput + +const synchronizedOutput = { + id: `row-1` as RowId, + createdAt: new Date(`2026-09-18T12:00:00.000Z`), + score: 42, + label: `untitled`, + note: null, +} satisfies RowOutput + +/** + * Adapter mapping for the shared schema input/output law: + * `electricCollectionOptions` carries Electric shape rows into sync change + * messages as schema output. `getKey`, `compare`, Collection rows, and handler + * mutations observe that output. Public insert and update entry points accept + * schema input. Electric deliberately keeps its public Collection key domain + * at `string | number`; it does not infer the schema's branded ID type. + * + * The assertions observe those production type paths after overload + * resolution. Hostile controls reject an untransformed shape row at the sync + * boundary and an invalid value at the mutation boundary. This partial oracle + * does not execute Shape parsing, network I/O, or mutation-handler timing. + */ +describe(`Electric schema transform conformance`, () => { + it(`keeps the shape and mutation sides distinct`, () => { + const options = electricCollectionOptions({ + shapeOptions: { url: `https://example.com/v1/shape` }, + schema: rowSchema, + getKey: (row) => { + expectTypeOf(row).toEqualTypeOf() + expectTypeOf(row.id).toEqualTypeOf() + expectTypeOf(row.createdAt).toEqualTypeOf() + expectTypeOf(row.score).toEqualTypeOf() + expectTypeOf(row.label).toEqualTypeOf() + expectTypeOf(row.note).toEqualTypeOf() + return row.id + }, + compare: (left, right) => left.score - right.score, + onInsert: ({ transaction }) => { + expectTypeOf( + transaction.mutations[0].modified, + ).toEqualTypeOf() + return Promise.resolve() + }, + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0] + type SyncMessage = Parameters[0] + expectTypeOf().toEqualTypeOf< + ChangeMessageOrDeleteKeyMessage + >() + + const assertShapeBoundary = (write: SyncParams[`write`]) => { + const wrongDate = { + ...synchronizedOutput, + createdAt: rawInput.createdAt, + } + + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error one untransformed field cannot cross the sync boundary + write({ type: `insert`, value: wrongDate }) + // @ts-expect-error an untransformed shape row is not collection output + write({ type: `insert`, value: rawInput }) + } + expectTypeOf(assertShapeBoundary).toBeFunction() + + const assertMutationInput = () => { + collection.insert(rawInput) + // @ts-expect-error mutation input does not accept unrelated numeric shapes + collection.insert({ ...rawInput, score: false }) + } + expectTypeOf(assertMutationInput).toBeFunction() + }) +}) diff --git a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts new file mode 100644 index 0000000000..3c11eeaa41 --- /dev/null +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,278 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { Schema, Table, column } from '@powersync/node' +import { z } from 'zod' +import { createCollection } from '@tanstack/db' +import { powerSyncCollectionOptions } from '../src' +import type { + ChangeMessageOrDeleteKeyMessage, + WithVirtualProps, +} from '@tanstack/db' +import type { PowerSyncDatabase } from '@powersync/node' +import type { ConfigWithArbitraryCollectionTypes } from '../src' + +const database = {} as PowerSyncDatabase +const appSchema = new Schema({ + rows: new Table({ + created_at: column.text, + score: column.integer, + label: column.text, + note: column.text, + enabled: column.integer, + }), +}) + +const sqliteTransformSchema = z.object({ + id: z.string().brand<`PowerSyncRowId`>(), + created_at: z + .string() + .nullable() + .default(null) + .transform((value) => + value ? new Date(value) : new Date(`1970-01-01T00:00:00.000Z`), + ), + score: z + .number() + .nullable() + .default(0) + .transform((value) => value ?? 0) + .brand<`Score`>(), + label: z + .string() + .nullable() + .default(`untitled`) + .transform((value) => value ?? `untitled`), + note: z.string().nullable().default(null), + enabled: z + .number() + .nullable() + .default(0) + .transform((value) => Boolean(value)), +}) + +const applicationSchema = z.object({ + id: z.string().brand<`PowerSyncRowId`>(), + created_at: z.date(), + score: z.number().brand<`Score`>(), + label: z.string(), + note: z.string().nullable(), + enabled: z.boolean(), +}) + +const applicationDeserializer = z.object({ + id: z.string().brand<`PowerSyncRowId`>(), + created_at: z + .string() + .nullable() + .transform((value) => new Date(value!)), + score: z + .number() + .nullable() + .transform( + (value) => (value ?? 0) as z.output[`score`], + ), + label: z + .string() + .nullable() + .transform((value) => value ?? `untitled`), + note: z.string().nullable(), + enabled: z + .number() + .nullable() + .transform((value) => Boolean(value)), +}) + +type SqliteInput = z.input +type SqliteOutput = z.output +type ApplicationInput = z.input +type ApplicationOutput = z.output +type ItemOf = T extends Array ? U : T + +const sqliteInput = { + id: `row-1`, + created_at: `2026-09-18T12:00:00.000Z`, + score: 42, + note: null, + enabled: 1, +} satisfies SqliteInput + +const applicationInput = { + id: `row-1`, + created_at: new Date(`2026-09-18T12:00:00.000Z`), + score: 42, + label: `untitled`, + note: null, + enabled: true, +} satisfies ApplicationInput + +const synchronizedOutput = { + id: `row-1` as ApplicationOutput[`id`], + created_at: new Date(`2026-09-18T12:00:00.000Z`), + score: 42 as ApplicationOutput[`score`], + label: `untitled`, + note: null, + enabled: true, +} satisfies ApplicationOutput + +/** + * Adapter mapping for the shared schema input/output law: + * PowerSync has two supported production type paths. A transforming Collection + * schema maps SQLite-shaped input to Collection output. With an application + * schema, `deserializationSchema` maps SQLite rows to that schema's exact + * output. In both paths, public mutations accept the Collection schema input; + * compare, serializer, Collection rows, and sync change messages use output. + * PowerSync keys are strings because every runtime key comes from the table's + * string `id`; schema branding does not narrow the public Collection key. + * + * The assertions observe both paths after TypeScript resolves the public + * options and Collection types. Hostile controls reject SQLite values at an + * application mutation boundary, application values at a SQLite mutation + * boundary, input rows at the sync boundary, and a deserializer with the wrong + * output. This partial oracle does not execute database reads, parsing, + * serialization, or deserialization-error handling. + */ +describe(`PowerSync schema transform conformance`, () => { + it(`transforms SQLite-shaped mutation and sync rows to exact output`, () => { + const options = powerSyncCollectionOptions({ + database, + table: appSchema.props.rows, + schema: sqliteTransformSchema, + onDeserializationError: () => {}, + serializer: { + created_at: (value) => value.toISOString(), + score: (value) => value, + enabled: (value) => (value ? 1 : 0), + }, + compare: (left, right) => { + expectTypeOf(left).toEqualTypeOf() + expectTypeOf(right).toEqualTypeOf() + expectTypeOf(left.id).toEqualTypeOf() + expectTypeOf(left.created_at).toEqualTypeOf() + expectTypeOf(left.score).toEqualTypeOf() + expectTypeOf(left.label).toEqualTypeOf() + expectTypeOf(left.note).toEqualTypeOf() + expectTypeOf(left.enabled).toEqualTypeOf() + return left.score - right.score + }, + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + const assertMutationInput = () => { + collection.insert(sqliteInput) + // @ts-expect-error SQLite-shaped mutations do not accept booleans + collection.insert({ ...sqliteInput, enabled: true }) + } + expectTypeOf(assertMutationInput).toBeFunction() + }) + + it(`respects the explicit SQLite deserialization boundary`, () => { + const options = powerSyncCollectionOptions({ + database, + table: appSchema.props.rows, + schema: applicationSchema, + deserializationSchema: applicationDeserializer, + onDeserializationError: () => {}, + serializer: { + created_at: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value.toISOString() + }, + score: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value + }, + enabled: (value) => { + expectTypeOf(value).toEqualTypeOf() + return value ? 1 : 0 + }, + }, + compare: (left, right) => { + expectTypeOf(left).toEqualTypeOf() + expectTypeOf(right).toEqualTypeOf() + return left.score - right.score + }, + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0] + type SyncMessage = Parameters[0] + expectTypeOf().toEqualTypeOf< + ChangeMessageOrDeleteKeyMessage + >() + + const assertSyncBoundary = (write: SyncParams[`write`]) => { + const wrongDate = { + ...synchronizedOutput, + created_at: sqliteInput.created_at, + } + + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error one SQLite field cannot cross the sync boundary + write({ type: `insert`, value: wrongDate }) + // @ts-expect-error SQLite/mutation input is not synchronized output + write({ type: `insert`, value: sqliteInput }) + } + expectTypeOf(assertSyncBoundary).toBeFunction() + + const assertMutationInput = () => { + collection.insert(applicationInput) + // @ts-expect-error application mutations use booleans, not SQLite integers + collection.insert({ ...applicationInput, enabled: 1 }) + } + expectTypeOf(assertMutationInput).toBeFunction() + }) + + it(`rejects a deserializer whose output is not collection output`, () => { + const wrongDeserializer = z.object({ + id: z.string(), + created_at: z.string().nullable(), + score: z.number().nullable(), + label: z.string().nullable(), + note: z.string().nullable(), + enabled: z.number().nullable(), + }) + + type DeserializationSchema = ConfigWithArbitraryCollectionTypes< + (typeof appSchema.props)[`rows`], + typeof applicationSchema + >[`deserializationSchema`] + const acceptDeserializer = (_schema: DeserializationSchema) => {} + + // @ts-expect-error deserialization must produce collection output + acceptDeserializer(wrongDeserializer) + + // The member probe isolates the schema relation. This public call also + // protects overload and configuration-union selection. + const wrongPublicOptions = { + database, + table: appSchema.props.rows, + schema: applicationSchema, + deserializationSchema: wrongDeserializer, + onDeserializationError: () => {}, + } + + // @ts-expect-error public options require exact collection output + powerSyncCollectionOptions(wrongPublicOptions) + }) +}) diff --git a/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts new file mode 100644 index 0000000000..1809e5a615 --- /dev/null +++ b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,124 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { z } from 'zod' +import { createCollection } from '@tanstack/db' +import { rxdbCollectionOptions } from '../src/rxdb' +import type { + ChangeMessageOrDeleteKeyMessage, + WithVirtualProps, +} from '@tanstack/db' +import type { RxCollection } from 'rxdb/plugins/core' + +const rowSchema = z.object({ + id: z.string().brand<`RxDBRowId`>(), + createdAt: z + .union([z.string(), z.date()]) + .transform((value) => + typeof value === `string` ? new Date(value) : value, + ), + score: z + .union([z.string(), z.number()]) + .transform((value) => (typeof value === `string` ? Number(value) : value)), + label: z.string().default(`untitled`), + note: z.string().nullish(), +}) + +type RowInput = z.input +type RowOutput = z.output +type RowId = RowOutput[`id`] +type ItemOf = T extends Array ? U : T + +const rawInput = { + id: `row-1`, + createdAt: `2026-09-18T12:00:00.000Z`, + score: `42`, + note: null, +} satisfies RowInput + +const synchronizedOutput = { + id: `row-1` as RowId, + createdAt: new Date(`2026-09-18T12:00:00.000Z`), + score: 42, + label: `untitled`, + note: null, +} satisfies RowOutput + +/** + * Adapter mapping for the shared schema input/output law: + * `rxdbCollectionOptions` requires the RxDB document type to equal schema + * output because RxDB supplies sync rows. `getKey`, `compare`, Collection rows, + * and sync change messages observe that output. Public insert and update entry + * points accept schema input. RxDB keys remain strings because the runtime + * reads its string-only `primaryPath`; schema branding does not narrow them. + * + * The assertions observe those production type paths after option and + * Collection inference. Hostile controls reject an input row at the sync + * boundary and an input-shaped `RxCollection`. This partial oracle does not + * execute RxDB subscriptions, provider I/O, or schema parsing. + */ +describe(`RxDB schema transform conformance`, () => { + it(`requires output-shaped RxDB documents`, () => { + const rxCollection = {} as RxCollection< + RowOutput, + unknown, + unknown, + unknown + > + const options = rxdbCollectionOptions({ + rxCollection, + schema: rowSchema, + compare: (left, right) => { + expectTypeOf(left).toEqualTypeOf() + expectTypeOf(right).toEqualTypeOf() + return left.score - right.score + }, + }) + const collection = createCollection(options) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() + expectTypeOf(collection.toArray).toEqualTypeOf< + Array> + >() + + type Insert = ItemOf[0]> + expectTypeOf().toEqualTypeOf() + collection.update(`row-1`, (draft) => { + expectTypeOf(draft).toEqualTypeOf() + }) + + type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0] + type SyncMessage = Parameters[0] + expectTypeOf().toEqualTypeOf< + ChangeMessageOrDeleteKeyMessage + >() + + const assertRxDBBoundary = (write: SyncParams[`write`]) => { + const wrongDate = { + ...synchronizedOutput, + createdAt: rawInput.createdAt, + } + + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error one untransformed field cannot cross the sync boundary + write({ type: `insert`, value: wrongDate }) + // @ts-expect-error RxDB sync must not publish schema input rows + write({ type: `insert`, value: rawInput }) + } + expectTypeOf(assertRxDBBoundary).toBeFunction() + }) + + it(`rejects an input-shaped RxDB collection`, () => { + const inputCollection = {} as RxCollection< + RowInput, + unknown, + unknown, + unknown + > + + // @ts-expect-error provider documents must match schema output + rxdbCollectionOptions({ + rxCollection: inputCollection, + schema: rowSchema, + }) + }) +})