|
| 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 | +}) |
0 commit comments