@zmdb/schema 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +30 -0
  3. package/dist/custom-types/index.d.ts +41 -0
  4. package/dist/custom-types/index.d.ts.map +1 -0
  5. package/dist/custom-types/index.js +32 -0
  6. package/dist/custom-types/index.js.map +1 -0
  7. package/dist/derive/index.d.ts +122 -0
  8. package/dist/derive/index.d.ts.map +1 -0
  9. package/dist/derive/index.js +13 -0
  10. package/dist/derive/index.js.map +1 -0
  11. package/dist/derive/query.d.ts +62 -0
  12. package/dist/derive/query.d.ts.map +1 -0
  13. package/dist/derive/query.js +18 -0
  14. package/dist/derive/query.js.map +1 -0
  15. package/dist/dto/index.d.ts +224 -0
  16. package/dist/dto/index.d.ts.map +1 -0
  17. package/dist/dto/index.js +118 -0
  18. package/dist/dto/index.js.map +1 -0
  19. package/dist/entity-modeling/index.d.ts +12 -0
  20. package/dist/entity-modeling/index.d.ts.map +1 -0
  21. package/dist/entity-modeling/index.js +28 -0
  22. package/dist/entity-modeling/index.js.map +1 -0
  23. package/dist/index.d.ts +151 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +84 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/ir/index.d.ts +374 -0
  28. package/dist/ir/index.d.ts.map +1 -0
  29. package/dist/ir/index.js +735 -0
  30. package/dist/ir/index.js.map +1 -0
  31. package/dist/ir/validation-shape.d.ts +46 -0
  32. package/dist/ir/validation-shape.d.ts.map +1 -0
  33. package/dist/ir/validation-shape.js +130 -0
  34. package/dist/ir/validation-shape.js.map +1 -0
  35. package/dist/ir/vocabulary.d.ts +54 -0
  36. package/dist/ir/vocabulary.d.ts.map +1 -0
  37. package/dist/ir/vocabulary.js +51 -0
  38. package/dist/ir/vocabulary.js.map +1 -0
  39. package/dist/naming/index.d.ts +26 -0
  40. package/dist/naming/index.d.ts.map +1 -0
  41. package/dist/naming/index.js +147 -0
  42. package/dist/naming/index.js.map +1 -0
  43. package/dist/openapi/index.d.ts +57 -0
  44. package/dist/openapi/index.d.ts.map +1 -0
  45. package/dist/openapi/index.js +98 -0
  46. package/dist/openapi/index.js.map +1 -0
  47. package/dist/relations/index.d.ts +23 -0
  48. package/dist/relations/index.d.ts.map +1 -0
  49. package/dist/relations/index.js +98 -0
  50. package/dist/relations/index.js.map +1 -0
  51. package/dist/tags/index.d.ts +261 -0
  52. package/dist/tags/index.d.ts.map +1 -0
  53. package/dist/tags/index.js +64 -0
  54. package/dist/tags/index.js.map +1 -0
  55. package/package.json +82 -0
  56. package/src/custom-types/index.ts +59 -0
  57. package/src/derive/index.ts +224 -0
  58. package/src/derive/query.ts +128 -0
  59. package/src/dto/index.ts +395 -0
  60. package/src/entity-modeling/index.ts +33 -0
  61. package/src/index.ts +263 -0
  62. package/src/ir/index.ts +1085 -0
  63. package/src/ir/validation-shape.ts +145 -0
  64. package/src/ir/vocabulary.ts +56 -0
  65. package/src/naming/index.ts +159 -0
  66. package/src/openapi/index.ts +133 -0
  67. package/src/relations/index.ts +134 -0
  68. package/src/tags/index.ts +284 -0
