From 138758e03b371f8c68ee1f462ec99d397a8cd1bf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 10:02:23 +0100 Subject: [PATCH 1/8] test: add adapter schema transform type oracles --- ...ter-schema-transform-conformance.test-d.ts | 154 +++++++++++ .../schema-transform-conformance.test-d.ts | 103 ++++++++ .../schema-transform-conformance.test-d.ts | 244 ++++++++++++++++++ .../schema-transform-conformance.test-d.ts | 107 ++++++++ 4 files changed, 608 insertions(+) create mode 100644 packages/db/tests/adapter-schema-transform-conformance.test-d.ts create mode 100644 packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts create mode 100644 packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts create mode 100644 packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts 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..93beafb093 --- /dev/null +++ b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts @@ -0,0 +1,154 @@ +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 + +/** + * Contract card: + * - Schema output is the stored/read/synchronized row and the input to getKey. + * - Schema input is accepted by insert and exposed as the update draft. + * - The matrix crosses transformed Date/number fields, a branded key, a + * defaulted field, and a nullish field. + * - The input-only synchronized-row controls are deliberate hostile mutants. + */ +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`]) => { + write({ type: `insert`, value: synchronizedOutput }) + // @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`]) => { + write({ type: `insert`, value: synchronizedOutput }) + // @ts-expect-error storage parsing must produce schema output rows + write({ type: `insert`, value: rawInput }) + } + expectTypeOf(assertBoundary).toBeFunction() + + const assertOutput = (row: RowOutput) => { + row.createdAt.getTime() + row.score.toFixed() + // @ts-expect-error output dates do not expose string methods + row.createdAt.toUpperCase() + // @ts-expect-error defaulted output fields are required + const missingDefault: undefined = row.label + return missingDefault + } + 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..fcdfcb6f8c --- /dev/null +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,103 @@ +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 + +/** + * Electric shape rows enter the collection as schema output. Mutation entry + * points accept schema input, while handlers observe validated output. + */ +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(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`]) => { + write({ type: `insert`, value: synchronizedOutput }) + // @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..d84136d07d --- /dev/null +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,244 @@ +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) + .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! as z.output[`score`], + ), + label: z + .string() + .nullable() + .transform((value) => value!), + 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 + +/** + * PowerSync has two independent boundaries: collection mutations use the + * collection schema input, while SQLite synchronization must pass through a + * deserialization schema whose output is exactly the collection output. + */ +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(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(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`]) => { + write({ type: `insert`, value: synchronizedOutput }) + // @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) + }) +}) 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..81b66a290d --- /dev/null +++ b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts @@ -0,0 +1,107 @@ +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 + +/** + * RxDB is the synchronization provider, so its collection document type must + * already be schema output. TanStack mutation entry points remain schema input. + */ +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(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`]) => { + write({ type: `insert`, value: synchronizedOutput }) + // @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, + }) + }) +}) From 1730b1896820cb1b8b9248a02004f9523ca6314b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 19:54:14 +0100 Subject: [PATCH 2/8] test: strengthen adapter output oracle --- .../tests/adapter-schema-transform-conformance.test-d.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts index 93beafb093..73dfa84de6 100644 --- a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts +++ b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts @@ -140,14 +140,13 @@ describe(`local adapter schema transform conformance`, () => { } expectTypeOf(assertBoundary).toBeFunction() - const assertOutput = (row: RowOutput) => { + 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() - // @ts-expect-error defaulted output fields are required - const missingDefault: undefined = row.label - return missingDefault } expectTypeOf(assertOutput).toBeFunction() }) From c8283fc13d0b12a833059a39f93527014f9bf8df Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 20 Sep 2026 19:56:49 +0100 Subject: [PATCH 3/8] test: normalize nullable PowerSync fixtures --- .../tests/schema-transform-conformance.test-d.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 index d84136d07d..6abcf5e1d0 100644 --- a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -34,7 +34,7 @@ const sqliteTransformSchema = z.object({ .number() .nullable() .default(0) - .transform((value) => value) + .transform((value) => value ?? 0) .brand<`Score`>(), label: z .string() @@ -68,12 +68,12 @@ const applicationDeserializer = z.object({ .number() .nullable() .transform( - (value) => value! as z.output[`score`], + (value) => (value ?? 0) as z.output[`score`], ), label: z .string() .nullable() - .transform((value) => value!), + .transform((value) => value ?? `untitled`), note: z.string().nullable(), enabled: z .number() @@ -135,7 +135,7 @@ describe(`PowerSync schema transform conformance`, () => { expectTypeOf(right).toEqualTypeOf() expectTypeOf(left.id).toEqualTypeOf() expectTypeOf(left.created_at).toEqualTypeOf() - expectTypeOf(left.score).toEqualTypeOf() + expectTypeOf(left.score).toEqualTypeOf() expectTypeOf(left.label).toEqualTypeOf() expectTypeOf(left.note).toEqualTypeOf() expectTypeOf(left.enabled).toEqualTypeOf() From 4d40f5e6b86c06ff2026113018d1c39840e01725 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:51:58 +0100 Subject: [PATCH 4/8] test: document adapter schema type oracles --- ...ter-schema-transform-conformance.test-d.ts | 31 +++++++++++++++---- .../schema-transform-conformance.test-d.ts | 12 +++++-- .../schema-transform-conformance.test-d.ts | 16 ++++++++-- .../schema-transform-conformance.test-d.ts | 14 +++++++-- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts index 73dfa84de6..e1f1ac0841 100644 --- a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts +++ b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts @@ -42,12 +42,31 @@ const synchronizedOutput = { type ItemOf = T extends Array ? U : T /** - * Contract card: - * - Schema output is the stored/read/synchronized row and the input to getKey. - * - Schema input is accepted by insert and exposed as the update draft. - * - The matrix crosses transformed Date/number fields, a branded key, a - * defaulted field, and a nullish field. - * - The input-only synchronized-row controls are deliberate hostile mutants. + * 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. */ describe(`local adapter schema transform conformance`, () => { it(`keeps local-only synchronized rows on the output side`, () => { 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 index fcdfcb6f8c..2e6bb31a61 100644 --- a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -42,8 +42,16 @@ const synchronizedOutput = { } satisfies RowOutput /** - * Electric shape rows enter the collection as schema output. Mutation entry - * points accept schema input, while handlers observe validated output. + * 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. + * + * 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`, () => { 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 index 6abcf5e1d0..057493ceef 100644 --- a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -114,9 +114,19 @@ const synchronizedOutput = { } satisfies ApplicationOutput /** - * PowerSync has two independent boundaries: collection mutations use the - * collection schema input, while SQLite synchronization must pass through a - * deserialization schema whose output is exactly the collection output. + * 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. + * + * 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`, () => { 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 index 81b66a290d..3bc0c15f5c 100644 --- a/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts @@ -43,8 +43,16 @@ const synchronizedOutput = { } satisfies RowOutput /** - * RxDB is the synchronization provider, so its collection document type must - * already be schema output. TanStack mutation entry points remain schema input. + * 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. + * + * 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`, () => { @@ -98,9 +106,9 @@ describe(`RxDB schema transform conformance`, () => { unknown > - // @ts-expect-error provider documents must match schema output rxdbCollectionOptions({ rxCollection: inputCollection, + // @ts-expect-error provider documents must match schema output schema: rowSchema, }) }) From 5e5e61e5ad7586306b0767662cb73babb67d1a0b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 10:03:29 +0100 Subject: [PATCH 5/8] test(rxdb): align negative oracle with workspace check --- .../tests/schema-transform-conformance.test-d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3bc0c15f5c..670836c633 100644 --- a/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts @@ -106,9 +106,9 @@ describe(`RxDB schema transform conformance`, () => { unknown > + // @ts-expect-error provider documents must match schema output rxdbCollectionOptions({ rxCollection: inputCollection, - // @ts-expect-error provider documents must match schema output schema: rowSchema, }) }) From 7fb38e3fd98a1c20bff6748454033e654478a801 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 11:59:11 +0100 Subject: [PATCH 6/8] test: strengthen adapter schema type oracles --- ...ter-schema-transform-conformance.test-d.ts | 18 ++++++++++++- .../schema-transform-conformance.test-d.ts | 11 +++++++- .../schema-transform-conformance.test-d.ts | 27 +++++++++++++++++++ .../schema-transform-conformance.test-d.ts | 11 +++++++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts index e1f1ac0841..a8961be4a8 100644 --- a/packages/db/tests/adapter-schema-transform-conformance.test-d.ts +++ b/packages/db/tests/adapter-schema-transform-conformance.test-d.ts @@ -66,7 +66,9 @@ type ItemOf = T extends Array ? U : T * `@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. + * 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`, () => { @@ -104,7 +106,14 @@ describe(`local adapter schema transform conformance`, () => { >() 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 }) } @@ -153,7 +162,14 @@ describe(`local adapter schema transform conformance`, () => { >() 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 }) } 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 index 2e6bb31a61..ec62d0399d 100644 --- a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -46,7 +46,8 @@ const synchronizedOutput = { * `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. + * 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 @@ -78,6 +79,7 @@ describe(`Electric schema transform conformance`, () => { const collection = createCollection(options) expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() expectTypeOf(collection.toArray).toEqualTypeOf< Array> >() @@ -95,7 +97,14 @@ describe(`Electric schema transform conformance`, () => { >() 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 }) } 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 index 057493ceef..c7179d7511 100644 --- a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -120,6 +120,8 @@ const synchronizedOutput = { * 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 @@ -154,6 +156,7 @@ describe(`PowerSync schema transform conformance`, () => { }) const collection = createCollection(options) + expectTypeOf(options.getKey).returns.toEqualTypeOf() expectTypeOf(collection.toArray).toEqualTypeOf< Array> >() @@ -201,6 +204,7 @@ describe(`PowerSync schema transform conformance`, () => { }) const collection = createCollection(options) + expectTypeOf(options.getKey).returns.toEqualTypeOf() expectTypeOf(collection.toArray).toEqualTypeOf< Array> >() @@ -218,7 +222,14 @@ describe(`PowerSync schema transform conformance`, () => { >() 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 }) } @@ -250,5 +261,21 @@ describe(`PowerSync schema transform conformance`, () => { // @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. + powerSyncCollectionOptions({ + database, + table: appSchema.props.rows, + schema: applicationSchema, + // @ts-expect-error public options require exact collection output + deserializationSchema: wrongDeserializer, + onDeserializationError: () => {}, + serializer: { + created_at: (value) => value.toISOString(), + score: (value) => value, + enabled: (value) => (value ? 1 : 0), + }, + }) }) }) 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 index 670836c633..1809e5a615 100644 --- a/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/rxdb-db-collection/tests/schema-transform-conformance.test-d.ts @@ -47,7 +47,8 @@ const synchronizedOutput = { * `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. + * 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 @@ -74,6 +75,7 @@ describe(`RxDB schema transform conformance`, () => { const collection = createCollection(options) expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>() + expectTypeOf(options.getKey).returns.toEqualTypeOf() expectTypeOf(collection.toArray).toEqualTypeOf< Array> >() @@ -91,7 +93,14 @@ describe(`RxDB schema transform conformance`, () => { >() 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 }) } From 86a5e6eadcfe193f1368a03b165d6df7f88430b0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 12:29:35 +0100 Subject: [PATCH 7/8] test(powersync): isolate deserializer type control --- .../tests/schema-transform-conformance.test-d.ts | 5 ----- 1 file changed, 5 deletions(-) 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 index c7179d7511..d059e206b0 100644 --- a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -271,11 +271,6 @@ describe(`PowerSync schema transform conformance`, () => { // @ts-expect-error public options require exact collection output deserializationSchema: wrongDeserializer, onDeserializationError: () => {}, - serializer: { - created_at: (value) => value.toISOString(), - score: (value) => value, - enabled: (value) => (value ? 1 : 0), - }, }) }) }) From 7d019f12344d10234aa2a9cba981b94ccdf01b20 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Tue, 22 Sep 2026 12:42:11 +0100 Subject: [PATCH 8/8] test(powersync): stabilize overload error control --- .../tests/schema-transform-conformance.test-d.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 index d059e206b0..3c11eeaa41 100644 --- a/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/powersync-db-collection/tests/schema-transform-conformance.test-d.ts @@ -264,13 +264,15 @@ describe(`PowerSync schema transform conformance`, () => { // The member probe isolates the schema relation. This public call also // protects overload and configuration-union selection. - powerSyncCollectionOptions({ + const wrongPublicOptions = { database, table: appSchema.props.rows, schema: applicationSchema, - // @ts-expect-error public options require exact collection output deserializationSchema: wrongDeserializer, onDeserializationError: () => {}, - }) + } + + // @ts-expect-error public options require exact collection output + powerSyncCollectionOptions(wrongPublicOptions) }) })