Skip to content

Commit 3ce5366

Browse files
authored
test: add adapter schema transform type oracles (#1863)
* test: add adapter schema transform type oracles * test: strengthen adapter output oracle * test: normalize nullable PowerSync fixtures * test: document adapter schema type oracles * test(rxdb): align negative oracle with workspace check * test: strengthen adapter schema type oracles * test(powersync): isolate deserializer type control * test(powersync): stabilize overload error control
1 parent 47004bd commit 3ce5366

4 files changed

Lines changed: 710 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { z } from 'zod'
3+
import { createCollection } from '../src/index'
4+
import { localOnlyCollectionOptions } from '../src/local-only'
5+
import { localStorageCollectionOptions } from '../src/local-storage'
6+
import type { ChangeMessageOrDeleteKeyMessage } from '../src/types'
7+
import type { OutputWithVirtual } from './utils'
8+
9+
const rowSchema = z.object({
10+
id: z.string().brand<`AdapterRowId`>(),
11+
createdAt: z
12+
.union([z.string(), z.date()])
13+
.transform((value) =>
14+
typeof value === `string` ? new Date(value) : value,
15+
),
16+
score: z
17+
.union([z.string(), z.number()])
18+
.transform((value) => (typeof value === `string` ? Number(value) : value)),
19+
label: z.string().default(`untitled`),
20+
note: z.string().nullish(),
21+
})
22+
23+
type RowInput = z.input<typeof rowSchema>
24+
type RowOutput = z.output<typeof rowSchema>
25+
type RowId = RowOutput[`id`]
26+
27+
const rawInput = {
28+
id: `row-1`,
29+
createdAt: `2026-09-18T12:00:00.000Z`,
30+
score: `42`,
31+
note: null,
32+
} satisfies RowInput
33+
34+
const synchronizedOutput = {
35+
id: `row-1` as RowId,
36+
createdAt: new Date(`2026-09-18T12:00:00.000Z`),
37+
score: 42,
38+
label: `untitled`,
39+
note: null,
40+
} satisfies RowOutput
41+
42+
type ItemOf<T> = T extends Array<infer U> ? U : T
43+
44+
/**
45+
* Which side of a Standard Schema transform belongs at each Collection
46+
* boundary?
47+
*
48+
* Shared law:
49+
* - Mutation entry points accept schema input. An update draft is schema input.
50+
* - A Collection stores and exposes schema output. `getKey`, `compare`, and
51+
* sync change messages therefore use schema output.
52+
*
53+
* Type relation and domain:
54+
* `RowInput` and `RowOutput` come from one schema, but deliberately differ in
55+
* transformed Date and number fields, a branded key, a defaulted field, and a
56+
* nullish field. The compile-time oracle requires every production type path
57+
* to choose the correct side of that relation.
58+
*
59+
* Production paths and observation cut:
60+
* The driver passes the schema through `localOnlyCollectionOptions` and
61+
* `localStorageCollectionOptions`, then creates the public Collection.
62+
* `expectTypeOf` observes callbacks, mutation parameters, Collection rows, and
63+
* sync change messages after TypeScript resolves each public adapter type.
64+
*
65+
* Fault controls and omissions:
66+
* `@ts-expect-error` controls send an input-only row through the sync boundary
67+
* or an invalid value through the mutation boundary. This partial oracle does
68+
* not execute schema parsing, local storage, change publication, or provider
69+
* I/O. Package-specific files map the same law to their production paths. Each
70+
* package owns its fixture so the oracle crosses that package's real compile
71+
* boundary without importing test types from a sibling package.
72+
*/
73+
describe(`local adapter schema transform conformance`, () => {
74+
it(`keeps local-only synchronized rows on the output side`, () => {
75+
const options = localOnlyCollectionOptions({
76+
schema: rowSchema,
77+
getKey: (row) => {
78+
expectTypeOf(row).toEqualTypeOf<RowOutput>()
79+
expectTypeOf(row.id).toEqualTypeOf<RowId>()
80+
expectTypeOf(row.createdAt).toEqualTypeOf<Date>()
81+
expectTypeOf(row.score).toEqualTypeOf<number>()
82+
expectTypeOf(row.label).toEqualTypeOf<string>()
83+
expectTypeOf(row.note).toEqualTypeOf<string | null | undefined>()
84+
return row.id
85+
},
86+
initialData: [synchronizedOutput],
87+
})
88+
const collection = createCollection(options)
89+
90+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>()
91+
expectTypeOf(options.getKey).returns.toEqualTypeOf<RowId>()
92+
expectTypeOf(collection.toArray).toEqualTypeOf<
93+
Array<OutputWithVirtual<RowOutput, RowId>>
94+
>()
95+
96+
type Insert = ItemOf<Parameters<typeof collection.insert>[0]>
97+
expectTypeOf<Insert>().toEqualTypeOf<RowInput>()
98+
collection.update(`row-1` as RowId, (draft) => {
99+
expectTypeOf(draft).toEqualTypeOf<RowInput>()
100+
})
101+
102+
type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0]
103+
type SyncMessage = Parameters<SyncParams[`write`]>[0]
104+
expectTypeOf<SyncMessage>().toEqualTypeOf<
105+
ChangeMessageOrDeleteKeyMessage<RowOutput, RowId>
106+
>()
107+
108+
const assertBoundary = (write: SyncParams[`write`]) => {
109+
const wrongDate = {
110+
...synchronizedOutput,
111+
createdAt: rawInput.createdAt,
112+
}
113+
114+
write({ type: `insert`, value: synchronizedOutput })
115+
// @ts-expect-error one untransformed field cannot cross the sync boundary
116+
write({ type: `insert`, value: wrongDate })
117+
// @ts-expect-error input-only rows have not crossed the schema boundary
118+
write({ type: `insert`, value: rawInput })
119+
}
120+
expectTypeOf(assertBoundary).toBeFunction()
121+
122+
const assertMutationInput = () => {
123+
collection.insert(rawInput)
124+
// @ts-expect-error transformed fields reject unrelated values
125+
collection.insert({ ...rawInput, score: false })
126+
}
127+
expectTypeOf(assertMutationInput).toBeFunction()
128+
})
129+
130+
it(`keeps local-storage synchronized rows on the output side`, () => {
131+
const options = localStorageCollectionOptions({
132+
storageKey: `adapter-schema-transform-conformance`,
133+
schema: rowSchema,
134+
getKey: (row) => {
135+
expectTypeOf(row).toEqualTypeOf<RowOutput>()
136+
return row.id
137+
},
138+
compare: (left, right) => {
139+
expectTypeOf(left).toEqualTypeOf<RowOutput>()
140+
expectTypeOf(right).toEqualTypeOf<RowOutput>()
141+
return left.score - right.score
142+
},
143+
})
144+
const collection = createCollection(options)
145+
146+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>()
147+
expectTypeOf(options.getKey).returns.toEqualTypeOf<RowId>()
148+
expectTypeOf(collection.toArray).toEqualTypeOf<
149+
Array<OutputWithVirtual<RowOutput, RowId>>
150+
>()
151+
152+
type Insert = ItemOf<Parameters<typeof collection.insert>[0]>
153+
expectTypeOf<Insert>().toEqualTypeOf<RowInput>()
154+
collection.update(`row-1` as RowId, (draft) => {
155+
expectTypeOf(draft).toEqualTypeOf<RowInput>()
156+
})
157+
158+
type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0]
159+
type SyncMessage = Parameters<SyncParams[`write`]>[0]
160+
expectTypeOf<SyncMessage>().toEqualTypeOf<
161+
ChangeMessageOrDeleteKeyMessage<RowOutput, RowId>
162+
>()
163+
164+
const assertBoundary = (write: SyncParams[`write`]) => {
165+
const wrongDate = {
166+
...synchronizedOutput,
167+
createdAt: rawInput.createdAt,
168+
}
169+
170+
write({ type: `insert`, value: synchronizedOutput })
171+
// @ts-expect-error one untransformed field cannot cross the sync boundary
172+
write({ type: `insert`, value: wrongDate })
173+
// @ts-expect-error storage parsing must produce schema output rows
174+
write({ type: `insert`, value: rawInput })
175+
}
176+
expectTypeOf(assertBoundary).toBeFunction()
177+
178+
type LocalStorageOutput = ItemOf<typeof collection.toArray>
179+
const assertOutput = (row: LocalStorageOutput) => {
180+
row.createdAt.getTime()
181+
row.score.toFixed()
182+
expectTypeOf(row.label).toEqualTypeOf<string>()
183+
// @ts-expect-error output dates do not expose string methods
184+
row.createdAt.toUpperCase()
185+
}
186+
expectTypeOf(assertOutput).toBeFunction()
187+
})
188+
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { describe, expectTypeOf, it } from 'vitest'
2+
import { z } from 'zod'
3+
import { createCollection } from '@tanstack/db'
4+
import { electricCollectionOptions } from '../src/electric'
5+
import type {
6+
ChangeMessageOrDeleteKeyMessage,
7+
WithVirtualProps,
8+
} from '@tanstack/db'
9+
10+
const rowSchema = z.object({
11+
id: z.string().brand<`ElectricRowId`>(),
12+
createdAt: z
13+
.union([z.string(), z.date()])
14+
.transform((value) =>
15+
typeof value === `string` ? new Date(value) : value,
16+
),
17+
score: z
18+
.union([z.string(), z.number()])
19+
.transform((value) => (typeof value === `string` ? Number(value) : value)),
20+
label: z.string().default(`untitled`),
21+
note: z.string().nullish(),
22+
})
23+
24+
type RowInput = z.input<typeof rowSchema>
25+
type RowOutput = z.output<typeof rowSchema>
26+
type RowId = RowOutput[`id`]
27+
type ItemOf<T> = T extends Array<infer U> ? U : T
28+
29+
const rawInput = {
30+
id: `row-1`,
31+
createdAt: `2026-09-18T12:00:00.000Z`,
32+
score: `42`,
33+
note: null,
34+
} satisfies RowInput
35+
36+
const synchronizedOutput = {
37+
id: `row-1` as RowId,
38+
createdAt: new Date(`2026-09-18T12:00:00.000Z`),
39+
score: 42,
40+
label: `untitled`,
41+
note: null,
42+
} satisfies RowOutput
43+
44+
/**
45+
* Adapter mapping for the shared schema input/output law:
46+
* `electricCollectionOptions` carries Electric shape rows into sync change
47+
* messages as schema output. `getKey`, `compare`, Collection rows, and handler
48+
* mutations observe that output. Public insert and update entry points accept
49+
* schema input. Electric deliberately keeps its public Collection key domain
50+
* at `string | number`; it does not infer the schema's branded ID type.
51+
*
52+
* The assertions observe those production type paths after overload
53+
* resolution. Hostile controls reject an untransformed shape row at the sync
54+
* boundary and an invalid value at the mutation boundary. This partial oracle
55+
* does not execute Shape parsing, network I/O, or mutation-handler timing.
56+
*/
57+
describe(`Electric schema transform conformance`, () => {
58+
it(`keeps the shape and mutation sides distinct`, () => {
59+
const options = electricCollectionOptions({
60+
shapeOptions: { url: `https://example.com/v1/shape` },
61+
schema: rowSchema,
62+
getKey: (row) => {
63+
expectTypeOf(row).toEqualTypeOf<RowOutput>()
64+
expectTypeOf(row.id).toEqualTypeOf<RowId>()
65+
expectTypeOf(row.createdAt).toEqualTypeOf<Date>()
66+
expectTypeOf(row.score).toEqualTypeOf<number>()
67+
expectTypeOf(row.label).toEqualTypeOf<string>()
68+
expectTypeOf(row.note).toEqualTypeOf<string | null | undefined>()
69+
return row.id
70+
},
71+
compare: (left, right) => left.score - right.score,
72+
onInsert: ({ transaction }) => {
73+
expectTypeOf(
74+
transaction.mutations[0].modified,
75+
).toEqualTypeOf<RowOutput>()
76+
return Promise.resolve()
77+
},
78+
})
79+
const collection = createCollection(options)
80+
81+
expectTypeOf(options.getKey).parameters.toEqualTypeOf<[RowOutput]>()
82+
expectTypeOf(options.getKey).returns.toEqualTypeOf<string | number>()
83+
expectTypeOf(collection.toArray).toEqualTypeOf<
84+
Array<WithVirtualProps<RowOutput, string | number>>
85+
>()
86+
87+
type Insert = ItemOf<Parameters<typeof collection.insert>[0]>
88+
expectTypeOf<Insert>().toEqualTypeOf<RowInput>()
89+
collection.update(`row-1`, (draft) => {
90+
expectTypeOf(draft).toEqualTypeOf<RowInput>()
91+
})
92+
93+
type SyncParams = Parameters<(typeof options)[`sync`][`sync`]>[0]
94+
type SyncMessage = Parameters<SyncParams[`write`]>[0]
95+
expectTypeOf<SyncMessage>().toEqualTypeOf<
96+
ChangeMessageOrDeleteKeyMessage<RowOutput, string | number>
97+
>()
98+
99+
const assertShapeBoundary = (write: SyncParams[`write`]) => {
100+
const wrongDate = {
101+
...synchronizedOutput,
102+
createdAt: rawInput.createdAt,
103+
}
104+
105+
write({ type: `insert`, value: synchronizedOutput })
106+
// @ts-expect-error one untransformed field cannot cross the sync boundary
107+
write({ type: `insert`, value: wrongDate })
108+
// @ts-expect-error an untransformed shape row is not collection output
109+
write({ type: `insert`, value: rawInput })
110+
}
111+
expectTypeOf(assertShapeBoundary).toBeFunction()
112+
113+
const assertMutationInput = () => {
114+
collection.insert(rawInput)
115+
// @ts-expect-error mutation input does not accept unrelated numeric shapes
116+
collection.insert({ ...rawInput, score: false })
117+
}
118+
expectTypeOf(assertMutationInput).toBeFunction()
119+
})
120+
})

0 commit comments

Comments
 (0)