@@ -0,0 +1,1085 @@
1
+ // @zmdb/schema/ir — the intermediate representation every walker consumes.
2
+ //
3
+ // This module exists because the repo grew four independent walkers over the same
4
+ // column metadata (`PLAN-type-first.md` §1): the AOT's `emitCheck`, its runtime
5
+ // `matches`/`collectIssues`, `openapi`'s `scalarSchema`, and the repository's
6
+ // `valueMatchesColumn`. Each had its own vocabulary and its own gaps, and they
7
+ // disagreed with each other. Adding a fifth for tagged types would have made it
8
+ // worse, so the tags land on top of one IR instead.
9
+ //
10
+ // FRONT-END IR BACK-ENDS
11
+ // ┌── predicate JS (is)
12
+ // tagged type ────▶ SchemaIR / TypeIR ─────┼── JSON Schema (openapi/llm/web)
13
+ // (pure data) ├── runtime walker (fallback, repository)
14
+ // ├── schema value (schemaFromIR)
15
+ // └── SQL / DDL (query-compiler)
16
+ //
17
+ // There used to be a second front-end — `irFromSchema`, which read the IR back out of a
18
+ // `defineSchema` value — and its whole purpose was to be the thing the tagged front-end
19
+ // was proved equal to. It went when `defineSchema` did. `schemaFromIR` is what remains,
20
+ // and it points the other way: the value is now a projection of the IR, not a source of it.
21
+ //
22
+ // Two hard constraints on everything below:
23
+ //
24
+ // 1. **The IR is serialisable JSON.** No symbols, no functions, no class
25
+ // instances. That is what lets the codegen CLI write it to disk, lets golden
26
+ // tests snapshot it, and keeps `typescript` out of every runtime bundle.
27
+ // 2. **`sql` stays abstract.** A `timestamp` column carries `'timestamp'`, never
28
+ // `'timestamptz'`. A column has three types — wire, app and db — and each
29
+ // layer renders the one it owns (plan D3). Rendering a dialect's spelling is
30
+ // the dialect's job; baking one in here would force every other back-end to
31
+ // parse it back out.
32
+
33
+ import { type ColumnMeta, type CoreSchema, type SqlType, type ValidationRule } from '../index.js';
34
+ import { KNOWN_CONSTRAINT_KINDS } from './vocabulary.js';
35
+
36
+ export { KNOWN_CONSTRAINT_KINDS, TAG_NAMES } from './vocabulary.js';
37
+ export type { ConstraintKind, TagField } from './vocabulary.js';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Type IR
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Numeric and string bounds. Deliberately a flat record rather than the old
45
+ * `ValidationRule[]`: the four walkers disagreed partly because `TypeDescriptor`
46
+ * had `minimum` and `maxLength` but no `maximum` and no `minLength`, so a
47
+ * `Min<18> & Max<120>` column validated differently depending on which one you
48
+ * asked.
49
+ */
50
+ export interface Constraints {
51
+ readonly minimum?: number;
52
+ readonly maximum?: number;
53
+ readonly minLength?: number;
54
+ readonly maxLength?: number;
55
+ readonly pattern?: string;
56
+ }
57
+
58
+ /**
59
+ * `integer` is separate from `number` so an emitter can produce
60
+ * `Number.isInteger`, and `date` is separate from `string` so the app type and
61
+ * the wire type can differ without either lying (plan D3).
62
+ */
63
+ export type ScalarKind = 'string' | 'number' | 'integer' | 'bigint' | 'boolean' | 'date';
64
+
65
+ export interface ScalarIR {
66
+ readonly kind: 'scalar';
67
+ readonly scalar: ScalarKind;
68
+ /** Protobuf scalar spelling, when the declaration made width/signedness explicit. */
69
+ readonly proto?: ProtoScalar;
70
+ /** JSON Schema `format`, when the scalar has a conventional one. */
71
+ readonly format?: string;
72
+ readonly constraints?: Constraints;
73
+ }
74
+
75
+ export interface LiteralIR {
76
+ readonly kind: 'literal';
77
+ readonly value: string | number | boolean;
78
+ }
79
+
80
+ export interface NullIR {
81
+ readonly kind: 'null';
82
+ }
83
+
84
+ export interface UndefinedIR {
85
+ readonly kind: 'undefined';
86
+ }
87
+
88
+ export interface UnknownIR {
89
+ readonly kind: 'unknown';
90
+ }
91
+
92
+ export interface UnionIR {
93
+ readonly kind: 'union';
94
+ readonly members: readonly TypeIR[];
95
+ }
96
+
97
+ export interface ArrayIR {
98
+ readonly kind: 'array';
99
+ readonly element: TypeIR;
100
+ readonly constraints?: Constraints;
101
+ }
102
+
103
+ export interface TupleIR {
104
+ readonly kind: 'tuple';
105
+ readonly elements: readonly TypeIR[];
106
+ }
107
+
108
+ export interface ObjectIR {
109
+ readonly kind: 'object';
110
+ /** Set when the type had a name, so emitters can hoist a shared helper. */
111
+ readonly name?: string;
112
+ readonly properties: readonly PropertyIR[];
113
+ }
114
+
115
+ /** A back-reference to a named `ObjectIR` already on the stack. Cycle guard. */
116
+ export interface RefIR {
117
+ readonly kind: 'ref';
118
+ readonly name: string;
119
+ }
120
+
121
+ /**
122
+ * A first-class node, not an absence. A gap has to be visible: the transformer
123
+ * bug fixed in `f70186c6` happened because an unrecognised type produced a
124
+ * *partial* answer that looked like a real one. An `unsupported` node makes the
125
+ * emitter refuse and the build fail with the reason (plan D4).
126
+ */
127
+ export interface UnsupportedIR {
128
+ readonly kind: 'unsupported';
129
+ readonly reason: string;
130
+ /** The type as written, when the producer can recover it. */
131
+ readonly source?: string;
132
+ }
133
+
134
+ export type TypeIR =
135
+ | ScalarIR
136
+ | LiteralIR
137
+ | NullIR
138
+ | UndefinedIR
139
+ | UnknownIR
140
+ | UnionIR
141
+ | ArrayIR
142
+ | TupleIR
143
+ | ObjectIR
144
+ | RefIR
145
+ | UnsupportedIR;
146
+
147
+ export interface PropertyIR {
148
+ readonly name: string;
149
+ readonly type: TypeIR;
150
+ readonly optional: boolean;
151
+ readonly readonly: boolean;
152
+ /** Stable protobuf identity. Required only when this object is emitted as a message. */
153
+ readonly protoField?: number;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Schema IR
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export interface TableOptions {
161
+ readonly shardKey?: readonly string[];
162
+ readonly sortKey?: readonly string[];
163
+ readonly rowstore?: true;
164
+ }
165
+
166
+ /**
167
+ * The four cardinalities, as data so a reader can check a string against them.
168
+ *
169
+ * Written this way round — the list first, the type derived — because `../tags` fixes
170
+ * `kind` to a literal per tag, but the reflection reads it back off the checker as a
171
+ * `string`. Deriving the type from the list is what lets that read be a check rather than
172
+ * an assertion, and keeps the two from drifting.
173
+ */
174
+ export const RELATION_KINDS = ['manyToOne', 'oneToMany', 'oneToOne', 'manyToMany'] as const;
175
+
176
+ export type RelationKind = (typeof RELATION_KINDS)[number];
177
+
178
+ export type ReferentialAction = 'cascade' | 'restrict' | 'set null' | 'set default' | 'no action';
179
+
180
+ export interface RelationIR {
181
+ readonly name: string;
182
+ readonly relation: RelationKind;
183
+ readonly target: string;
184
+ /** The foreign-key column, or the join table for `manyToMany`. */
185
+ readonly via: string;
186
+ }
187
+
188
+ export interface ForeignKeyIR {
189
+ readonly columns: readonly string[];
190
+ readonly targetTable: string;
191
+ readonly targetColumns: readonly string[];
192
+ }
193
+
194
+ /** A SQL type installed by a database extension rather than the closed core vocabulary. */
195
+ export interface ExtensionType {
196
+ readonly extension: string;
197
+ readonly name: string;
198
+ readonly args?: readonly (string | number)[];
199
+ }
200
+
201
+ export interface ColumnIR {
202
+ readonly name: string;
203
+ /** The database column name resolved by the build-time naming strategy. */
204
+ readonly physicalName: string;
205
+ /** Abstract SQL type. The dialect renders the spelling — see plan D3. */
206
+ readonly sql: SqlType | ExtensionType;
207
+ readonly nullable: boolean;
208
+ readonly primaryKey: boolean;
209
+ /** Database-generated. Absent from `CreateDTO`, not merely optional. */
210
+ readonly serial: boolean;
211
+ readonly unique: boolean;
212
+ readonly hasDefault: boolean;
213
+ readonly sensitive: boolean;
214
+ readonly length?: number;
215
+ readonly precision?: readonly [number, number];
216
+ /**
217
+ * The permitted values, **sorted**.
218
+ *
219
+ * Not declaration order, and deliberately so. The producer reads
220
+ * `'free' | 'pro' | 'enterprise'` back out of the checker, which normalises string-literal
221
+ * union members and hands them over in its own order — declaration order is simply not
222
+ * recoverable from a type. A set of permitted values has no order to lose, so sorting is
223
+ * what makes this a function of the declaration rather than of the compiler's internals.
224
+ *
225
+ * Emitters may therefore rely on this being stable across TypeScript versions, which the
226
+ * checker's order is not.
227
+ */
228
+ readonly enum?: readonly string[];
229
+ readonly references?: string;
230
+ readonly onDelete?: ReferentialAction;
231
+ readonly onUpdate?: ReferentialAction;
232
+ readonly codec?: string;
233
+ /**
234
+ * The declared wire type (`WireAs<W>`), for a column whose wire form does not follow
235
+ * from `sql`. A codec's does not: only the declaration knows whether `Money` crosses
236
+ * as a decimal string, a `{ cents }` object or a pair.
237
+ */
238
+ readonly wire?: TypeIR;
239
+ readonly constraints: Constraints;
240
+ /** Named custom rules (`Rule<'name'>`) an emitter must resolve or refuse. */
241
+ readonly rules: readonly string[];
242
+ readonly default?: unknown;
243
+ /**
244
+ * The declared app type: a `json` column's payload shape, or the type behind a codec.
245
+ *
246
+ * There is nowhere in `ColumnMeta` for this to go, which is why `CoreSchema` carries the
247
+ * IR rather than only its projection — a `Settings & Sql<'json'>` read back off the flags
248
+ * alone is just `json`, and the emitted validator would check nothing about the payload.
249
+ */
250
+ readonly payload?: TypeIR;
251
+ }
252
+
253
+ export interface SchemaIR {
254
+ readonly table: string;
255
+ /** The database table name resolved by the build-time naming strategy. */
256
+ readonly physicalTable: string;
257
+ readonly columns: readonly ColumnIR[];
258
+ readonly primaryKey: readonly string[];
259
+ readonly relations: readonly RelationIR[];
260
+ readonly foreignKeys: readonly ForeignKeyIR[];
261
+ readonly ftsTable?: string | boolean;
262
+ /** The nullable timestamp column managed by soft delete. */
263
+ readonly softDelete?: { readonly column: string };
264
+ readonly tableOptions?: TableOptions;
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Vocabulary coverage (REQ-TF-1)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /** Every `SqlType`, as data. `sql-types.type-test.ts` asserts exhaustiveness. */
272
+ export const SQL_TYPES = [
273
+ 'serial',
274
+ 'integer',
275
+ 'bigint',
276
+ 'numeric',
277
+ 'text',
278
+ 'varchar',
279
+ 'boolean',
280
+ 'timestamp',
281
+ 'json',
282
+ 'jsonEnum',
283
+ ] as const satisfies readonly SqlType[];
284
+
285
+ /** The closed protobuf scalar vocabulary carried by {@link ScalarIR}. */
286
+ export const PROTO_SCALARS = [
287
+ 'int32',
288
+ 'int64',
289
+ 'uint32',
290
+ 'uint64',
291
+ 'sint32',
292
+ 'sint64',
293
+ 'fixed32',
294
+ 'fixed64',
295
+ 'sfixed32',
296
+ 'sfixed64',
297
+ 'float',
298
+ 'double',
299
+ 'bool',
300
+ 'string',
301
+ 'bytes',
302
+ ] as const;
303
+
304
+ export type ProtoScalar = (typeof PROTO_SCALARS)[number];
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Back-end: IR → schema value (REQ-TF-10)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * The constraints, in the spelling `ColumnMeta.validation` uses.
312
+ *
313
+ * Emitted in `KNOWN_CONSTRAINT_KINDS` order rather than any order they arrived in: the
314
+ * IR holds them in a record, which has none to preserve, so a fixed order is the only
315
+ * one that makes the output a function of the input. Named custom rules keep their name
316
+ * and lose their arguments, because `ColumnIR.rules` only ever held the name.
317
+ */
318
+ function validationFromIR(col: ColumnIR): readonly ValidationRule[] {
319
+ const rules: ValidationRule[] = [];
320
+ for (const kind of KNOWN_CONSTRAINT_KINDS) {
321
+ const value = col.constraints[kind];
322
+ if (value !== undefined) rules.push({ kind, value });
323
+ }
324
+ for (const kind of col.rules) rules.push({ kind });
325
+ return rules;
326
+ }
327
+
328
+ /**
329
+ * A column's metadata. Flags are written only when set — a plain `text` column gives
330
+ * `{ nullable: false }` and nothing else — so the generated literal stays as small as
331
+ * the declaration it came from.
332
+ */
333
+ function columnMetaFromIR(col: ColumnIR): ColumnMeta {
334
+ const validation = validationFromIR(col);
335
+ return {
336
+ type: col.sql,
337
+ flags: {
338
+ nullable: col.nullable,
339
+ ...(col.primaryKey ? { primaryKey: true } : {}),
340
+ ...(col.serial ? { autoIncrement: true } : {}),
341
+ ...(col.unique ? { unique: true } : {}),
342
+ ...(col.hasDefault ? { hasDefault: true } : {}),
343
+ ...(col.sensitive ? { sensitive: true } : {}),
344
+ ...(col.length === undefined ? {} : { length: col.length }),
345
+ ...(col.enum === undefined ? {} : { enum: col.enum }),
346
+ },
347
+ ...(col.default === undefined ? {} : { default: col.default }),
348
+ ...(col.references === undefined ? {} : { references: { target: col.references } }),
349
+ ...(validation.length === 0 ? {} : { validation }),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * The schema value, from the IR — and the only way to get one (REQ-TF-10).
355
+ *
356
+ * The query compiler wants the table name and the column types as *data*, and this is
357
+ * data. `@zmdb/compiler` emits the result of this function as a frozen literal, so
358
+ * `schemaOf<T>()` costs nothing at runtime and the tagged type stays the only place the
359
+ * schema is written.
360
+ *
361
+ * The IR itself is carried through on `ir` rather than left behind. Three things a
362
+ * `ColumnMeta` has no field for — `Numeric<P, S>` precision, a `Codec<'Name'>`, a `json`
363
+ * payload shape — used to be dropped here and were unrecoverable afterwards, because the
364
+ * only way back to an IR was to walk the flags. Keeping the IR makes the value a superset
365
+ * of what it projects rather than a lossy copy, and it is what let `irFromSchema` go: no
366
+ * consumer has to reconstruct from `columns` what the declaration already said.
367
+ *
368
+ * Nothing is registered. A generated literal is not a call and has nowhere to do that,
369
+ * and a global "which schema was that?" lookup is for code that has lost track of its own
370
+ * schema — which type-first code, by construction, has not.
371
+ */
372
+ export function schemaFromIR(ir: SchemaIR): CoreSchema<string> {
373
+ const key = new Set(ir.primaryKey);
374
+ const columnNames = new Set(ir.columns.map(column => column.name));
375
+ const missing = ir.primaryKey.filter(column => !columnNames.has(column));
376
+ if (missing.length > 0) {
377
+ throw new Error(
378
+ `${ir.table}: primary key names ${missing.map(column => `"${column}"`).join(', ')}, ` +
379
+ `${missing.length === 1 ? 'a column' : 'columns'} the table does not have`,
380
+ );
381
+ }
382
+
383
+ const normalizedColumns = ir.columns.map(column => {
384
+ const primaryKey = key.has(column.name);
385
+ return column.primaryKey === primaryKey ? column : { ...column, primaryKey };
386
+ });
387
+ const normalizedIr = normalizedColumns.every((column, index) => column === ir.columns[index])
388
+ ? ir
389
+ : { ...ir, columns: normalizedColumns };
390
+
391
+ const physicalNames = new Map<string, string>();
392
+ for (const column of normalizedIr.columns) {
393
+ const previous = physicalNames.get(column.physicalName);
394
+ if (previous !== undefined) {
395
+ throw new Error(
396
+ `${normalizedIr.table}: \`${previous}\` and \`${column.name}\` both map to the column ` +
397
+ `\`${column.physicalName}\``,
398
+ );
399
+ }
400
+ physicalNames.set(column.physicalName, column.name);
401
+ }
402
+
403
+ const physicalByProperty = new Map(normalizedIr.columns.map(column => [column.name, column.physicalName]));
404
+ const columns: Record<string, ColumnMeta> = {};
405
+ for (const col of normalizedIr.columns) columns[col.physicalName] = columnMetaFromIR(col);
406
+
407
+ return {
408
+ table: normalizedIr.physicalTable,
409
+ columns,
410
+ primaryKey: normalizedIr.primaryKey.map(column => physicalByProperty.get(column) ?? column),
411
+ references: normalizedIr.columns.flatMap(col =>
412
+ col.references === undefined ? [] : [{ column: col.physicalName, target: col.references }],
413
+ ),
414
+ ...(normalizedIr.ftsTable === undefined ? {} : { ftsTable: normalizedIr.ftsTable }),
415
+ ir: normalizedIr,
416
+ };
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // The three types of a column (plan D3 / REQ-TF-13)
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function withNull(type: TypeIR, nullable: boolean): TypeIR {
424
+ return nullable ? { kind: 'union', members: [type, { kind: 'null' }] } : type;
425
+ }
426
+
427
+ function constrained(scalar: ScalarKind, col: ColumnIR, format?: string): ScalarIR {
428
+ const withLength: Constraints =
429
+ col.length !== undefined && col.constraints.maxLength === undefined
430
+ ? { ...col.constraints, maxLength: col.length }
431
+ : col.constraints;
432
+ const derived = format === undefined ? undefined : FORMAT_PATTERNS[format];
433
+ const constraints: Constraints =
434
+ derived !== undefined && withLength.pattern === undefined ? { ...withLength, pattern: derived } : withLength;
435
+ return {
436
+ kind: 'scalar',
437
+ scalar,
438
+ ...(format === undefined ? {} : { format }),
439
+ ...(Object.keys(constraints).length === 0 ? {} : { constraints }),
440
+ };
441
+ }
442
+
443
+ function extensionDimension(type: ExtensionType): number | undefined {
444
+ if (type.name !== 'vector') return undefined;
445
+ const dimension = type.args?.[0];
446
+ return typeof dimension === 'number' && Number.isInteger(dimension) && dimension >= 0 ? dimension : undefined;
447
+ }
448
+
449
+ function extensionAppBase(col: ColumnIR, type: ExtensionType): TypeIR {
450
+ if (type.name === 'vector') {
451
+ const dimension = extensionDimension(type);
452
+ return {
453
+ kind: 'array',
454
+ element: { kind: 'scalar', scalar: 'number' },
455
+ ...(dimension === undefined ? {} : { constraints: { minLength: dimension, maxLength: dimension } }),
456
+ };
457
+ }
458
+ if (type.name === 'citext') return constrained('string', col);
459
+ return {
460
+ kind: 'unsupported',
461
+ reason:
462
+ `extension type "${type.name}" on column "${col.name}" needs its declared application shape ` +
463
+ 'carried in the IR',
464
+ };
465
+ }
466
+
467
+ /**
468
+ * The **app** type: what handler code sees. A `timestamp` is a `Date` here, and
469
+ * a `bigint` is a `bigint`.
470
+ */
471
+ export function appTypeOf(col: ColumnIR): TypeIR {
472
+ return withNull(appBaseOf(col), col.nullable);
473
+ }
474
+
475
+ function appBaseOf(col: ColumnIR): TypeIR {
476
+ if (typeof col.sql !== 'string') {
477
+ if (col.sql.name === 'vector' || col.sql.name === 'citext') return extensionAppBase(col, col.sql);
478
+ if (col.payload !== undefined) return col.payload;
479
+ return extensionAppBase(col, col.sql);
480
+ }
481
+
482
+ // A declared app type wins over the SQL type it is stored as. `amount: Money &
483
+ // Sql<'integer'> & Codec<'Money'>` is an integer in the database and a `Money` in the
484
+ // app, and a validator that checked `integer` here would reject every valid value.
485
+ if (col.payload !== undefined) return col.payload;
486
+
487
+ switch (col.sql) {
488
+ case 'serial':
489
+ case 'integer':
490
+ return constrained('integer', col);
491
+ case 'bigint':
492
+ return constrained('bigint', col);
493
+ case 'numeric':
494
+ return constrained('number', col);
495
+ case 'text':
496
+ case 'varchar':
497
+ return constrained('string', col);
498
+ case 'boolean':
499
+ return { kind: 'scalar', scalar: 'boolean' };
500
+ case 'timestamp':
501
+ return { kind: 'scalar', scalar: 'date' };
502
+ case 'jsonEnum':
503
+ return col.enum === undefined || col.enum.length === 0
504
+ ? constrained('string', col)
505
+ : { kind: 'union', members: col.enum.map(value => ({ kind: 'literal', value }) as const) };
506
+ case 'json':
507
+ return JSON_CONTAINER;
508
+ }
509
+ }
510
+
511
+ /**
512
+ * A `json` column whose payload shape is not known: anything JSON puts in a column, and
513
+ * nothing else. An object with no declared properties accepts any record, and an array of
514
+ * `unknown` accepts any array, so together they are "not a primitive".
515
+ *
516
+ * Not `{ kind: 'unknown' }`, which accepts `123`. A declaration that says what the payload
517
+ * is — `lines: Line[] & Sql<'json'>` — gets that type instead, via `ColumnIR.payload`; this
518
+ * is the answer for a bare `object & Sql<'json'>`, which really does permit any record. It
519
+ * is the weakest true statement rather than no statement, which is the difference between a
520
+ * validator that rejects `settings: 123` and one that does not.
521
+ */
522
+ const JSON_CONTAINER: TypeIR = {
523
+ kind: 'union',
524
+ members: [
525
+ { kind: 'object', properties: [] },
526
+ { kind: 'array', element: { kind: 'unknown' } },
527
+ ],
528
+ };
529
+
530
+ /**
531
+ * The assertion behind a `format`, as a `pattern`.
532
+ *
533
+ * `format` is an annotation in JSON Schema, not an assertion — a document may say
534
+ * `date-time` and a conforming validator may check nothing. Neither the runtime walk nor the
535
+ * emitter reads `format` at all, so a wire type that only said `{scalar:'string',
536
+ * format:'date-time'}` accepted `"tomorrow"`, and plan D3's claim that a `Wire<T>` validator
537
+ * checks the ISO string was not true of any validator. Lowering it to a `pattern` makes it
538
+ * true through machinery that already exists in both walks, which is the reason it is spelled
539
+ * this way rather than as a sixth constraint kind: a new keyword would need the emitter, the
540
+ * walker and their equivalence test, and would then check exactly what a pattern checks.
541
+ *
542
+ * `date-time` is RFC 3339, so the offset is **required**. `2020-01-01T00:00:00` is a valid
543
+ * ISO-8601 string and `new Date()` reads it as local time, which is the same lost-offset bug
544
+ * `TIMESTAMPTZ` exists to prevent — the wire is where that has to be refused, because by the
545
+ * time it is a `Date` the offset it was read at is gone.
546
+ *
547
+ * `int64` is `asBigInt`'s own `DECIMAL`, so what the wire validator accepts and what the
548
+ * decoder can convert are one expression rather than two that agree today.
549
+ */
550
+ /** What `asBigInt` will convert. Declared here so the wire pattern is not a second copy. */
551
+ const DECIMAL = /^-?\d+$/;
552
+
553
+ const FORMAT_PATTERNS: Readonly<Record<string, string>> = {
554
+ 'date-time': '^\\d{4}-\\d{2}-\\d{2}[Tt ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$',
555
+ int64: DECIMAL.source,
556
+ };
557
+
558
+ /**
559
+ * The **wire** type: what a JSON body actually contains. A `timestamp` is an
560
+ * ISO-8601 string, because a `Date` cannot survive JSON, and a `bigint` is a
561
+ * string for the same reason. Anything else matches the app type.
562
+ */
563
+ export function wireTypeOf(col: ColumnIR): TypeIR {
564
+ if (col.wire !== undefined) return withNull(col.wire, col.nullable);
565
+ if (col.codec !== undefined) {
566
+ // A gap, and gaps are visible (plan D4). A codec exists because the app type is not
567
+ // the stored type; what it puts on the wire is a third choice that nothing but the
568
+ // declaration knows, and guessing "the same as the app type" is how a `Money`
569
+ // instance reaches `JSON.stringify`.
570
+ return {
571
+ kind: 'unsupported',
572
+ reason: `the codec "${col.codec}" does not say what column "${col.name}" looks like on the wire; add WireAs<…> to the declaration`,
573
+ };
574
+ }
575
+ if (typeof col.sql !== 'string') return appTypeOf(col);
576
+ if (col.sql === 'timestamp') return withNull(constrained('string', col, 'date-time'), col.nullable);
577
+ if (col.sql === 'bigint') return withNull(constrained('string', col, 'int64'), col.nullable);
578
+ return appTypeOf(col);
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Back-end: IR → JSON Schema
583
+ // ---------------------------------------------------------------------------
584
+
585
+ export type Variant = 'entity' | 'create' | 'update' | 'get' | 'list' | 'search';
586
+
587
+ export interface JsonSchemaObject {
588
+ readonly type: 'object';
589
+ readonly properties: Readonly<Record<string, unknown>>;
590
+ readonly required: readonly string[];
591
+ }
592
+
593
+ /** A JSON-serialisable value, used by build artifacts that carry schema projections. */
594
+ export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue };
595
+
596
+ /**
597
+ * A single column's JSON Schema. Emitted from the **wire** type, which is why a
598
+ * `timestamp` becomes `{type:'string',format:'date-time'}` here and a `Date` in
599
+ * `Entity<T>` — one column, two correct answers, each in its own layer.
600
+ */
601
+ export function jsonSchemaForColumn(col: ColumnIR): Record<string, unknown> {
602
+ const base: Record<string, JsonValue> = {};
603
+
604
+ const declared = declaredWireKeywords(col);
605
+ if (declared) return nullableType(declared, col.nullable);
606
+
607
+ if (typeof col.sql !== 'string') return nullableType(extensionJsonSchema(col, col.sql), col.nullable);
608
+
609
+ switch (col.sql) {
610
+ case 'serial':
611
+ case 'integer':
612
+ base.type = 'integer';
613
+ break;
614
+ case 'bigint':
615
+ base.type = 'integer';
616
+ base.format = 'int64';
617
+ break;
618
+ case 'numeric':
619
+ base.type = 'number';
620
+ break;
621
+ case 'text':
622
+ case 'varchar':
623
+ base.type = 'string';
624
+ if (col.length !== undefined) base.maxLength = col.length;
625
+ break;
626
+ case 'boolean':
627
+ base.type = 'boolean';
628
+ break;
629
+ case 'timestamp':
630
+ base.type = 'string';
631
+ base.format = 'date-time';
632
+ break;
633
+ case 'jsonEnum':
634
+ base.type = 'string';
635
+ base.enum = [...(col.enum ?? [])];
636
+ break;
637
+ case 'json':
638
+ break;
639
+ }
640
+
641
+ const c = col.constraints;
642
+ if (c.minimum !== undefined) base.minimum = c.minimum;
643
+ if (c.maximum !== undefined) base.maximum = c.maximum;
644
+ if (c.minLength !== undefined) base.minLength = c.minLength;
645
+ if (c.maxLength !== undefined) base.maxLength = c.maxLength;
646
+ if (c.pattern !== undefined) base.pattern = c.pattern;
647
+
648
+ // Nullable widens the `type` keyword. A `json` column has no `type` to widen,
649
+ // which is the pre-existing behaviour and is preserved deliberately.
650
+ return nullableType(base, col.nullable);
651
+ }
652
+
653
+ function extensionJsonSchema(col: ColumnIR, type: ExtensionType): Record<string, JsonValue> {
654
+ if (type.name === 'vector') {
655
+ const dimension = extensionDimension(type);
656
+ return {
657
+ type: 'array',
658
+ items: { type: 'number' },
659
+ ...(dimension === undefined ? {} : { minItems: dimension, maxItems: dimension }),
660
+ };
661
+ }
662
+ if (type.name === 'citext') return jsonSchemaFromTypeIR(constrained('string', col));
663
+ return col.payload === undefined ? {} : jsonSchemaFromTypeIR(col.payload);
664
+ }
665
+
666
+ /**
667
+ * The JSON Schema projection of one structural type.
668
+ *
669
+ * HTTP contracts, validators and other build-time consumers call this after the
670
+ * existing reflector has produced TypeIR. Keeping the projection here prevents a
671
+ * second consumer from interpreting TypeScript types or re-walking schema metadata.
672
+ */
673
+ export function jsonSchemaFromTypeIR(type: TypeIR): Record<string, JsonValue> {
674
+ switch (type.kind) {
675
+ case 'scalar': {
676
+ const scalar = JSON_SCALAR_TYPES[type.scalar];
677
+ if (scalar === undefined) return {};
678
+ return {
679
+ type: scalar,
680
+ ...(type.format === undefined ? {} : { format: type.format }),
681
+ ...(type.constraints?.minimum === undefined ? {} : { minimum: type.constraints.minimum }),
682
+ ...(type.constraints?.maximum === undefined ? {} : { maximum: type.constraints.maximum }),
683
+ ...(type.constraints?.minLength === undefined ? {} : { minLength: type.constraints.minLength }),
684
+ ...(type.constraints?.maxLength === undefined ? {} : { maxLength: type.constraints.maxLength }),
685
+ ...(type.constraints?.pattern === undefined ? {} : { pattern: type.constraints.pattern }),
686
+ };
687
+ }
688
+ case 'literal':
689
+ return { const: type.value };
690
+ case 'null':
691
+ return { type: 'null' };
692
+ case 'array':
693
+ return {
694
+ type: 'array',
695
+ items: jsonSchemaFromTypeIR(type.element),
696
+ ...(type.constraints?.minLength === undefined ? {} : { minItems: type.constraints.minLength }),
697
+ ...(type.constraints?.maxLength === undefined ? {} : { maxItems: type.constraints.maxLength }),
698
+ };
699
+ case 'tuple':
700
+ return {
701
+ type: 'array',
702
+ prefixItems: type.elements.map(jsonSchemaFromTypeIR),
703
+ minItems: type.elements.length,
704
+ maxItems: type.elements.length,
705
+ };
706
+ case 'object': {
707
+ const properties: Record<string, JsonValue> = {};
708
+ const required: string[] = [];
709
+ for (const property of type.properties) {
710
+ properties[property.name] = jsonSchemaFromTypeIR(property.type);
711
+ if (!property.optional) required.push(property.name);
712
+ }
713
+ return { type: 'object', properties, required };
714
+ }
715
+ case 'union':
716
+ return { anyOf: type.members.map(jsonSchemaFromTypeIR) };
717
+ case 'undefined':
718
+ case 'unknown':
719
+ case 'ref':
720
+ case 'unsupported':
721
+ return {};
722
+ }
723
+ }
724
+
725
+ function nullableType(schema: Record<string, JsonValue>, nullable: boolean): Record<string, JsonValue> {
726
+ if (nullable && typeof schema.type === 'string') return { ...schema, type: [schema.type, 'null'] };
727
+ return schema;
728
+ }
729
+
730
+ /**
731
+ * The keywords for a `WireAs<W>` column, when `W` is something JSON Schema has keywords
732
+ * for. A scalar and a union of string literals cover every wire form seen so far — cents
733
+ * as a decimal string, a UUID, an enum.
734
+ *
735
+ * Anything richer (a tuple, an object) gets no `type` keyword rather than a wrong one:
736
+ * the same "widest true statement" a `json` column has always produced, for the same
737
+ * reason. It is a smaller document, not a false one.
738
+ */
739
+ function declaredWireKeywords(col: ColumnIR): Record<string, JsonValue> | undefined {
740
+ if (col.wire === undefined) return undefined;
741
+ const node = col.wire;
742
+ if (node.kind === 'scalar') {
743
+ const type = JSON_SCALAR_TYPES[node.scalar];
744
+ if (type === undefined) return {};
745
+ return { type, ...(node.format === undefined ? {} : { format: node.format }) };
746
+ }
747
+ if (node.kind === 'union' && node.members.every(member => member.kind === 'literal')) {
748
+ // boundary: `every` proves the predicate for every member but returns a `boolean`, so
749
+ // the narrowing does not reach the `map` that follows. Re-testing `kind` inside the map
750
+ // would be the same check twice for a branch that cannot be taken.
751
+ return { enum: node.members.map(member => (member as LiteralIR).value) };
752
+ }
753
+ return {};
754
+ }
755
+
756
+ /** The JSON Schema `type` for a scalar the wire can carry. `date` and `bigint` cannot. */
757
+ const JSON_SCALAR_TYPES: Readonly<Record<string, string | undefined>> = {
758
+ string: 'string',
759
+ number: 'number',
760
+ integer: 'integer',
761
+ boolean: 'boolean',
762
+ };
763
+
764
+ /**
765
+ * A column, plus whether the shape it was read from makes it optional.
766
+ *
767
+ * This is what the JSON Schema back-end actually consumes, and it exists because a
768
+ * variant name and a derived type are two spellings of the same information.
769
+ * `toJsonSchema(schema, 'create')` names the variant, and the rule is "a column with a
770
+ * default is optional here". `toJsonSchema<CreateDTO<User>>()` names the type, and the
771
+ * type has already applied that rule — `createdAt?: Date & …` is optional because
772
+ * `CreateDTO` made it so.
773
+ *
774
+ * Collapsing both onto `optional` is what keeps REQ-TF-7 structural rather than tested.
775
+ * A second document generator that reads optionality off a type would be a fifth walker
776
+ * (`PLAN-type-first.md` §1) and would drift the way the other four did.
777
+ */
778
+ export interface ShapeColumnIR {
779
+ readonly column: ColumnIR;
780
+ /** The document does not require this property. */
781
+ readonly optional: boolean;
782
+ }
783
+
784
+ /** The columns a document is generated from, in the order they were declared. */
785
+ export type ShapeIR = readonly ShapeColumnIR[];
786
+
787
+ /**
788
+ * A variant, rewritten as a shape.
789
+ *
790
+ * The three rules the variants used to spell out inline: an input variant has no
791
+ * database-generated columns at all, a patch requires nothing, and an input column with
792
+ * a default may be left out. Each is exactly what the corresponding derived type does to
793
+ * `Entity<T>`, which is the reason this translation exists rather than a coincidence.
794
+ *
795
+ * `update` also drops the primary key, which is what `UpdateDTO<T>` does and what this
796
+ * function did not: a patch body identifies its row in the URL, so a key in the body is
797
+ * either redundant or an attempt to move the row. It only ever showed for a *non-serial*
798
+ * key, since a serial one was already gone, which is why no existing document changes.
799
+ */
800
+ export function shapeOfVariant(ir: SchemaIR, variant: Variant): ShapeIR {
801
+ const isResponse = variant === 'entity' || variant === 'get' || variant === 'list' || variant === 'search';
802
+ return ir.columns
803
+ .filter(col => isResponse || (!col.serial && col.name !== ir.softDelete?.column))
804
+ .filter(col => variant !== 'update' || !col.primaryKey)
805
+ .map(col => ({
806
+ column: col,
807
+ // Nullable is optional on the way in, for the reason `CreateDTO`'s comment gives:
808
+ // omitting the key inserts `NULL`, so requiring it buys nothing. On the way out it
809
+ // is not — a row that came back has every column, `null` included.
810
+ optional: variant === 'update' || (!isResponse && (col.hasDefault || col.nullable)),
811
+ }));
812
+ }
813
+
814
+ /**
815
+ * The document for a shape.
816
+ *
817
+ * `required` is "not optional and not nullable", which is the single rule the three
818
+ * variants were three cases of. A nullable column is never required because the value
819
+ * `null` is admissible for it, so demanding the key adds nothing a validator can act on
820
+ * — that is pre-existing behaviour, preserved deliberately.
821
+ *
822
+ * Sensitive columns are dropped here, in the emitter, and not in the shape. A generated
823
+ * document is published, `Sensitive` means "must not be", and putting the filter at the
824
+ * last step is what makes that unconditional (REQ-TF-6): no variant, and no derived type
825
+ * a caller invents, can route around it. `CreateDTO<User>` deliberately *keeps* a
826
+ * sensitive column — you have to be able to send a password — and its document still
827
+ * must not name it.
828
+ */
829
+ export function jsonSchemaFromShape(shape: ShapeIR): JsonSchemaObject {
830
+ const visible = shape
831
+ .filter(entry => !entry.column.sensitive)
832
+ .toSorted((a, b) => a.column.name.localeCompare(b.column.name));
833
+
834
+ const properties: Record<string, unknown> = {};
835
+ const required: string[] = [];
836
+
837
+ for (const { column, optional } of visible) {
838
+ properties[column.name] = jsonSchemaForColumn(column);
839
+ if (!optional && !column.nullable) required.push(column.name);
840
+ }
841
+
842
+ return { type: 'object', properties, required: required.toSorted() };
843
+ }
844
+
845
+ /**
846
+ * The document for a variant. Byte-for-byte the contract `toJsonSchema` already
847
+ * publishes; the point is that it is a pure function of IR, so naming a variant and
848
+ * naming a derived type cannot produce different documents (REQ-TF-7). That AC stops
849
+ * being a test to chase and becomes the only thing the code can do.
850
+ */
851
+ export function jsonSchemaFromIR(ir: SchemaIR, variant: Variant = 'entity'): JsonSchemaObject {
852
+ return jsonSchemaFromShape(shapeOfVariant(ir, variant));
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Back-end: IR → validator type (a `TypeIR` for a whole row or payload)
857
+ // ---------------------------------------------------------------------------
858
+ //
859
+ // The repository used to answer "is this a legal payload for this table" with its own
860
+ // walk over `ColumnMeta` — `valueMatchesColumn`, the fourth walker of §1, and the one
861
+ // that accepted `Date | string` for a `timestamp` while `toJsonSchema` said ISO string
862
+ // and the declared type said `Date`. It does not need a walk. It needs the *type* of a payload,
863
+ // which is a `TypeIR`, and then the one runtime walker in `@zmdb/validator` checks
864
+ // it — the same walker the emitted code is differentially tested against.
865
+ //
866
+ // So this is the third back-end onto the same shape, beside the JSON Schema one, and it
867
+ // takes the same two decisions from the same place: which columns a variant has
868
+ // (`shapeOfVariant`) and what each column's type is at this layer (`appTypeOf` /
869
+ // `wireTypeOf`). Nothing here decides anything on its own, which is the point.
870
+
871
+ /**
872
+ * Which of a column's three types to render (plan D3).
873
+ *
874
+ * `'app'` is what handler code holds: a `timestamp` is a `Date`. `'wire'` is what a JSON
875
+ * body contains: the same column is an ISO-8601 string. A validator has to pick one —
876
+ * accepting both is how the disagreement went unnoticed for so long — so the caller says
877
+ * which side of the boundary it is on.
878
+ */
879
+ export type Layer = 'app' | 'wire';
880
+
881
+ /**
882
+ * A shape as the object type a validator checks against.
883
+ *
884
+ * Unlike the JSON Schema back-end this keeps sensitive columns: a payload validator that
885
+ * silently ignored `passwordHash` would reject every legitimate `create`. REQ-TF-6 is
886
+ * about what gets *published*, and nothing here is published.
887
+ *
888
+ * Column order is preserved rather than sorted, because a `TypeIR` is not a contract
889
+ * anybody serialises — the JSON Schema back-end sorts because a document is published
890
+ * and key order is part of it.
891
+ */
892
+ export function objectTypeFromShape(shape: ShapeIR, layer: Layer = 'app'): ObjectIR {
893
+ return {
894
+ kind: 'object',
895
+ properties: shape.map(({ column, optional }) => ({
896
+ name: column.name,
897
+ type: layer === 'wire' ? wireTypeOf(column) : appTypeOf(column),
898
+ optional,
899
+ // A DTO is a plain object the caller just built, so nothing about it is readonly.
900
+ // `Entity<T>` is `-readonly` for the same reason.
901
+ readonly: false,
902
+ })),
903
+ };
904
+ }
905
+
906
+ /** The object type of one variant of one table, at one layer. */
907
+ export function objectTypeFromIR(ir: SchemaIR, variant: Variant = 'entity', layer: Layer = 'app'): ObjectIR {
908
+ return objectTypeFromShape(shapeOfVariant(ir, variant), layer);
909
+ }
910
+
911
+ // ---------------------------------------------------------------------------
912
+ // Back-end: the crossing between the two layers (plan D3)
913
+ // ---------------------------------------------------------------------------
914
+ //
915
+ // Having two layers is only useful if something converts between them, once, at the
916
+ // boundary. Otherwise every handler decides for itself whether the `at` it was handed is
917
+ // a string or a `Date`, which is the state that let the three types disagree.
918
+ //
919
+ // So: `decodeWire` turns a JSON body into app values, `encodeWire` turns a row back into
920
+ // a JSON body, and both read the same `ColumnIR` the validators and the DDL read. They
921
+ // convert and nothing else — a value they cannot convert is passed through untouched for
922
+ // the validator to reject. That division matters: a decoder that produced `new
923
+ // Date('nonsense')` would hand the app layer an `Invalid Date`, which passes `instanceof
924
+ // Date` and reaches the database as `NULL` or an error from the driver. Leaving the string
925
+ // alone makes the validator say `expected Date`, which is true and actionable.
926
+
927
+ /** How one named `Codec<'Name'>` column crosses the boundary. */
928
+ export interface Codec {
929
+ readonly decode: (wire: unknown) => unknown;
930
+ readonly encode: (app: unknown) => unknown;
931
+ }
932
+
933
+ /** Codec name → its conversions. Supplied by the application, not by zmdb. */
934
+ export type CodecRegistry = Readonly<Record<string, Codec>>;
935
+
936
+ /** An ISO-8601 string, if that is what this is and it parses. */
937
+ function asDate(value: unknown): unknown {
938
+ if (typeof value !== 'string') return value;
939
+ const parsed = new Date(value);
940
+ return Number.isNaN(parsed.getTime()) ? value : parsed;
941
+ }
942
+
943
+ /**
944
+ * A decimal string as a `bigint`.
945
+ *
946
+ * `DECIMAL` rather than a bare `BigInt()` call in a `try`: `BigInt('0x10')` is 16 and
947
+ * `BigInt('')` is 0, neither of which is something a caller meant to send.
948
+ */
949
+ function asBigInt(value: unknown): unknown {
950
+ return typeof value === 'string' && DECIMAL.test(value) ? BigInt(value) : value;
951
+ }
952
+
953
+ const VECTOR_COMPONENT = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
954
+
955
+ function asVector(value: unknown): unknown {
956
+ if (Array.isArray(value) || typeof value !== 'string' || !value.startsWith('[') || !value.endsWith(']')) {
957
+ return value;
958
+ }
959
+ const body = value.slice(1, -1);
960
+ if (body === '') return [];
961
+ const fields = body.split(',');
962
+ if (fields.some(field => !VECTOR_COMPONENT.test(field.trim()))) return value;
963
+ const vector = fields.map(field => Number(field.trim()));
964
+ return vector.every(Number.isFinite) ? vector : value;
965
+ }
966
+
967
+ function codecFor(col: ColumnIR, codecs: CodecRegistry): Codec | undefined {
968
+ if (col.codec === undefined) return undefined;
969
+ const codec = codecs[col.codec];
970
+ if (!codec) {
971
+ // A named codec with nothing behind it is a gap, and a gap has to be visible (plan
972
+ // D4). Silently passing the value through would store whatever JSON happened to
973
+ // carry in a column whose whole point is that it needs converting.
974
+ throw new Error(`column "${col.name}" names the codec "${col.codec}", which is not in the registry`);
975
+ }
976
+ return codec;
977
+ }
978
+
979
+ /** One column's value, as the app layer holds it. */
980
+ export function decodeWireValue(col: ColumnIR, value: unknown, codecs: CodecRegistry = {}): unknown {
981
+ if (value === null || value === undefined) return value;
982
+ const codec = codecFor(col, codecs);
983
+ if (codec) return codec.decode(value);
984
+ if (col.sql === 'timestamp') return asDate(value);
985
+ if (col.sql === 'bigint') return asBigInt(value);
986
+ return value;
987
+ }
988
+
989
+ /** One column's value, as JSON can carry it. */
990
+ export function encodeWireValue(col: ColumnIR, value: unknown, codecs: CodecRegistry = {}): unknown {
991
+ if (value === null || value === undefined) return value;
992
+ const codec = codecFor(col, codecs);
993
+ if (codec) return codec.encode(value);
994
+ if (col.sql === 'timestamp' && value instanceof Date) {
995
+ return Number.isNaN(value.getTime()) ? value : value.toISOString();
996
+ }
997
+ if (col.sql === 'bigint' && typeof value === 'bigint') return value.toString();
998
+ return value;
999
+ }
1000
+
1001
+ /**
1002
+ * A JSON body as an app-layer payload: the wire→app decode, once, at the boundary.
1003
+ *
1004
+ * Keys the variant does not have are copied through rather than dropped, because dropping
1005
+ * them here would hide them from the repository's excess check — the decoder's job is to
1006
+ * convert, and deciding what a payload may contain belongs to exactly one place.
1007
+ */
1008
+ export function decodeWire(
1009
+ ir: SchemaIR,
1010
+ variant: Variant,
1011
+ body: Readonly<Record<string, unknown>>,
1012
+ codecs: CodecRegistry = {},
1013
+ ): Record<string, unknown> {
1014
+ const columns = new Map(shapeOfVariant(ir, variant).map(({ column }) => [column.name, column]));
1015
+ const out: Record<string, unknown> = {};
1016
+ for (const key of Object.keys(body)) {
1017
+ const column = columns.get(key);
1018
+ out[key] = column ? decodeWireValue(column, body[key], codecs) : body[key];
1019
+ }
1020
+ return out;
1021
+ }
1022
+
1023
+ /**
1024
+ * A value that came out of a database, as the app layer holds it — the third layer's
1025
+ * crossing (plan D3).
1026
+ *
1027
+ * Written in terms of what *arrived* rather than in terms of the dialect, and that is the
1028
+ * whole design: `pg` hands back a `Date` for a `timestamptz` and a string for an `int8`,
1029
+ * SQLite hands back the `TEXT` it stored and a `number` for an `INTEGER`, and a third
1030
+ * driver will do something else again. Asking "is this already the app value?" answers all
1031
+ * of them, and keeps this function out of the dialect's business — which is the constraint
1032
+ * the whole IR is written under.
1033
+ *
1034
+ * `timestamp` and `bigint` are the only core types whose app values need a distinct JSON
1035
+ * wire form. The db crossing also handles extension vectors: their app and wire forms are
1036
+ * both number arrays, but a driver without pgvector's parser can return the database text
1037
+ * form instead.
1038
+ */
1039
+ export function decodeDbValue(col: ColumnIR, value: unknown): unknown {
1040
+ if (value === null || value === undefined) return value;
1041
+ if (typeof col.sql !== 'string') return col.sql.name === 'vector' ? asVector(value) : value;
1042
+ if (col.sql === 'timestamp') return asDate(value);
1043
+ if (col.sql === 'bigint') {
1044
+ // A driver that read an 8-byte integer into a `number`. Safe integers only: past 2^53
1045
+ // the number has already lost digits, and `BigInt(9007199254740993)` would state a
1046
+ // value the database never held — better to hand back the number the driver gave and
1047
+ // let the validator say the app type is not what arrived.
1048
+ if (typeof value === 'number') return Number.isSafeInteger(value) ? BigInt(value) : value;
1049
+ return asBigInt(value);
1050
+ }
1051
+ return value;
1052
+ }
1053
+
1054
+ /** Which columns `decodeDbValue` can change — so a read path can skip the walk entirely. */
1055
+ export function dbDecodedColumns(ir: SchemaIR): readonly ColumnIR[] {
1056
+ return ir.columns.filter(col => {
1057
+ if (typeof col.sql === 'string') return col.sql === 'timestamp' || col.sql === 'bigint';
1058
+ return col.sql.name === 'vector';
1059
+ });
1060
+ }
1061
+
1062
+ /** A row as a JSON body: the app→wire encode, for a response. */
1063
+ export function encodeWire(
1064
+ ir: SchemaIR,
1065
+ row: Readonly<Record<string, unknown>>,
1066
+ codecs: CodecRegistry = {},
1067
+ ): Record<string, unknown> {
1068
+ const columns = new Map(ir.columns.map(column => [column.name, column]));
1069
+ const out: Record<string, unknown> = {};
1070
+ for (const key of Object.keys(row)) {
1071
+ const column = columns.get(key);
1072
+ out[key] = column ? encodeWireValue(column, row[key], codecs) : row[key];
1073
+ }
1074
+ return out;
1075
+ }
1076
+
1077
+ export {
1078
+ discriminantOf,
1079
+ expectedForConstraint,
1080
+ expectedForDiscriminant,
1081
+ expectedOf,
1082
+ hasExcessCheck,
1083
+ messageFor,
1084
+ } from './validation-shape.js';
1085
+ export type { ConstraintKeyword, Discriminant, DiscriminantArm } from './validation-shape.js';