@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,735 @@
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
+ import {} from '../index.js';
33
+ import { KNOWN_CONSTRAINT_KINDS } from './vocabulary.js';
34
+ export { KNOWN_CONSTRAINT_KINDS, TAG_NAMES } from './vocabulary.js';
35
+ /**
36
+ * The four cardinalities, as data so a reader can check a string against them.
37
+ *
38
+ * Written this way round — the list first, the type derived — because `../tags` fixes
39
+ * `kind` to a literal per tag, but the reflection reads it back off the checker as a
40
+ * `string`. Deriving the type from the list is what lets that read be a check rather than
41
+ * an assertion, and keeps the two from drifting.
42
+ */
43
+ export const RELATION_KINDS = ['manyToOne', 'oneToMany', 'oneToOne', 'manyToMany'];
44
+ // ---------------------------------------------------------------------------
45
+ // Vocabulary coverage (REQ-TF-1)
46
+ // ---------------------------------------------------------------------------
47
+ /** Every `SqlType`, as data. `sql-types.type-test.ts` asserts exhaustiveness. */
48
+ export const SQL_TYPES = [
49
+ 'serial',
50
+ 'integer',
51
+ 'bigint',
52
+ 'numeric',
53
+ 'text',
54
+ 'varchar',
55
+ 'boolean',
56
+ 'timestamp',
57
+ 'json',
58
+ 'jsonEnum',
59
+ ];
60
+ /** The closed protobuf scalar vocabulary carried by {@link ScalarIR}. */
61
+ export const PROTO_SCALARS = [
62
+ 'int32',
63
+ 'int64',
64
+ 'uint32',
65
+ 'uint64',
66
+ 'sint32',
67
+ 'sint64',
68
+ 'fixed32',
69
+ 'fixed64',
70
+ 'sfixed32',
71
+ 'sfixed64',
72
+ 'float',
73
+ 'double',
74
+ 'bool',
75
+ 'string',
76
+ 'bytes',
77
+ ];
78
+ // ---------------------------------------------------------------------------
79
+ // Back-end: IR → schema value (REQ-TF-10)
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * The constraints, in the spelling `ColumnMeta.validation` uses.
83
+ *
84
+ * Emitted in `KNOWN_CONSTRAINT_KINDS` order rather than any order they arrived in: the
85
+ * IR holds them in a record, which has none to preserve, so a fixed order is the only
86
+ * one that makes the output a function of the input. Named custom rules keep their name
87
+ * and lose their arguments, because `ColumnIR.rules` only ever held the name.
88
+ */
89
+ function validationFromIR(col) {
90
+ const rules = [];
91
+ for (const kind of KNOWN_CONSTRAINT_KINDS) {
92
+ const value = col.constraints[kind];
93
+ if (value !== undefined)
94
+ rules.push({ kind, value });
95
+ }
96
+ for (const kind of col.rules)
97
+ rules.push({ kind });
98
+ return rules;
99
+ }
100
+ /**
101
+ * A column's metadata. Flags are written only when set — a plain `text` column gives
102
+ * `{ nullable: false }` and nothing else — so the generated literal stays as small as
103
+ * the declaration it came from.
104
+ */
105
+ function columnMetaFromIR(col) {
106
+ const validation = validationFromIR(col);
107
+ return {
108
+ type: col.sql,
109
+ flags: {
110
+ nullable: col.nullable,
111
+ ...(col.primaryKey ? { primaryKey: true } : {}),
112
+ ...(col.serial ? { autoIncrement: true } : {}),
113
+ ...(col.unique ? { unique: true } : {}),
114
+ ...(col.hasDefault ? { hasDefault: true } : {}),
115
+ ...(col.sensitive ? { sensitive: true } : {}),
116
+ ...(col.length === undefined ? {} : { length: col.length }),
117
+ ...(col.enum === undefined ? {} : { enum: col.enum }),
118
+ },
119
+ ...(col.default === undefined ? {} : { default: col.default }),
120
+ ...(col.references === undefined ? {} : { references: { target: col.references } }),
121
+ ...(validation.length === 0 ? {} : { validation }),
122
+ };
123
+ }
124
+ /**
125
+ * The schema value, from the IR — and the only way to get one (REQ-TF-10).
126
+ *
127
+ * The query compiler wants the table name and the column types as *data*, and this is
128
+ * data. `@zmdb/compiler` emits the result of this function as a frozen literal, so
129
+ * `schemaOf<T>()` costs nothing at runtime and the tagged type stays the only place the
130
+ * schema is written.
131
+ *
132
+ * The IR itself is carried through on `ir` rather than left behind. Three things a
133
+ * `ColumnMeta` has no field for — `Numeric<P, S>` precision, a `Codec<'Name'>`, a `json`
134
+ * payload shape — used to be dropped here and were unrecoverable afterwards, because the
135
+ * only way back to an IR was to walk the flags. Keeping the IR makes the value a superset
136
+ * of what it projects rather than a lossy copy, and it is what let `irFromSchema` go: no
137
+ * consumer has to reconstruct from `columns` what the declaration already said.
138
+ *
139
+ * Nothing is registered. A generated literal is not a call and has nowhere to do that,
140
+ * and a global "which schema was that?" lookup is for code that has lost track of its own
141
+ * schema — which type-first code, by construction, has not.
142
+ */
143
+ export function schemaFromIR(ir) {
144
+ const key = new Set(ir.primaryKey);
145
+ const columnNames = new Set(ir.columns.map(column => column.name));
146
+ const missing = ir.primaryKey.filter(column => !columnNames.has(column));
147
+ if (missing.length > 0) {
148
+ throw new Error(`${ir.table}: primary key names ${missing.map(column => `"${column}"`).join(', ')}, ` +
149
+ `${missing.length === 1 ? 'a column' : 'columns'} the table does not have`);
150
+ }
151
+ const normalizedColumns = ir.columns.map(column => {
152
+ const primaryKey = key.has(column.name);
153
+ return column.primaryKey === primaryKey ? column : { ...column, primaryKey };
154
+ });
155
+ const normalizedIr = normalizedColumns.every((column, index) => column === ir.columns[index])
156
+ ? ir
157
+ : { ...ir, columns: normalizedColumns };
158
+ const physicalNames = new Map();
159
+ for (const column of normalizedIr.columns) {
160
+ const previous = physicalNames.get(column.physicalName);
161
+ if (previous !== undefined) {
162
+ throw new Error(`${normalizedIr.table}: \`${previous}\` and \`${column.name}\` both map to the column ` +
163
+ `\`${column.physicalName}\``);
164
+ }
165
+ physicalNames.set(column.physicalName, column.name);
166
+ }
167
+ const physicalByProperty = new Map(normalizedIr.columns.map(column => [column.name, column.physicalName]));
168
+ const columns = {};
169
+ for (const col of normalizedIr.columns)
170
+ columns[col.physicalName] = columnMetaFromIR(col);
171
+ return {
172
+ table: normalizedIr.physicalTable,
173
+ columns,
174
+ primaryKey: normalizedIr.primaryKey.map(column => physicalByProperty.get(column) ?? column),
175
+ references: normalizedIr.columns.flatMap(col => col.references === undefined ? [] : [{ column: col.physicalName, target: col.references }]),
176
+ ...(normalizedIr.ftsTable === undefined ? {} : { ftsTable: normalizedIr.ftsTable }),
177
+ ir: normalizedIr,
178
+ };
179
+ }
180
+ // ---------------------------------------------------------------------------
181
+ // The three types of a column (plan D3 / REQ-TF-13)
182
+ // ---------------------------------------------------------------------------
183
+ function withNull(type, nullable) {
184
+ return nullable ? { kind: 'union', members: [type, { kind: 'null' }] } : type;
185
+ }
186
+ function constrained(scalar, col, format) {
187
+ const withLength = col.length !== undefined && col.constraints.maxLength === undefined
188
+ ? { ...col.constraints, maxLength: col.length }
189
+ : col.constraints;
190
+ const derived = format === undefined ? undefined : FORMAT_PATTERNS[format];
191
+ const constraints = derived !== undefined && withLength.pattern === undefined ? { ...withLength, pattern: derived } : withLength;
192
+ return {
193
+ kind: 'scalar',
194
+ scalar,
195
+ ...(format === undefined ? {} : { format }),
196
+ ...(Object.keys(constraints).length === 0 ? {} : { constraints }),
197
+ };
198
+ }
199
+ function extensionDimension(type) {
200
+ if (type.name !== 'vector')
201
+ return undefined;
202
+ const dimension = type.args?.[0];
203
+ return typeof dimension === 'number' && Number.isInteger(dimension) && dimension >= 0 ? dimension : undefined;
204
+ }
205
+ function extensionAppBase(col, type) {
206
+ if (type.name === 'vector') {
207
+ const dimension = extensionDimension(type);
208
+ return {
209
+ kind: 'array',
210
+ element: { kind: 'scalar', scalar: 'number' },
211
+ ...(dimension === undefined ? {} : { constraints: { minLength: dimension, maxLength: dimension } }),
212
+ };
213
+ }
214
+ if (type.name === 'citext')
215
+ return constrained('string', col);
216
+ return {
217
+ kind: 'unsupported',
218
+ reason: `extension type "${type.name}" on column "${col.name}" needs its declared application shape ` +
219
+ 'carried in the IR',
220
+ };
221
+ }
222
+ /**
223
+ * The **app** type: what handler code sees. A `timestamp` is a `Date` here, and
224
+ * a `bigint` is a `bigint`.
225
+ */
226
+ export function appTypeOf(col) {
227
+ return withNull(appBaseOf(col), col.nullable);
228
+ }
229
+ function appBaseOf(col) {
230
+ if (typeof col.sql !== 'string') {
231
+ if (col.sql.name === 'vector' || col.sql.name === 'citext')
232
+ return extensionAppBase(col, col.sql);
233
+ if (col.payload !== undefined)
234
+ return col.payload;
235
+ return extensionAppBase(col, col.sql);
236
+ }
237
+ // A declared app type wins over the SQL type it is stored as. `amount: Money &
238
+ // Sql<'integer'> & Codec<'Money'>` is an integer in the database and a `Money` in the
239
+ // app, and a validator that checked `integer` here would reject every valid value.
240
+ if (col.payload !== undefined)
241
+ return col.payload;
242
+ switch (col.sql) {
243
+ case 'serial':
244
+ case 'integer':
245
+ return constrained('integer', col);
246
+ case 'bigint':
247
+ return constrained('bigint', col);
248
+ case 'numeric':
249
+ return constrained('number', col);
250
+ case 'text':
251
+ case 'varchar':
252
+ return constrained('string', col);
253
+ case 'boolean':
254
+ return { kind: 'scalar', scalar: 'boolean' };
255
+ case 'timestamp':
256
+ return { kind: 'scalar', scalar: 'date' };
257
+ case 'jsonEnum':
258
+ return col.enum === undefined || col.enum.length === 0
259
+ ? constrained('string', col)
260
+ : { kind: 'union', members: col.enum.map(value => ({ kind: 'literal', value })) };
261
+ case 'json':
262
+ return JSON_CONTAINER;
263
+ }
264
+ }
265
+ /**
266
+ * A `json` column whose payload shape is not known: anything JSON puts in a column, and
267
+ * nothing else. An object with no declared properties accepts any record, and an array of
268
+ * `unknown` accepts any array, so together they are "not a primitive".
269
+ *
270
+ * Not `{ kind: 'unknown' }`, which accepts `123`. A declaration that says what the payload
271
+ * is — `lines: Line[] & Sql<'json'>` — gets that type instead, via `ColumnIR.payload`; this
272
+ * is the answer for a bare `object & Sql<'json'>`, which really does permit any record. It
273
+ * is the weakest true statement rather than no statement, which is the difference between a
274
+ * validator that rejects `settings: 123` and one that does not.
275
+ */
276
+ const JSON_CONTAINER = {
277
+ kind: 'union',
278
+ members: [
279
+ { kind: 'object', properties: [] },
280
+ { kind: 'array', element: { kind: 'unknown' } },
281
+ ],
282
+ };
283
+ /**
284
+ * The assertion behind a `format`, as a `pattern`.
285
+ *
286
+ * `format` is an annotation in JSON Schema, not an assertion — a document may say
287
+ * `date-time` and a conforming validator may check nothing. Neither the runtime walk nor the
288
+ * emitter reads `format` at all, so a wire type that only said `{scalar:'string',
289
+ * format:'date-time'}` accepted `"tomorrow"`, and plan D3's claim that a `Wire<T>` validator
290
+ * checks the ISO string was not true of any validator. Lowering it to a `pattern` makes it
291
+ * true through machinery that already exists in both walks, which is the reason it is spelled
292
+ * this way rather than as a sixth constraint kind: a new keyword would need the emitter, the
293
+ * walker and their equivalence test, and would then check exactly what a pattern checks.
294
+ *
295
+ * `date-time` is RFC 3339, so the offset is **required**. `2020-01-01T00:00:00` is a valid
296
+ * ISO-8601 string and `new Date()` reads it as local time, which is the same lost-offset bug
297
+ * `TIMESTAMPTZ` exists to prevent — the wire is where that has to be refused, because by the
298
+ * time it is a `Date` the offset it was read at is gone.
299
+ *
300
+ * `int64` is `asBigInt`'s own `DECIMAL`, so what the wire validator accepts and what the
301
+ * decoder can convert are one expression rather than two that agree today.
302
+ */
303
+ /** What `asBigInt` will convert. Declared here so the wire pattern is not a second copy. */
304
+ const DECIMAL = /^-?\d+$/;
305
+ const FORMAT_PATTERNS = {
306
+ 'date-time': '^\\d{4}-\\d{2}-\\d{2}[Tt ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:[Zz]|[+-]\\d{2}:\\d{2})$',
307
+ int64: DECIMAL.source,
308
+ };
309
+ /**
310
+ * The **wire** type: what a JSON body actually contains. A `timestamp` is an
311
+ * ISO-8601 string, because a `Date` cannot survive JSON, and a `bigint` is a
312
+ * string for the same reason. Anything else matches the app type.
313
+ */
314
+ export function wireTypeOf(col) {
315
+ if (col.wire !== undefined)
316
+ return withNull(col.wire, col.nullable);
317
+ if (col.codec !== undefined) {
318
+ // A gap, and gaps are visible (plan D4). A codec exists because the app type is not
319
+ // the stored type; what it puts on the wire is a third choice that nothing but the
320
+ // declaration knows, and guessing "the same as the app type" is how a `Money`
321
+ // instance reaches `JSON.stringify`.
322
+ return {
323
+ kind: 'unsupported',
324
+ reason: `the codec "${col.codec}" does not say what column "${col.name}" looks like on the wire; add WireAs<…> to the declaration`,
325
+ };
326
+ }
327
+ if (typeof col.sql !== 'string')
328
+ return appTypeOf(col);
329
+ if (col.sql === 'timestamp')
330
+ return withNull(constrained('string', col, 'date-time'), col.nullable);
331
+ if (col.sql === 'bigint')
332
+ return withNull(constrained('string', col, 'int64'), col.nullable);
333
+ return appTypeOf(col);
334
+ }
335
+ /**
336
+ * A single column's JSON Schema. Emitted from the **wire** type, which is why a
337
+ * `timestamp` becomes `{type:'string',format:'date-time'}` here and a `Date` in
338
+ * `Entity<T>` — one column, two correct answers, each in its own layer.
339
+ */
340
+ export function jsonSchemaForColumn(col) {
341
+ const base = {};
342
+ const declared = declaredWireKeywords(col);
343
+ if (declared)
344
+ return nullableType(declared, col.nullable);
345
+ if (typeof col.sql !== 'string')
346
+ return nullableType(extensionJsonSchema(col, col.sql), col.nullable);
347
+ switch (col.sql) {
348
+ case 'serial':
349
+ case 'integer':
350
+ base.type = 'integer';
351
+ break;
352
+ case 'bigint':
353
+ base.type = 'integer';
354
+ base.format = 'int64';
355
+ break;
356
+ case 'numeric':
357
+ base.type = 'number';
358
+ break;
359
+ case 'text':
360
+ case 'varchar':
361
+ base.type = 'string';
362
+ if (col.length !== undefined)
363
+ base.maxLength = col.length;
364
+ break;
365
+ case 'boolean':
366
+ base.type = 'boolean';
367
+ break;
368
+ case 'timestamp':
369
+ base.type = 'string';
370
+ base.format = 'date-time';
371
+ break;
372
+ case 'jsonEnum':
373
+ base.type = 'string';
374
+ base.enum = [...(col.enum ?? [])];
375
+ break;
376
+ case 'json':
377
+ break;
378
+ }
379
+ const c = col.constraints;
380
+ if (c.minimum !== undefined)
381
+ base.minimum = c.minimum;
382
+ if (c.maximum !== undefined)
383
+ base.maximum = c.maximum;
384
+ if (c.minLength !== undefined)
385
+ base.minLength = c.minLength;
386
+ if (c.maxLength !== undefined)
387
+ base.maxLength = c.maxLength;
388
+ if (c.pattern !== undefined)
389
+ base.pattern = c.pattern;
390
+ // Nullable widens the `type` keyword. A `json` column has no `type` to widen,
391
+ // which is the pre-existing behaviour and is preserved deliberately.
392
+ return nullableType(base, col.nullable);
393
+ }
394
+ function extensionJsonSchema(col, type) {
395
+ if (type.name === 'vector') {
396
+ const dimension = extensionDimension(type);
397
+ return {
398
+ type: 'array',
399
+ items: { type: 'number' },
400
+ ...(dimension === undefined ? {} : { minItems: dimension, maxItems: dimension }),
401
+ };
402
+ }
403
+ if (type.name === 'citext')
404
+ return jsonSchemaFromTypeIR(constrained('string', col));
405
+ return col.payload === undefined ? {} : jsonSchemaFromTypeIR(col.payload);
406
+ }
407
+ /**
408
+ * The JSON Schema projection of one structural type.
409
+ *
410
+ * HTTP contracts, validators and other build-time consumers call this after the
411
+ * existing reflector has produced TypeIR. Keeping the projection here prevents a
412
+ * second consumer from interpreting TypeScript types or re-walking schema metadata.
413
+ */
414
+ export function jsonSchemaFromTypeIR(type) {
415
+ switch (type.kind) {
416
+ case 'scalar': {
417
+ const scalar = JSON_SCALAR_TYPES[type.scalar];
418
+ if (scalar === undefined)
419
+ return {};
420
+ return {
421
+ type: scalar,
422
+ ...(type.format === undefined ? {} : { format: type.format }),
423
+ ...(type.constraints?.minimum === undefined ? {} : { minimum: type.constraints.minimum }),
424
+ ...(type.constraints?.maximum === undefined ? {} : { maximum: type.constraints.maximum }),
425
+ ...(type.constraints?.minLength === undefined ? {} : { minLength: type.constraints.minLength }),
426
+ ...(type.constraints?.maxLength === undefined ? {} : { maxLength: type.constraints.maxLength }),
427
+ ...(type.constraints?.pattern === undefined ? {} : { pattern: type.constraints.pattern }),
428
+ };
429
+ }
430
+ case 'literal':
431
+ return { const: type.value };
432
+ case 'null':
433
+ return { type: 'null' };
434
+ case 'array':
435
+ return {
436
+ type: 'array',
437
+ items: jsonSchemaFromTypeIR(type.element),
438
+ ...(type.constraints?.minLength === undefined ? {} : { minItems: type.constraints.minLength }),
439
+ ...(type.constraints?.maxLength === undefined ? {} : { maxItems: type.constraints.maxLength }),
440
+ };
441
+ case 'tuple':
442
+ return {
443
+ type: 'array',
444
+ prefixItems: type.elements.map(jsonSchemaFromTypeIR),
445
+ minItems: type.elements.length,
446
+ maxItems: type.elements.length,
447
+ };
448
+ case 'object': {
449
+ const properties = {};
450
+ const required = [];
451
+ for (const property of type.properties) {
452
+ properties[property.name] = jsonSchemaFromTypeIR(property.type);
453
+ if (!property.optional)
454
+ required.push(property.name);
455
+ }
456
+ return { type: 'object', properties, required };
457
+ }
458
+ case 'union':
459
+ return { anyOf: type.members.map(jsonSchemaFromTypeIR) };
460
+ case 'undefined':
461
+ case 'unknown':
462
+ case 'ref':
463
+ case 'unsupported':
464
+ return {};
465
+ }
466
+ }
467
+ function nullableType(schema, nullable) {
468
+ if (nullable && typeof schema.type === 'string')
469
+ return { ...schema, type: [schema.type, 'null'] };
470
+ return schema;
471
+ }
472
+ /**
473
+ * The keywords for a `WireAs<W>` column, when `W` is something JSON Schema has keywords
474
+ * for. A scalar and a union of string literals cover every wire form seen so far — cents
475
+ * as a decimal string, a UUID, an enum.
476
+ *
477
+ * Anything richer (a tuple, an object) gets no `type` keyword rather than a wrong one:
478
+ * the same "widest true statement" a `json` column has always produced, for the same
479
+ * reason. It is a smaller document, not a false one.
480
+ */
481
+ function declaredWireKeywords(col) {
482
+ if (col.wire === undefined)
483
+ return undefined;
484
+ const node = col.wire;
485
+ if (node.kind === 'scalar') {
486
+ const type = JSON_SCALAR_TYPES[node.scalar];
487
+ if (type === undefined)
488
+ return {};
489
+ return { type, ...(node.format === undefined ? {} : { format: node.format }) };
490
+ }
491
+ if (node.kind === 'union' && node.members.every(member => member.kind === 'literal')) {
492
+ // boundary: `every` proves the predicate for every member but returns a `boolean`, so
493
+ // the narrowing does not reach the `map` that follows. Re-testing `kind` inside the map
494
+ // would be the same check twice for a branch that cannot be taken.
495
+ return { enum: node.members.map(member => member.value) };
496
+ }
497
+ return {};
498
+ }
499
+ /** The JSON Schema `type` for a scalar the wire can carry. `date` and `bigint` cannot. */
500
+ const JSON_SCALAR_TYPES = {
501
+ string: 'string',
502
+ number: 'number',
503
+ integer: 'integer',
504
+ boolean: 'boolean',
505
+ };
506
+ /**
507
+ * A variant, rewritten as a shape.
508
+ *
509
+ * The three rules the variants used to spell out inline: an input variant has no
510
+ * database-generated columns at all, a patch requires nothing, and an input column with
511
+ * a default may be left out. Each is exactly what the corresponding derived type does to
512
+ * `Entity<T>`, which is the reason this translation exists rather than a coincidence.
513
+ *
514
+ * `update` also drops the primary key, which is what `UpdateDTO<T>` does and what this
515
+ * function did not: a patch body identifies its row in the URL, so a key in the body is
516
+ * either redundant or an attempt to move the row. It only ever showed for a *non-serial*
517
+ * key, since a serial one was already gone, which is why no existing document changes.
518
+ */
519
+ export function shapeOfVariant(ir, variant) {
520
+ const isResponse = variant === 'entity' || variant === 'get' || variant === 'list' || variant === 'search';
521
+ return ir.columns
522
+ .filter(col => isResponse || (!col.serial && col.name !== ir.softDelete?.column))
523
+ .filter(col => variant !== 'update' || !col.primaryKey)
524
+ .map(col => ({
525
+ column: col,
526
+ // Nullable is optional on the way in, for the reason `CreateDTO`'s comment gives:
527
+ // omitting the key inserts `NULL`, so requiring it buys nothing. On the way out it
528
+ // is not — a row that came back has every column, `null` included.
529
+ optional: variant === 'update' || (!isResponse && (col.hasDefault || col.nullable)),
530
+ }));
531
+ }
532
+ /**
533
+ * The document for a shape.
534
+ *
535
+ * `required` is "not optional and not nullable", which is the single rule the three
536
+ * variants were three cases of. A nullable column is never required because the value
537
+ * `null` is admissible for it, so demanding the key adds nothing a validator can act on
538
+ * — that is pre-existing behaviour, preserved deliberately.
539
+ *
540
+ * Sensitive columns are dropped here, in the emitter, and not in the shape. A generated
541
+ * document is published, `Sensitive` means "must not be", and putting the filter at the
542
+ * last step is what makes that unconditional (REQ-TF-6): no variant, and no derived type
543
+ * a caller invents, can route around it. `CreateDTO<User>` deliberately *keeps* a
544
+ * sensitive column — you have to be able to send a password — and its document still
545
+ * must not name it.
546
+ */
547
+ export function jsonSchemaFromShape(shape) {
548
+ const visible = shape
549
+ .filter(entry => !entry.column.sensitive)
550
+ .toSorted((a, b) => a.column.name.localeCompare(b.column.name));
551
+ const properties = {};
552
+ const required = [];
553
+ for (const { column, optional } of visible) {
554
+ properties[column.name] = jsonSchemaForColumn(column);
555
+ if (!optional && !column.nullable)
556
+ required.push(column.name);
557
+ }
558
+ return { type: 'object', properties, required: required.toSorted() };
559
+ }
560
+ /**
561
+ * The document for a variant. Byte-for-byte the contract `toJsonSchema` already
562
+ * publishes; the point is that it is a pure function of IR, so naming a variant and
563
+ * naming a derived type cannot produce different documents (REQ-TF-7). That AC stops
564
+ * being a test to chase and becomes the only thing the code can do.
565
+ */
566
+ export function jsonSchemaFromIR(ir, variant = 'entity') {
567
+ return jsonSchemaFromShape(shapeOfVariant(ir, variant));
568
+ }
569
+ /**
570
+ * A shape as the object type a validator checks against.
571
+ *
572
+ * Unlike the JSON Schema back-end this keeps sensitive columns: a payload validator that
573
+ * silently ignored `passwordHash` would reject every legitimate `create`. REQ-TF-6 is
574
+ * about what gets *published*, and nothing here is published.
575
+ *
576
+ * Column order is preserved rather than sorted, because a `TypeIR` is not a contract
577
+ * anybody serialises — the JSON Schema back-end sorts because a document is published
578
+ * and key order is part of it.
579
+ */
580
+ export function objectTypeFromShape(shape, layer = 'app') {
581
+ return {
582
+ kind: 'object',
583
+ properties: shape.map(({ column, optional }) => ({
584
+ name: column.name,
585
+ type: layer === 'wire' ? wireTypeOf(column) : appTypeOf(column),
586
+ optional,
587
+ // A DTO is a plain object the caller just built, so nothing about it is readonly.
588
+ // `Entity<T>` is `-readonly` for the same reason.
589
+ readonly: false,
590
+ })),
591
+ };
592
+ }
593
+ /** The object type of one variant of one table, at one layer. */
594
+ export function objectTypeFromIR(ir, variant = 'entity', layer = 'app') {
595
+ return objectTypeFromShape(shapeOfVariant(ir, variant), layer);
596
+ }
597
+ /** An ISO-8601 string, if that is what this is and it parses. */
598
+ function asDate(value) {
599
+ if (typeof value !== 'string')
600
+ return value;
601
+ const parsed = new Date(value);
602
+ return Number.isNaN(parsed.getTime()) ? value : parsed;
603
+ }
604
+ /**
605
+ * A decimal string as a `bigint`.
606
+ *
607
+ * `DECIMAL` rather than a bare `BigInt()` call in a `try`: `BigInt('0x10')` is 16 and
608
+ * `BigInt('')` is 0, neither of which is something a caller meant to send.
609
+ */
610
+ function asBigInt(value) {
611
+ return typeof value === 'string' && DECIMAL.test(value) ? BigInt(value) : value;
612
+ }
613
+ const VECTOR_COMPONENT = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
614
+ function asVector(value) {
615
+ if (Array.isArray(value) || typeof value !== 'string' || !value.startsWith('[') || !value.endsWith(']')) {
616
+ return value;
617
+ }
618
+ const body = value.slice(1, -1);
619
+ if (body === '')
620
+ return [];
621
+ const fields = body.split(',');
622
+ if (fields.some(field => !VECTOR_COMPONENT.test(field.trim())))
623
+ return value;
624
+ const vector = fields.map(field => Number(field.trim()));
625
+ return vector.every(Number.isFinite) ? vector : value;
626
+ }
627
+ function codecFor(col, codecs) {
628
+ if (col.codec === undefined)
629
+ return undefined;
630
+ const codec = codecs[col.codec];
631
+ if (!codec) {
632
+ // A named codec with nothing behind it is a gap, and a gap has to be visible (plan
633
+ // D4). Silently passing the value through would store whatever JSON happened to
634
+ // carry in a column whose whole point is that it needs converting.
635
+ throw new Error(`column "${col.name}" names the codec "${col.codec}", which is not in the registry`);
636
+ }
637
+ return codec;
638
+ }
639
+ /** One column's value, as the app layer holds it. */
640
+ export function decodeWireValue(col, value, codecs = {}) {
641
+ if (value === null || value === undefined)
642
+ return value;
643
+ const codec = codecFor(col, codecs);
644
+ if (codec)
645
+ return codec.decode(value);
646
+ if (col.sql === 'timestamp')
647
+ return asDate(value);
648
+ if (col.sql === 'bigint')
649
+ return asBigInt(value);
650
+ return value;
651
+ }
652
+ /** One column's value, as JSON can carry it. */
653
+ export function encodeWireValue(col, value, codecs = {}) {
654
+ if (value === null || value === undefined)
655
+ return value;
656
+ const codec = codecFor(col, codecs);
657
+ if (codec)
658
+ return codec.encode(value);
659
+ if (col.sql === 'timestamp' && value instanceof Date) {
660
+ return Number.isNaN(value.getTime()) ? value : value.toISOString();
661
+ }
662
+ if (col.sql === 'bigint' && typeof value === 'bigint')
663
+ return value.toString();
664
+ return value;
665
+ }
666
+ /**
667
+ * A JSON body as an app-layer payload: the wire→app decode, once, at the boundary.
668
+ *
669
+ * Keys the variant does not have are copied through rather than dropped, because dropping
670
+ * them here would hide them from the repository's excess check — the decoder's job is to
671
+ * convert, and deciding what a payload may contain belongs to exactly one place.
672
+ */
673
+ export function decodeWire(ir, variant, body, codecs = {}) {
674
+ const columns = new Map(shapeOfVariant(ir, variant).map(({ column }) => [column.name, column]));
675
+ const out = {};
676
+ for (const key of Object.keys(body)) {
677
+ const column = columns.get(key);
678
+ out[key] = column ? decodeWireValue(column, body[key], codecs) : body[key];
679
+ }
680
+ return out;
681
+ }
682
+ /**
683
+ * A value that came out of a database, as the app layer holds it — the third layer's
684
+ * crossing (plan D3).
685
+ *
686
+ * Written in terms of what *arrived* rather than in terms of the dialect, and that is the
687
+ * whole design: `pg` hands back a `Date` for a `timestamptz` and a string for an `int8`,
688
+ * SQLite hands back the `TEXT` it stored and a `number` for an `INTEGER`, and a third
689
+ * driver will do something else again. Asking "is this already the app value?" answers all
690
+ * of them, and keeps this function out of the dialect's business — which is the constraint
691
+ * the whole IR is written under.
692
+ *
693
+ * `timestamp` and `bigint` are the only core types whose app values need a distinct JSON
694
+ * wire form. The db crossing also handles extension vectors: their app and wire forms are
695
+ * both number arrays, but a driver without pgvector's parser can return the database text
696
+ * form instead.
697
+ */
698
+ export function decodeDbValue(col, value) {
699
+ if (value === null || value === undefined)
700
+ return value;
701
+ if (typeof col.sql !== 'string')
702
+ return col.sql.name === 'vector' ? asVector(value) : value;
703
+ if (col.sql === 'timestamp')
704
+ return asDate(value);
705
+ if (col.sql === 'bigint') {
706
+ // A driver that read an 8-byte integer into a `number`. Safe integers only: past 2^53
707
+ // the number has already lost digits, and `BigInt(9007199254740993)` would state a
708
+ // value the database never held — better to hand back the number the driver gave and
709
+ // let the validator say the app type is not what arrived.
710
+ if (typeof value === 'number')
711
+ return Number.isSafeInteger(value) ? BigInt(value) : value;
712
+ return asBigInt(value);
713
+ }
714
+ return value;
715
+ }
716
+ /** Which columns `decodeDbValue` can change — so a read path can skip the walk entirely. */
717
+ export function dbDecodedColumns(ir) {
718
+ return ir.columns.filter(col => {
719
+ if (typeof col.sql === 'string')
720
+ return col.sql === 'timestamp' || col.sql === 'bigint';
721
+ return col.sql.name === 'vector';
722
+ });
723
+ }
724
+ /** A row as a JSON body: the app→wire encode, for a response. */
725
+ export function encodeWire(ir, row, codecs = {}) {
726
+ const columns = new Map(ir.columns.map(column => [column.name, column]));
727
+ const out = {};
728
+ for (const key of Object.keys(row)) {
729
+ const column = columns.get(key);
730
+ out[key] = column ? encodeWireValue(column, row[key], codecs) : row[key];
731
+ }
732
+ return out;
733
+ }
734
+ export { discriminantOf, expectedForConstraint, expectedForDiscriminant, expectedOf, hasExcessCheck, messageFor, } from './validation-shape.js';
735
+ //# sourceMappingURL=index.js.map