@zmdb/compiler 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 (172) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +40 -0
  3. package/dist/codegen/index.d.ts +50 -0
  4. package/dist/codegen/index.d.ts.map +1 -0
  5. package/dist/codegen/index.js +364 -0
  6. package/dist/codegen/index.js.map +1 -0
  7. package/dist/codegen/scan.d.ts +105 -0
  8. package/dist/codegen/scan.d.ts.map +1 -0
  9. package/dist/codegen/scan.js +450 -0
  10. package/dist/codegen/scan.js.map +1 -0
  11. package/dist/codegen/witness.d.ts +76 -0
  12. package/dist/codegen/witness.d.ts.map +1 -0
  13. package/dist/codegen/witness.js +690 -0
  14. package/dist/codegen/witness.js.map +1 -0
  15. package/dist/config/contract.d.ts +41 -0
  16. package/dist/config/contract.d.ts.map +1 -0
  17. package/dist/config/contract.js +5 -0
  18. package/dist/config/contract.js.map +1 -0
  19. package/dist/config/index.d.ts +53 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +277 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/index.zmdb.generated.d.ts +12 -0
  24. package/dist/config/index.zmdb.generated.js +28 -0
  25. package/dist/config/index.zmdb.witness.d.ts +3 -0
  26. package/dist/config/index.zmdb.witness.d.ts.map +1 -0
  27. package/dist/config/index.zmdb.witness.js +20 -0
  28. package/dist/config/index.zmdb.witness.js.map +1 -0
  29. package/dist/configured-plugin.d.ts +11 -0
  30. package/dist/configured-plugin.d.ts.map +1 -0
  31. package/dist/configured-plugin.js +27 -0
  32. package/dist/configured-plugin.js.map +1 -0
  33. package/dist/emit/index.d.ts +103 -0
  34. package/dist/emit/index.d.ts.map +1 -0
  35. package/dist/emit/index.js +1356 -0
  36. package/dist/emit/index.js.map +1 -0
  37. package/dist/errors.d.ts +7 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +2 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +46 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +254 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lint/ast.d.ts +17 -0
  46. package/dist/lint/ast.d.ts.map +1 -0
  47. package/dist/lint/ast.js +77 -0
  48. package/dist/lint/ast.js.map +1 -0
  49. package/dist/lint/host-types.d.ts +9 -0
  50. package/dist/lint/host-types.d.ts.map +1 -0
  51. package/dist/lint/host-types.js +2 -0
  52. package/dist/lint/host-types.js.map +1 -0
  53. package/dist/lint/index.d.ts +23 -0
  54. package/dist/lint/index.d.ts.map +1 -0
  55. package/dist/lint/index.js +53 -0
  56. package/dist/lint/index.js.map +1 -0
  57. package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
  58. package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
  59. package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
  60. package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
  61. package/dist/lint/rules/no-empty-patch.d.ts +3 -0
  62. package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
  63. package/dist/lint/rules/no-empty-patch.js +26 -0
  64. package/dist/lint/rules/no-empty-patch.js.map +1 -0
  65. package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
  66. package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
  67. package/dist/lint/rules/no-interpolated-sql.js +35 -0
  68. package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
  69. package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
  70. package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
  71. package/dist/lint/rules/no-unbounded-find.js +26 -0
  72. package/dist/lint/rules/no-unbounded-find.js.map +1 -0
  73. package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
  74. package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
  75. package/dist/lint/rules/no-unknown-json-column.js +35 -0
  76. package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
  77. package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
  78. package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
  79. package/dist/lint/rules/require-sql-on-number.js +34 -0
  80. package/dist/lint/rules/require-sql-on-number.js.map +1 -0
  81. package/dist/lint/types.d.ts +12 -0
  82. package/dist/lint/types.d.ts.map +1 -0
  83. package/dist/lint/types.js +2 -0
  84. package/dist/lint/types.js.map +1 -0
  85. package/dist/metro/metro.d.ts +22 -0
  86. package/dist/metro/metro.d.ts.map +1 -0
  87. package/dist/metro/metro.js +242 -0
  88. package/dist/metro/metro.js.map +1 -0
  89. package/dist/protobuf/decode.d.ts +20 -0
  90. package/dist/protobuf/decode.d.ts.map +1 -0
  91. package/dist/protobuf/decode.js +289 -0
  92. package/dist/protobuf/decode.js.map +1 -0
  93. package/dist/protobuf/descriptor.d.ts +16 -0
  94. package/dist/protobuf/descriptor.d.ts.map +1 -0
  95. package/dist/protobuf/descriptor.js +303 -0
  96. package/dist/protobuf/descriptor.js.map +1 -0
  97. package/dist/protobuf/encode.d.ts +20 -0
  98. package/dist/protobuf/encode.d.ts.map +1 -0
  99. package/dist/protobuf/encode.js +203 -0
  100. package/dist/protobuf/encode.js.map +1 -0
  101. package/dist/protobuf/grpc-ir.d.ts +16 -0
  102. package/dist/protobuf/grpc-ir.d.ts.map +1 -0
  103. package/dist/protobuf/grpc-ir.js +2 -0
  104. package/dist/protobuf/grpc-ir.js.map +1 -0
  105. package/dist/protobuf/plan.d.ts +42 -0
  106. package/dist/protobuf/plan.d.ts.map +1 -0
  107. package/dist/protobuf/plan.js +158 -0
  108. package/dist/protobuf/plan.js.map +1 -0
  109. package/dist/reflect/callsites.d.ts +40 -0
  110. package/dist/reflect/callsites.d.ts.map +1 -0
  111. package/dist/reflect/callsites.js +153 -0
  112. package/dist/reflect/callsites.js.map +1 -0
  113. package/dist/reflect/index.d.ts +106 -0
  114. package/dist/reflect/index.d.ts.map +1 -0
  115. package/dist/reflect/index.js +1421 -0
  116. package/dist/reflect/index.js.map +1 -0
  117. package/dist/reflect/session.d.ts +118 -0
  118. package/dist/reflect/session.d.ts.map +1 -0
  119. package/dist/reflect/session.js +228 -0
  120. package/dist/reflect/session.js.map +1 -0
  121. package/dist/testing/index.d.ts +69 -0
  122. package/dist/testing/index.d.ts.map +1 -0
  123. package/dist/testing/index.js +210 -0
  124. package/dist/testing/index.js.map +1 -0
  125. package/dist/transform/index.d.ts +74 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +572 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/unplugin/index.d.ts +53 -0
  130. package/dist/unplugin/index.d.ts.map +1 -0
  131. package/dist/unplugin/index.js +130 -0
  132. package/dist/unplugin/index.js.map +1 -0
  133. package/dist/unplugin/inline-bench.d.ts +29 -0
  134. package/dist/unplugin/inline-bench.d.ts.map +1 -0
  135. package/dist/unplugin/inline-bench.js +82 -0
  136. package/dist/unplugin/inline-bench.js.map +1 -0
  137. package/package.json +110 -0
  138. package/src/codegen/index.ts +451 -0
  139. package/src/codegen/scan.ts +574 -0
  140. package/src/codegen/witness.ts +819 -0
  141. package/src/config/contract.ts +48 -0
  142. package/src/config/index.ts +384 -0
  143. package/src/config/index.zmdb.generated.d.ts +12 -0
  144. package/src/config/index.zmdb.generated.js +28 -0
  145. package/src/config/index.zmdb.witness.ts +22 -0
  146. package/src/configured-plugin.ts +35 -0
  147. package/src/emit/index.ts +1496 -0
  148. package/src/errors.ts +6 -0
  149. package/src/index.ts +336 -0
  150. package/src/lint/ast.ts +96 -0
  151. package/src/lint/host-types.ts +9 -0
  152. package/src/lint/index.ts +80 -0
  153. package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
  154. package/src/lint/rules/no-empty-patch.ts +26 -0
  155. package/src/lint/rules/no-interpolated-sql.ts +45 -0
  156. package/src/lint/rules/no-unbounded-find.ts +26 -0
  157. package/src/lint/rules/no-unknown-json-column.ts +36 -0
  158. package/src/lint/rules/require-sql-on-number.ts +34 -0
  159. package/src/lint/types.ts +11 -0
  160. package/src/metro/metro.ts +313 -0
  161. package/src/protobuf/decode.ts +375 -0
  162. package/src/protobuf/descriptor.ts +353 -0
  163. package/src/protobuf/encode.ts +277 -0
  164. package/src/protobuf/grpc-ir.ts +17 -0
  165. package/src/protobuf/plan.ts +244 -0
  166. package/src/reflect/callsites.ts +198 -0
  167. package/src/reflect/index.ts +1723 -0
  168. package/src/reflect/session.ts +276 -0
  169. package/src/testing/index.ts +298 -0
  170. package/src/transform/index.ts +690 -0
  171. package/src/unplugin/index.ts +182 -0
  172. package/src/unplugin/inline-bench.ts +92 -0
@@ -0,0 +1,1723 @@
1
+ // Reflection: a TypeScript type → `@zmdb/schema/ir`.
2
+ //
3
+ // This is the front-end that makes type-first declaration possible, and since the
4
+ // builder DSL was deleted it is the only one (PRD §6.7, REQ-TF-4 … REQ-TF-7). It reads
5
+ // a checker `Type` and produces plain serialisable data; from there the back-ends —
6
+ // validator emission, JSON Schema, DDL, the schema value itself — are written against
7
+ // the IR and know nothing about where it came from. `PLAN-type-first.md` Phase 4.
8
+ //
9
+ // Three rules govern everything below, and each one is a reaction to a specific bug:
10
+ //
11
+ // 1. **Total.** `typeIR` never throws. Every input produces a node, and anything the
12
+ // reflection cannot model produces `{ kind: 'unsupported', reason }`. The
13
+ // alternative — throwing — makes one bad property abort a whole file, and the
14
+ // silent alternative is worse: `f70186c6` was a transformer that inlined a
15
+ // *partial* parse of a type it only half understood, and a partial answer is
16
+ // indistinguishable from a correct one until production.
17
+ //
18
+ // 2. **Refusals are named.** `unsupported.reason` is prose a human can act on, not
19
+ // `'unknown'`. The emitter turns an `unsupported` node into a build error (plan
20
+ // D4), so the reason is the error message, and a vague one wastes the reader's
21
+ // afternoon.
22
+ //
23
+ // 3. **Budgeted.** Depth, node count and helper count are capped. A recursive type
24
+ // or a 4000-property union should degrade to a named refusal, not hang the build.
25
+ // Exceeding a cap is an `unsupported` node like any other, so a budget overrun
26
+ // stops the build the same way an unmodellable type does; two mechanisms for one
27
+ // outcome would only be two things to keep in sync.
28
+ //
29
+ // What the checker actually gives us, and what it does not, was measured rather than
30
+ // assumed — `typescript@7` is the Go compiler behind a thin marshalling client, and
31
+ // several `Type` members that exist in the `.d.ts` come back `undefined` over the
32
+ // wire. The comments at each such site say which, and what is done instead.
33
+
34
+ import { type SqlType } from '@zmdb/schema';
35
+ import {
36
+ KNOWN_CONSTRAINT_KINDS,
37
+ PROTO_SCALARS,
38
+ RELATION_KINDS,
39
+ SQL_TYPES,
40
+ TAG_NAMES,
41
+ type ColumnIR,
42
+ type Constraints,
43
+ type ExtensionType,
44
+ type ForeignKeyIR,
45
+ type ObjectIR,
46
+ type PropertyIR,
47
+ type ProtoScalar,
48
+ type ReferentialAction,
49
+ type RelationIR,
50
+ type RelationKind,
51
+ type SchemaIR,
52
+ type ShapeColumnIR,
53
+ type ShapeIR,
54
+ type TagField,
55
+ type TableOptions,
56
+ type TypeIR,
57
+ } from '@zmdb/schema/ir';
58
+ import { type NamingStrategy } from '@zmdb/schema/naming';
59
+ import type { Node } from 'typescript/unstable/ast';
60
+ import { SignatureKind, SymbolFlags } from 'typescript/unstable/sync';
61
+ import type { Checker, IntersectionType, Symbol as TsSymbol, Type, TypeReference } from 'typescript/unstable/sync';
62
+
63
+ import type { GrpcMethodIR, GrpcServiceIR } from '../protobuf/grpc-ir.js';
64
+
65
+ export { apiInstanceCount, projectSourceFileNames, ReflectSession, withSession } from './session.js';
66
+ export type { SessionOptions, SessionUpdate, SourceFileHandle } from './session.js';
67
+ export type { GrpcMethodIR, GrpcServiceIR } from '../protobuf/grpc-ir.js';
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Diagnostics and limits
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * A refusal, recorded rather than thrown. `path` is the property chain that reached
75
+ * it (`'email'`, `'profile.address.zip'`) so the message can point somewhere.
76
+ */
77
+ export interface ReflectDiagnostic {
78
+ readonly path: string;
79
+ readonly reason: string;
80
+ /** The type as the checker prints it, when that is more use than the path. */
81
+ readonly source?: string;
82
+ }
83
+
84
+ export interface ReflectLimits {
85
+ /** Nesting depth of object/array/union recursion. */
86
+ readonly maxDepth: number;
87
+ /** Total IR nodes produced by one reflector. */
88
+ readonly maxNodes: number;
89
+ /** Distinct named objects, i.e. hoistable helpers in the emitted code. */
90
+ readonly maxHelpers: number;
91
+ }
92
+
93
+ /**
94
+ * Deliberately generous. These are a guard against pathological input, not a style
95
+ * guide: a legitimate 30-deep nested JSON payload is unusual but not wrong, and a
96
+ * cap that fires on real code trains people to raise it rather than to read it.
97
+ */
98
+ export const DEFAULT_LIMITS: ReflectLimits = { maxDepth: 32, maxNodes: 20_000, maxHelpers: 512 };
99
+
100
+ export interface ReflectOptions {
101
+ readonly limits?: Partial<ReflectLimits>;
102
+ /** Resolved from `zmdb.config.ts` by the caller. Absent means identity. */
103
+ readonly naming?: NamingStrategy;
104
+ }
105
+
106
+ export { type NamingStrategy } from '@zmdb/schema/naming';
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tag reading
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * The checker reports a `unique symbol` property as `__@<name>@<symbolId>`. The id
114
+ * suffix is what makes plan D5 detectable: two installed copies of
115
+ * `@zmdb/schema` declare `zmdbSerial` twice, the two are distinct types, and the
116
+ * escaped names differ only in that number.
117
+ */
118
+ const TAG_PATTERN = /^__@(\w+?)@?(\d*)$/;
119
+ const PHYSICAL_TAG_NAME = 'zmdbPhysical';
120
+
121
+ /**
122
+ * The tag vocabulary read the other way round: escaped symbol name → IR field.
123
+ *
124
+ * boundary: `TAG_NAMES` is keyed by `TagField`, but `Object.entries` types its keys as
125
+ * `string` — there is no form of it that keeps them. The assertion restores what the
126
+ * declaration of `TAG_NAMES` already says, and if a key were ever added that is not a
127
+ * `TagField`, `TAG_NAMES`' own type annotation is where that fails, not here.
128
+ */
129
+ const TAG_FIELD_BY_NAME: ReadonlyMap<string, TagField> = new Map(
130
+ Object.entries(TAG_NAMES).map(([field, symbolName]) => [symbolName, field as TagField]),
131
+ );
132
+
133
+ interface RecognizedTag {
134
+ readonly name: string;
135
+ /** Present only for a unique-symbol tag, where duplicate installs have distinct ids. */
136
+ readonly identity?: string;
137
+ }
138
+
139
+ /**
140
+ * Normalise both tag encodings without importing the type-only vocabulary.
141
+ *
142
+ * Most tags are unique-symbol properties (`__@zmdbSerial@1`). `Ext` is the single
143
+ * structural marker frozen by the IR contract, and the exact `__zmdbExt` spelling is
144
+ * recognised as the same `zmdbExt` vocabulary entry.
145
+ */
146
+ function recognizedTag(symbol: TsSymbol): RecognizedTag | undefined {
147
+ const match = TAG_PATTERN.exec(symbol.escapedName);
148
+ const uniqueName = match?.[1];
149
+ if (uniqueName === PHYSICAL_TAG_NAME) {
150
+ return { name: uniqueName, identity: symbol.escapedName };
151
+ }
152
+ if (uniqueName !== undefined && TAG_FIELD_BY_NAME.has(uniqueName)) {
153
+ return { name: uniqueName, identity: symbol.escapedName };
154
+ }
155
+ // `Ext` contributes an optional structural property. A required application
156
+ // column with the same spelling is ordinary data and must not disappear.
157
+ if (symbol.name === `__${TAG_NAMES.extension}` && (symbol.flags & SymbolFlags.Optional) !== 0) {
158
+ return { name: TAG_NAMES.extension };
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ // Both vocabularies as sets, behind predicates rather than `has` plus a cast. The tags fix
164
+ // each of these to a literal, but the checker hands them back as `string`, so this is the
165
+ // one place the narrowing has to be earned — and a predicate earns it for every caller.
166
+ const SQL_TYPE_SET: ReadonlySet<string> = new Set<string>(SQL_TYPES);
167
+ const RELATION_KIND_SET: ReadonlySet<string> = new Set<string>(RELATION_KINDS);
168
+ const PROTO_SCALAR_SET: ReadonlySet<string> = new Set<string>(PROTO_SCALARS);
169
+ const REFERENTIAL_ACTION_SET: ReadonlySet<string> = new Set<string>([
170
+ 'cascade',
171
+ 'restrict',
172
+ 'set null',
173
+ 'set default',
174
+ 'no action',
175
+ ]);
176
+ const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
177
+
178
+ function isSqlType(value: string): value is SqlType {
179
+ return SQL_TYPE_SET.has(value);
180
+ }
181
+
182
+ function isRelationKind(value: string): value is RelationKind {
183
+ return RELATION_KIND_SET.has(value);
184
+ }
185
+
186
+ function isProtoScalar(value: string): value is ProtoScalar {
187
+ return PROTO_SCALAR_SET.has(value);
188
+ }
189
+
190
+ function isReferentialAction(value: string): value is ReferentialAction {
191
+ return REFERENTIAL_ACTION_SET.has(value);
192
+ }
193
+
194
+ /**
195
+ * A {@link Constraints} under construction.
196
+ *
197
+ * `Constraints` is readonly, and the two places that build one fill it in field by field —
198
+ * one keyword at a time from the tags, and then `Length<N>`'s implied maximum. Naming the
199
+ * mutable form is what lets the finished object be returned as a `Constraints` without an
200
+ * assertion, and it was previously spelled out three times.
201
+ */
202
+ type MutableConstraints = { -readonly [K in keyof Constraints]: Constraints[K] };
203
+
204
+ /** Whether a property symbol is one of our tag slots rather than real data. */
205
+ function isTagProperty(symbol: TsSymbol): boolean {
206
+ return recognizedTag(symbol) !== undefined;
207
+ }
208
+
209
+ /**
210
+ * Whether a property is a phantom slot — keyed by a `unique symbol` — rather than data.
211
+ * A superset of `isTagProperty`: it also covers brands from
212
+ * `aot-validator/src/advanced`, `io-ts`-style nominal markers, and anyone else's
213
+ * phantom parameter.
214
+ *
215
+ * Treating those as data rather than as phantom is what made `Brand<number, 'UserId'>`
216
+ * refuse: the brand object looked like a second data part of the intersection. And a
217
+ * symbol-keyed property is never data in a checked position anyway — it cannot cross a
218
+ * JSON boundary, so there is nothing to check and nothing lost by ignoring it.
219
+ */
220
+ function isPhantomProperty(symbol: TsSymbol): boolean {
221
+ return isTagProperty(symbol) || symbol.escapedName.startsWith('__@');
222
+ }
223
+
224
+ const OPTIONAL = SymbolFlags.Optional;
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Reflector
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /** A property type taken apart: `(string & Min<3>) | null` → nullable, `[string & Min<3>]`. */
231
+ interface NullableSplit {
232
+ readonly nullable: boolean;
233
+ readonly optional: boolean;
234
+ readonly rest: readonly Type[];
235
+ }
236
+
237
+ interface Frame {
238
+ readonly id: number;
239
+ /** Assigned lazily: an anonymous type only needs a name if a cycle refers to it. */
240
+ name: string | undefined;
241
+ referenced: boolean;
242
+ }
243
+
244
+ /**
245
+ * One reflector per file, not per type: the budget, the helper names and the
246
+ * duplicate-tag detector are all whole-program facts, and splitting them per type
247
+ * would let a 200-type file blow past every cap one type at a time.
248
+ */
249
+ export class Reflector {
250
+ readonly diagnostics: ReflectDiagnostic[] = [];
251
+
252
+ readonly #checker: Checker;
253
+ readonly #location: Node;
254
+ readonly #limits: ReflectLimits;
255
+ readonly #naming: NamingStrategy;
256
+
257
+ #nodes = 0;
258
+ /** Types currently being walked, innermost last. The cycle guard. */
259
+ readonly #stack: Frame[] = [];
260
+ /** Name → the type id that claimed it, so two `User`s do not share a helper. */
261
+ readonly #names = new Map<string, number>();
262
+ #anonymous = 0;
263
+ /** Tag basename → the full escaped name first seen for it. Plan D5. */
264
+ readonly #tagIdentity = new Map<string, string>();
265
+
266
+ constructor(checker: Checker, location: Node, options: ReflectOptions = {}) {
267
+ this.#checker = checker;
268
+ this.#location = location;
269
+ this.#limits = { ...DEFAULT_LIMITS, ...options.limits };
270
+ this.#naming = options.naming ?? {};
271
+ }
272
+
273
+ // -------------------------------------------------------------------------
274
+ // Public surface
275
+ // -------------------------------------------------------------------------
276
+
277
+ /** The structural IR of a type. Total: always returns a node. */
278
+ typeIR(type: Type, path = ''): TypeIR {
279
+ return this.#type(type, path, 0);
280
+ }
281
+
282
+ /**
283
+ * The structural IR of a protobuf message, with field-number validation enabled.
284
+ *
285
+ * Ordinary validators may reflect an object whose properties have no protobuf
286
+ * numbers; only a protobuf call makes complete numbering mandatory. Keeping that
287
+ * decision here means every protobuf back-end receives the same checked IR.
288
+ */
289
+ protobufIR(type: Type): TypeIR {
290
+ const name = typeName(type) ?? 'message';
291
+ const node = this.#type(type, name, 0);
292
+ this.#validateProtoNumbers(node, name, new Set<ObjectIR>());
293
+ return node;
294
+ }
295
+
296
+ /**
297
+ * Reflect a gRPC service while keeping every message on the protobuf path.
298
+ *
299
+ * The service shell is not a protobuf message: its properties are methods,
300
+ * and each method owns two message roots. Reflecting those roots here keeps
301
+ * field-number validation and every downstream codec on the one TypeIR walk.
302
+ */
303
+ grpcServiceIR(type: Type): GrpcServiceIR {
304
+ const methods: GrpcMethodIR[] = [];
305
+ for (const methodSymbol of this.#checker.getPropertiesOfType(type)) {
306
+ if (isTagProperty(methodSymbol)) continue;
307
+ const method = methodSymbol.name;
308
+ const methodType = this.#typeOf(methodSymbol);
309
+ if (methodType === undefined) {
310
+ this.#refuse(method, 'the checker did not resolve a type for this gRPC method');
311
+ continue;
312
+ }
313
+
314
+ const requestType = this.#grpcMember(methodType, method, 'request');
315
+ const responseType = this.#grpcMember(methodType, method, 'response');
316
+ if (requestType === undefined || responseType === undefined) continue;
317
+
318
+ const requestName = typeName(requestType) ?? `${pascalIdentifier(method)}Request`;
319
+ const responseName = typeName(responseType) ?? `${pascalIdentifier(method)}Response`;
320
+ const request = this.#type(requestType, requestName, 0);
321
+ const response = this.#type(responseType, responseName, 0);
322
+ this.#validateProtoNumbers(request, requestName, new Set<ObjectIR>());
323
+ this.#validateProtoNumbers(response, responseName, new Set<ObjectIR>());
324
+
325
+ methods.push({
326
+ name: method,
327
+ request,
328
+ requestName,
329
+ response,
330
+ responseName,
331
+ requestStream: this.#grpcStreamFlag(methodType, method, 'requestStream'),
332
+ responseStream: this.#grpcStreamFlag(methodType, method, 'responseStream'),
333
+ });
334
+ }
335
+ if (methods.length === 0) {
336
+ this.#refuse(typeName(type) ?? 'service', 'a gRPC service must declare at least one method');
337
+ }
338
+ return { methods };
339
+ }
340
+
341
+ /**
342
+ * The schema IR of a *tagged entity* type: the only way a table's IR is produced.
343
+ *
344
+ * There used to be a second producer that read the same document back out of a builder
345
+ * value, and for the length of the migration the two had to agree node for node. Now this
346
+ * is it, so every SQL snapshot, DDL golden and JSON Schema contract in the repository is
347
+ * downstream of what this returns (REQ-TF-7, REQ-TF-12). `reflect.spec.ts` writes out the
348
+ * answer for the two-table corpus, which is what stands in for that differential.
349
+ */
350
+ schemaIR(type: Type, fallbackTable?: string): SchemaIR {
351
+ const tags = this.#readTags(type);
352
+ const table = literalOf(this.#nonNullable(tags.get('table')));
353
+
354
+ const name = typeName(type);
355
+ const tableName = typeof table === 'string' ? table : (fallbackTable ?? name ?? 'unknown');
356
+ const explicitPhysicalTable = this.#physicalNameOf(type);
357
+ const physicalTable =
358
+ explicitPhysicalTable ?? (this.#naming.table === undefined ? tableName : this.#naming.table(tableName));
359
+ if (typeof table !== 'string') {
360
+ this.#refuse(name ?? 'entity', "no Table<'name'> tag; the table name cannot be guessed from the type name");
361
+ }
362
+
363
+ const columns: ColumnIR[] = [];
364
+ const relations: RelationIR[] = [];
365
+
366
+ for (const symbol of this.#checker.getPropertiesOfType(type)) {
367
+ if (isTagProperty(symbol)) continue;
368
+ const property = symbol.name;
369
+ const propertyType = this.#typeOf(symbol);
370
+ if (!propertyType) {
371
+ this.#refuse(property, 'the checker did not resolve a type for this property');
372
+ continue;
373
+ }
374
+
375
+ // Nullability is TypeScript's job, not a tag's (REQ-TF-2). Splitting it off
376
+ // first is not cosmetic: `getPropertiesOfType` on `(string & Min<3>) | null`
377
+ // returns NOTHING, because a union only has the properties every member has and
378
+ // `null` has none. Reading tags off the whole property type would therefore find
379
+ // none, and the column would come back untagged and unconstrained.
380
+ const split = this.#splitNullable(propertyType);
381
+ const propertyTags = this.#mergeTags(split.rest);
382
+
383
+ const relation = this.#relationOf(property, propertyTags);
384
+ if (relation) {
385
+ relations.push(relation);
386
+ continue;
387
+ }
388
+ columns.push(this.#column(property, split, propertyTags, tableName));
389
+ }
390
+
391
+ const fts = literalOf(this.#nonNullable(tags.get('ftsTable')));
392
+ const shardKey = this.#tableColumnList('ShardKey', tags.get('shardKey'));
393
+ const sortKey = this.#tableColumnList('SortKey', tags.get('sortKey'));
394
+ const rowstore = tags.has('rowstore');
395
+ const softDeleteTag = this.#nonNullable(tags.get('softDelete'));
396
+ const softDeleteName = literalOf(softDeleteTag);
397
+ const primaryKey = columns.filter(c => c.primaryKey).map(c => c.name);
398
+ if (primaryKey.length > 1) {
399
+ const serialKey = columns.find(column => column.primaryKey && column.serial);
400
+ if (serialKey) {
401
+ this.#refuse(
402
+ tableName,
403
+ `${tableName}.${serialKey.name}: a \`Serial\` column cannot be part of a composite primary key ` +
404
+ `(key is (${primaryKey.join(', ')})); give the table a single-column surrogate key or drop \`Serial\``,
405
+ );
406
+ }
407
+ }
408
+ const foreignKeys = this.#foreignKeysOf(tableName, tags, columns);
409
+ const columnNames = new Set(columns.map(column => column.name));
410
+ for (const [label, names] of [
411
+ ['ShardKey', shardKey],
412
+ ['SortKey', sortKey],
413
+ ] as const) {
414
+ for (const column of names ?? []) {
415
+ if (!columnNames.has(column)) {
416
+ this.#refuse(tableName, `${label} names unknown column "${column}" on "${tableName}"`);
417
+ }
418
+ }
419
+ }
420
+ const physicalColumns = new Map<string, string>();
421
+ for (const column of columns) {
422
+ const previous = physicalColumns.get(column.physicalName);
423
+ if (previous === undefined) {
424
+ physicalColumns.set(column.physicalName, column.name);
425
+ continue;
426
+ }
427
+ this.#refuse(
428
+ tableName,
429
+ `\`${previous}\` and \`${column.name}\` both map to the column \`${column.physicalName}\`; ` +
430
+ "rename one property or give one an explicit Physical<'…'>",
431
+ );
432
+ }
433
+
434
+ // A table with no primary key is refused rather than accepted with an empty one. This is
435
+ // the one rule `defineSchema` enforced that has no other home: it threw a `SchemaError`,
436
+ // synchronously, on a column map with no `primaryKey()` in it. The reason outlives the
437
+ // function — `findById`, `update` and `delete` all build their `WHERE` out of
438
+ // `primaryKey`, so an empty one compiles a statement with no conditions. `delete(1)` on a
439
+ // key-less table is `DELETE FROM users`.
440
+ if (typeof table === 'string' && primaryKey.length === 0) {
441
+ this.#refuse(
442
+ tableName,
443
+ 'no PrimaryKey column. Every table needs one: findById, update and delete build their ' +
444
+ 'WHERE clause from it, and an empty key compiles to a statement with no conditions.',
445
+ );
446
+ }
447
+
448
+ const tableOptions: TableOptions | undefined =
449
+ shardKey === undefined && sortKey === undefined && !rowstore
450
+ ? undefined
451
+ : {
452
+ ...(shardKey === undefined ? {} : { shardKey }),
453
+ ...(sortKey === undefined ? {} : { sortKey }),
454
+ ...(rowstore ? { rowstore: true } : {}),
455
+ };
456
+ let softDelete: SchemaIR['softDelete'];
457
+ if (softDeleteTag !== undefined) {
458
+ if (typeof softDeleteName !== 'string') {
459
+ this.#refuse(tableName, "SoftDelete<'column'> needs a string literal column name");
460
+ } else {
461
+ const column = columns.find(candidate => candidate.name === softDeleteName);
462
+ if (column === undefined) {
463
+ this.#refuse(tableName, `SoftDelete<'${softDeleteName}'> names a column that does not exist on ${tableName}`);
464
+ } else {
465
+ if (!column.nullable) {
466
+ this.#refuse(
467
+ tableName,
468
+ `${tableName}: SoftDelete<'${softDeleteName}'> names a non-nullable column; ` +
469
+ 'a soft-delete column must be nullable because IS NULL is what "live" means',
470
+ );
471
+ }
472
+ if (column.sql !== 'timestamp') {
473
+ this.#refuse(
474
+ tableName,
475
+ `${tableName}: SoftDelete<'${softDeleteName}'> names a ${String(column.sql)} column; ` +
476
+ "a soft-delete column must use Sql<'timestamp'>",
477
+ );
478
+ }
479
+ if (column.nullable && column.sql === 'timestamp') softDelete = { column: softDeleteName };
480
+ }
481
+ }
482
+ }
483
+
484
+ return {
485
+ table: tableName,
486
+ physicalTable,
487
+ columns,
488
+ primaryKey,
489
+ relations,
490
+ foreignKeys,
491
+ ...(typeof fts === 'string' || fts === true ? { ftsTable: fts } : {}),
492
+ ...(tableOptions === undefined ? {} : { tableOptions }),
493
+ ...(softDelete === undefined ? {} : { softDelete }),
494
+ };
495
+ }
496
+
497
+ /**
498
+ * The columns of any object type, each with its own optionality — what the JSON
499
+ * Schema back-end consumes (`jsonSchemaFromShape`).
500
+ *
501
+ * `schemaIR` above is for a tagged *entity*: it wants a `Table<'name'>` tag, reads
502
+ * relations, and computes a primary key. A document is generated from something
503
+ * weaker and more general — `CreateDTO<User>`, `ReadDTO<User>`, or a `Pick` of either
504
+ * — and none of those carry a table name, because a mapped type drops the
505
+ * symbol-keyed entity tags along with everything else non-string. Demanding one would
506
+ * make the type-driven `toJsonSchema<T>()` work on exactly one shape per table.
507
+ *
508
+ * The optionality is the type's own, read off the property symbol. That is the whole
509
+ * reason this is not `schemaIR` with a flag: `CreateDTO<User>` has already applied the
510
+ * "a column with a default may be omitted" rule that the `'create'` variant applies by
511
+ * hand, and reading it back off the type is what makes the two paths agree by
512
+ * construction rather than by a rule written twice.
513
+ *
514
+ * Relations are skipped, as they are in `schemaIR`: a join target is not a column and
515
+ * has no place in a column's document. `toJsonSchemaWithRelations` adds `$ref`s on top.
516
+ */
517
+ shapeIR(type: Type): ShapeIR {
518
+ const shape: ShapeColumnIR[] = [];
519
+
520
+ for (const symbol of this.#checker.getPropertiesOfType(type)) {
521
+ if (isTagProperty(symbol)) continue;
522
+ const property = symbol.name;
523
+ const propertyType = this.#typeOf(symbol);
524
+ if (!propertyType) {
525
+ this.#refuse(property, 'the checker did not resolve a type for this property');
526
+ continue;
527
+ }
528
+
529
+ // Same order as `schemaIR`, and for the same reason: reading tags off
530
+ // `(string & Min<3>) | null` finds none, because a union only has the properties
531
+ // every member has and `null` has none.
532
+ const split = this.#splitNullable(propertyType);
533
+ const propertyTags = this.#mergeTags(split.rest);
534
+ if (this.#relationOf(property, propertyTags)) continue;
535
+
536
+ shape.push({
537
+ column: this.#column(property, split, propertyTags),
538
+ optional: (symbol.flags & OPTIONAL) !== 0,
539
+ });
540
+ }
541
+
542
+ return shape;
543
+ }
544
+
545
+ // -------------------------------------------------------------------------
546
+ // Structural walk
547
+ // -------------------------------------------------------------------------
548
+
549
+ #type(type: Type, path: string, depth: number): TypeIR {
550
+ if (depth > this.#limits.maxDepth) {
551
+ return this.#unsupported(path, `nesting deeper than ${this.#limits.maxDepth} levels`);
552
+ }
553
+ if (++this.#nodes > this.#limits.maxNodes) {
554
+ return this.#unsupported(path, `more than ${this.#limits.maxNodes} IR nodes in one file`);
555
+ }
556
+
557
+ // A type parameter is not a type yet. Reflecting `T` would produce a check for
558
+ // whatever constraint it happens to have, which is a check for the wrong thing,
559
+ // so it is refused where it is written rather than at the instantiation site.
560
+ if (type.isTypeParameter()) {
561
+ return this.#unsupported(path, 'a generic type parameter has no shape to reflect', this.#print(type));
562
+ }
563
+ if (type.isErrorType()) {
564
+ return this.#unsupported(path, 'the checker could not resolve this type', this.#print(type));
565
+ }
566
+
567
+ const intrinsic = this.#intrinsic(type, path);
568
+ if (intrinsic) return intrinsic;
569
+
570
+ const literal = this.#literal(type, path);
571
+ if (literal) return literal;
572
+
573
+ const template = this.#template(type, path);
574
+ if (template) return template;
575
+
576
+ if (type.isUnionType()) return this.#union(type.getTypes(), path, depth);
577
+ if (type.isIntersectionType()) return this.#intersection(type, path, depth);
578
+
579
+ return this.#object(type, path, depth, this.#readTags(type));
580
+ }
581
+
582
+ /** `string`, `number`, `null`, `never`, … — everything with an `intrinsicName`. */
583
+ #intrinsic(type: Type, path: string): TypeIR | undefined {
584
+ if (!type.isIntrinsicType()) return undefined;
585
+ switch (type.intrinsicName) {
586
+ case 'string':
587
+ return { kind: 'scalar', scalar: 'string' };
588
+ case 'number':
589
+ return { kind: 'scalar', scalar: 'number' };
590
+ case 'boolean':
591
+ return { kind: 'scalar', scalar: 'boolean' };
592
+ case 'bigint':
593
+ return { kind: 'scalar', scalar: 'bigint' };
594
+ case 'null':
595
+ return { kind: 'null' };
596
+ case 'undefined':
597
+ case 'void':
598
+ return { kind: 'undefined' };
599
+ // `any` and `unknown` are refused rather than mapped to `UnknownIR`. That node
600
+ // means "a `json` column whose payload shape was not declared", where accepting
601
+ // anything is the documented contract. In a validated position it would mean a
602
+ // validator that always passes, which is a hole with a green test beside it.
603
+ case 'any':
604
+ return this.#unsupported(path, '`any` disables the check it would have to emit; declare the shape');
605
+ case 'unknown':
606
+ return this.#unsupported(path, '`unknown` has no shape to check; declare the shape or use a `json` payload');
607
+ case 'never':
608
+ return this.#unsupported(path, '`never` has no values, so no check can succeed');
609
+ case 'object':
610
+ return this.#unsupported(path, 'bare `object` has no properties to check; declare them');
611
+ case 'symbol':
612
+ return this.#unsupported(path, 'a symbol cannot cross a JSON boundary');
613
+ default:
614
+ return this.#unsupported(path, `unhandled primitive \`${type.intrinsicName}\``);
615
+ }
616
+ }
617
+
618
+ #literal(type: Type, path: string): TypeIR | undefined {
619
+ if (type.isStringLiteralType() || type.isNumberLiteralType() || type.isBooleanLiteralType()) {
620
+ return { kind: 'literal', value: type.value };
621
+ }
622
+ // A bigint literal has no JSON spelling and `LiteralIR.value` deliberately does
623
+ // not admit one — a `bigint` column is a string on the wire (plan D3), and a
624
+ // *literal* bigint would have to pick a side.
625
+ if (type.isBigIntLiteralType()) {
626
+ return this.#unsupported(path, 'a bigint literal type has no wire representation', this.#print(type));
627
+ }
628
+ return undefined;
629
+ }
630
+
631
+ /**
632
+ * `` `${string}@${string}` `` → a `string` with a derived `pattern`. Needed for its
633
+ * own sake, and needed because without it a template literal type falls through to
634
+ * `#object`: `string` carries a numeric index signature, so the refusal it would
635
+ * collect is "`Record<string, T>` cannot be modelled", which is true of neither the
636
+ * type nor the problem.
637
+ *
638
+ * Only placeholders with an exact character class are derivable. `Uppercase<string>`
639
+ * is not one, and guessing `[\s\S]*` for it would produce a pattern that accepts
640
+ * strings the type rejects — a validator that is wrong in the permissive direction.
641
+ */
642
+ #template(type: Type, path: string): TypeIR | undefined {
643
+ if (type.isStringMappingType()) {
644
+ return this.#unsupported(
645
+ path,
646
+ 'a string-mapping type (`Uppercase`, `Capitalize`, …) has no equivalent pattern; spell the constraint with `Pattern<…>`',
647
+ this.#print(type),
648
+ );
649
+ }
650
+ if (!type.isTemplateLiteralType()) return undefined;
651
+
652
+ const spans = type.getTypes();
653
+ const parts: string[] = [];
654
+ for (const [index, text] of type.texts.entries()) {
655
+ parts.push(escapeRegExp(text));
656
+ const span = spans[index];
657
+ if (span === undefined) continue;
658
+ const source = placeholderPattern(span);
659
+ if (source === undefined) {
660
+ return this.#unsupported(
661
+ path,
662
+ `the placeholder \`${this.#print(span)}\` in this template literal type has no equivalent pattern; spell the constraint with \`Pattern<…>\``,
663
+ this.#print(type),
664
+ );
665
+ }
666
+ parts.push(source);
667
+ }
668
+ return { kind: 'scalar', scalar: 'string', constraints: { pattern: `^${parts.join('')}$` } };
669
+ }
670
+
671
+ #union(members: readonly Type[], path: string, depth: number): TypeIR {
672
+ // `boolean` is `true | false` in the checker, not an intrinsic. Recognising it
673
+ // here is load-bearing: without it the walk falls through to the object branch
674
+ // and emits a property check for a primitive.
675
+ //
676
+ // The data part, not the member itself: `boolean & Sql<'boolean'>` is normalised by
677
+ // the checker into `(false & Sql<'boolean'>) | (true & Sql<'boolean'>)` — the same
678
+ // distribution that makes `(T | null) & Unique` a trap — so a tagged boolean column
679
+ // arrives here as two *intersections*. Reading through them is what stops a tagged
680
+ // boolean column emitting two literal comparisons where a `typeof` check is meant.
681
+ if (members.length === 2 && members.every(m => this.#dataPart(m).isBooleanLiteralType())) {
682
+ return this.#applyConstraints({ kind: 'scalar', scalar: 'boolean' }, this.#mergeTags(members));
683
+ }
684
+ // The checker sorts `null` and `undefined` to the FRONT of a union. `../ir`'s
685
+ // `withNull` puts them at the back, and the IR has to say one of the two — a union
686
+ // whose member order depends on which producer built it turns every golden into
687
+ // `[null, string]` in one place and `[string, null]` in another.
688
+ const nullish = (m: Type): boolean =>
689
+ m.isIntrinsicType() && (m.intrinsicName === 'null' || m.intrinsicName === 'undefined');
690
+ const ordered = [...members.filter(m => !nullish(m)), ...members.filter(nullish)];
691
+ return { kind: 'union', members: ordered.map(m => this.#type(m, path, depth + 1)) };
692
+ }
693
+
694
+ /**
695
+ * `number & Min<18>` is an intersection of one data part and one tag part. The tags
696
+ * were already collected by the caller's `#readTags` over the whole intersection —
697
+ * they are just properties — so this only has to find the part that carries data.
698
+ */
699
+ // Typed `IntersectionType` rather than `Type`: `getTypes()` only exists on the
700
+ // narrowed form, and the `isIntersectionType()` guard at the call site is what
701
+ // establishes it. Widening the parameter here would throw the narrowing away.
702
+ #intersection(type: IntersectionType, path: string, depth: number): TypeIR {
703
+ const tags = this.#readTags(type);
704
+ const parts = type.getTypes().filter(part => !this.#isTagOnly(part));
705
+
706
+ if (parts.length === 0) {
707
+ return this.#unsupported(path, 'a tags-only intersection carries no value', this.#print(type));
708
+ }
709
+ const [sole] = parts;
710
+ if (parts.length === 1 && sole !== undefined) {
711
+ return this.#applyConstraints(this.#type(sole, path, depth), tags);
712
+ }
713
+ // Several data parts: only an intersection of object types has a meaning we can
714
+ // check (merge the properties). Anything else — `string & number` — is `never` in
715
+ // practice and a mistake in the declaration.
716
+ if (parts.every(part => this.#isPlainObject(part))) {
717
+ return this.#object(type, path, depth, tags);
718
+ }
719
+ return this.#unsupported(
720
+ path,
721
+ 'an intersection of unrelated non-object types cannot be checked',
722
+ this.#print(type),
723
+ );
724
+ }
725
+
726
+ #object(type: Type, path: string, depth: number, tags: ReadonlyMap<TagField, Type>): TypeIR {
727
+ const checker = this.#checker;
728
+
729
+ // Order matters here, and it is not arbitrary. Arrays and tuples have a numeric
730
+ // index signature, so they must be recognised before the index-signature refusal;
731
+ // `Date` is an interface, so it must be recognised before the property walk.
732
+ if (checker.isArrayType(type)) {
733
+ const element = this.#typeArguments(type)[0];
734
+ if (!element) return this.#unsupported(path, 'an array type with no element type', this.#print(type));
735
+ return this.#applyConstraints({ kind: 'array', element: this.#type(element, `${path}[]`, depth + 1) }, tags);
736
+ }
737
+
738
+ if (checker.isTupleType(type)) return this.#tuple(type, path, depth);
739
+
740
+ const symbol = type.getSymbol();
741
+ if (symbol?.name === 'Date') return this.#applyConstraints({ kind: 'scalar', scalar: 'date' }, tags);
742
+
743
+ // A class instance is refused for a reason that is easy to miss: its *declared*
744
+ // shape is checkable, but a validated value that passes it is a plain object, not
745
+ // an instance, so private state, prototype methods and `instanceof` are all
746
+ // silently absent. Naming the refusal is honest; pretending is not.
747
+ if (symbol && (symbol.flags & SymbolFlags.Class) !== 0) {
748
+ return this.#unsupported(
749
+ path,
750
+ `\`${symbol.name}\` is a class; a checked value is a plain object, not an instance`,
751
+ );
752
+ }
753
+
754
+ if (checker.getSignaturesOfType(type, SignatureKind.Call).length > 0) {
755
+ return this.#unsupported(path, 'a function cannot be validated or serialised', this.#print(type));
756
+ }
757
+ if (checker.getSignaturesOfType(type, SignatureKind.Construct).length > 0) {
758
+ return this.#unsupported(path, 'a constructor cannot be validated or serialised', this.#print(type));
759
+ }
760
+
761
+ if (this.#hasIndexSignature(type)) {
762
+ // Measured, not assumed: `getPropertiesOfType` does not surface index
763
+ // signatures at all, and `getIndexInfosOfType` returns entries whose `keyType`
764
+ // the client fails to marshal. So an index signature is *detectable* but not
765
+ // *readable*, and a `Record<string, T>` would otherwise reflect as an object
766
+ // with zero properties — a validator that accepts `{}` and everything else.
767
+ return this.#unsupported(
768
+ path,
769
+ 'an index signature is not readable through the checker API, so `Record<string, T>` cannot be modelled',
770
+ this.#print(type),
771
+ );
772
+ }
773
+
774
+ const properties = checker
775
+ .getPropertiesOfType(type)
776
+ .filter(s => !isPhantomProperty(s))
777
+ .map(member => ({ member, type: this.#typeOf(member) }));
778
+ if (properties.length === 0) {
779
+ return this.#unsupported(path, 'an object type with no properties admits every object', this.#print(type));
780
+ }
781
+
782
+ // A method makes this a behavioural type rather than a data type, and refusing it
783
+ // here — naming the type — is what rules out `Map`, `Set`, `Promise`, a typed array
784
+ // and any class with a prototype method. One rule instead of a list of special
785
+ // cases, and the message points at the property that gave it away.
786
+ const method = properties.find(
787
+ p => p.type !== undefined && checker.getSignaturesOfType(p.type, SignatureKind.Call).length > 0,
788
+ );
789
+ if (method) {
790
+ return this.#unsupported(
791
+ path,
792
+ `\`${this.#print(type)}\` has a method (\`${method.member.name}\`); only data types can be checked`,
793
+ );
794
+ }
795
+
796
+ const cycle = this.#stack.find(frame => frame.id === type.id);
797
+ if (cycle) {
798
+ cycle.referenced = true;
799
+ cycle.name ??= this.#claimName(`Anonymous${++this.#anonymous}`, type.id);
800
+ return { kind: 'ref', name: cycle.name };
801
+ }
802
+
803
+ const declared = typeName(type);
804
+ const frame: Frame = {
805
+ id: type.id,
806
+ name: declared === undefined ? undefined : this.#claimName(declared, type.id),
807
+ referenced: false,
808
+ };
809
+ this.#stack.push(frame);
810
+
811
+ const members: PropertyIR[] = [];
812
+ for (const { member, type: propertyType } of properties) {
813
+ const childPath = path === '' ? member.name : `${path}.${member.name}`;
814
+ const propertyTags =
815
+ propertyType === undefined
816
+ ? new Map<TagField, Type>()
817
+ : this.#mergeTags(this.#splitNullable(propertyType).rest);
818
+ const protoFieldType = this.#nonNullable(propertyTags.get('protoField'));
819
+ const protoField = numberOf(protoFieldType);
820
+ if (protoFieldType !== undefined && protoField === undefined) {
821
+ this.#refuse(childPath, 'ProtoField<N> needs a number literal argument', this.#print(protoFieldType));
822
+ }
823
+ members.push({
824
+ name: member.name,
825
+ type: propertyType
826
+ ? this.#type(propertyType, childPath, depth + 1)
827
+ : this.#unsupported(childPath, 'the checker did not resolve a type for this property'),
828
+ // An optional property's type does NOT carry `| undefined` here, even under
829
+ // `exactOptionalPropertyTypes` — the checker reports `nickname?: string` as
830
+ // `string`. So `optional` is the only record of absence being allowed, and an
831
+ // emitter that ignores it produces a validator that rejects every value the
832
+ // type accepts.
833
+ optional: (member.flags & OPTIONAL) !== 0,
834
+ // `readonly` is not marshalled onto the symbol, and it is not a runtime
835
+ // distinction anyway: it constrains writes, and validation reads. Recorded as
836
+ // `false` rather than guessed at.
837
+ readonly: false,
838
+ ...(protoField === undefined ? {} : { protoField }),
839
+ });
840
+ }
841
+
842
+ this.#stack.pop();
843
+
844
+ if (frame.name !== undefined && this.#names.size > this.#limits.maxHelpers) {
845
+ return this.#unsupported(path, `more than ${this.#limits.maxHelpers} named object types in one file`);
846
+ }
847
+
848
+ return { kind: 'object', ...(frame.name === undefined ? {} : { name: frame.name }), properties: members };
849
+ }
850
+
851
+ #tuple(type: Type, path: string, depth: number): TypeIR {
852
+ // `TupleType.elementFlags` is in the `.d.ts` but comes back `undefined` over the
853
+ // client, so optional and rest elements are not distinguishable structurally.
854
+ // They ARE visible in the printed form, and refusing on that is better than
855
+ // emitting a fixed-length check for a variadic tuple.
856
+ const printed = this.#print(type);
857
+ if (/[?.]/.test(printed.slice(printed.indexOf('[')))) {
858
+ return this.#unsupported(path, 'a tuple with optional or rest elements is not modelled', printed);
859
+ }
860
+ const elements = this.#typeArguments(type);
861
+ return { kind: 'tuple', elements: elements.map((el, i) => this.#type(el, `${path}[${i}]`, depth + 1)) };
862
+ }
863
+
864
+ // -------------------------------------------------------------------------
865
+ // Tags
866
+ // -------------------------------------------------------------------------
867
+
868
+ /**
869
+ * Every tag slot on a type, keyed by the IR field it sets. Reads the whole type
870
+ * rather than an intersection part, because a tag *is* a property and the checker
871
+ * already merged them.
872
+ */
873
+ #readTags(type: Type): ReadonlyMap<TagField, Type> {
874
+ const found = new Map<TagField, Type>();
875
+ for (const symbol of this.#checker.getPropertiesOfType(type)) {
876
+ const tag = recognizedTag(symbol);
877
+ if (tag === undefined) continue;
878
+ this.#rememberTagIdentity(symbol, tag);
879
+
880
+ const field = TAG_FIELD_BY_NAME.get(tag.name);
881
+ if (field === undefined) continue;
882
+ const value = this.#typeOf(symbol);
883
+ if (value) found.set(field, value);
884
+ }
885
+ return found;
886
+ }
887
+
888
+ /** The public `Physical<Name>` tag's dedicated table-or-column reader. */
889
+ #physicalNameOf(type: Type): string | undefined {
890
+ for (const symbol of this.#checker.getPropertiesOfType(type)) {
891
+ const tag = recognizedTag(symbol);
892
+ if (tag?.name !== PHYSICAL_TAG_NAME) continue;
893
+ this.#rememberTagIdentity(symbol, tag);
894
+ const value = this.#typeOf(symbol);
895
+ const physical = value === undefined ? undefined : literalOf(this.#nonNullable(value));
896
+ if (typeof physical === 'string') return physical;
897
+ this.#refuse(symbol.name, 'Physical<Name> needs a string literal argument');
898
+ }
899
+ return undefined;
900
+ }
901
+
902
+ #physicalNameFrom(members: readonly Type[]): string | undefined {
903
+ let physical: string | undefined;
904
+ for (const member of members) physical = this.#physicalNameOf(member) ?? physical;
905
+ return physical;
906
+ }
907
+
908
+ #rememberTagIdentity(symbol: TsSymbol, tag: RecognizedTag): void {
909
+ if (tag.identity === undefined) return;
910
+ const first = this.#tagIdentity.get(tag.name);
911
+ if (first === undefined) {
912
+ this.#tagIdentity.set(tag.name, tag.identity);
913
+ return;
914
+ }
915
+ if (first === tag.identity) return;
916
+ this.#refuse(
917
+ symbol.name,
918
+ `the tag \`${tag.name}\` resolves to two different declarations (\`${first}\` and \`${tag.identity}\`), ` +
919
+ 'which means two copies of @zmdb/schema are installed; deduplicate them',
920
+ );
921
+ }
922
+
923
+ /** Union members, minus `null` and `undefined`, plus whether either was there. */
924
+ #splitNullable(type: Type): NullableSplit {
925
+ const members = type.isUnionType() ? type.getTypes() : [type];
926
+ const isNullish = (m: Type): boolean =>
927
+ m.isIntrinsicType() && (m.intrinsicName === 'null' || m.intrinsicName === 'undefined');
928
+ return {
929
+ nullable: members.some(m => m.isIntrinsicType() && m.intrinsicName === 'null'),
930
+ optional: members.some(m => m.isIntrinsicType() && m.intrinsicName === 'undefined'),
931
+ rest: members.filter(m => !isNullish(m)),
932
+ };
933
+ }
934
+
935
+ /** Tags from every member of a union. A tag on one arm is a tag on the column. */
936
+ #mergeTags(members: readonly Type[]): ReadonlyMap<TagField, Type> {
937
+ const [only] = members;
938
+ if (members.length === 1 && only !== undefined) return this.#readTags(only);
939
+ const merged = new Map<TagField, Type>();
940
+ for (const member of members) for (const [field, value] of this.#readTags(member)) merged.set(field, value);
941
+ return merged;
942
+ }
943
+
944
+ /** `string & Length<64>` → `string`. A type with no tag parts is its own data part. */
945
+ #dataPart(type: Type): Type {
946
+ // Written as a positive branch rather than an early return on the negation: the
947
+ // `this is IntersectionType` predicate does not survive `if (!…) return`, and
948
+ // without it `getTypes()` is not in scope.
949
+ if (type.isIntersectionType()) {
950
+ const parts = type.getTypes().filter(part => !this.#isTagOnly(part));
951
+ const [sole] = parts;
952
+ return parts.length === 1 && sole !== undefined ? sole : type;
953
+ }
954
+ return type;
955
+ }
956
+
957
+ /**
958
+ * A type whose every property is a phantom slot: `Min<18>`, `Serial`, `Table<'t'>`,
959
+ * and equally `{ readonly [__brand]: 'UserId' }`. Deliberately not restricted to
960
+ * *our* tags — see `isPhantomProperty`.
961
+ */
962
+ #isTagOnly(type: Type): boolean {
963
+ const properties = this.#checker.getPropertiesOfType(type);
964
+ return properties.length > 0 && properties.every(isPhantomProperty);
965
+ }
966
+
967
+ #isPlainObject(type: Type): boolean {
968
+ return (
969
+ type.isObjectType() &&
970
+ !this.#checker.isArrayType(type) &&
971
+ !this.#checker.isTupleType(type) &&
972
+ this.#checker.getSignaturesOfType(type, SignatureKind.Call).length === 0
973
+ );
974
+ }
975
+
976
+ /** Fold `Min`/`Max`/`MinLength`/`MaxLength`/`Pattern` into a scalar or array node. */
977
+ #applyConstraints(node: TypeIR, tags: ReadonlyMap<TagField, Type>): TypeIR {
978
+ if (node.kind !== 'scalar' && node.kind !== 'array') return node;
979
+
980
+ // The node may already carry constraints its *structure* implied — a template
981
+ // literal type derives a `pattern`. Tags win per keyword, so an explicit
982
+ // `Pattern<…>` overrides the derived one, but a `MinLength<3>` beside a template
983
+ // literal type does not silently erase it.
984
+ const constraints = { ...node.constraints, ...this.#constraintsFromTags(tags) };
985
+
986
+ // `Sql<'integer'>` on a `number` narrows the scalar: the emitter's integrality
987
+ // check comes from the SQL type, never from a `Min<1>` that happens to be there.
988
+ let scalar = node.kind === 'scalar' ? node.scalar : undefined;
989
+ const sql = this.#sqlOf(tags);
990
+ if (scalar === 'number' && (sql === 'integer' || sql === 'serial')) scalar = 'integer';
991
+
992
+ // `Length<N>` is `varchar(N)`; it is also a maximum, and the explicit
993
+ // `MaxLength<N>` wins when both are present.
994
+ const length = numberOf(this.#nonNullable(tags.get('length')));
995
+ if (length !== undefined && constraints.maxLength === undefined) constraints.maxLength = length;
996
+
997
+ if (node.kind === 'array') {
998
+ return Object.keys(constraints).length === 0 ? node : { ...node, constraints };
999
+ }
1000
+ const proto = this.#protoScalarOf(tags);
1001
+ return {
1002
+ ...node,
1003
+ ...(scalar === undefined ? {} : { scalar }),
1004
+ ...(proto === undefined ? {} : { proto }),
1005
+ ...(Object.keys(constraints).length === 0 ? {} : { constraints }),
1006
+ };
1007
+ }
1008
+
1009
+ #constraintsFromTags(tags: ReadonlyMap<TagField, Type>): MutableConstraints {
1010
+ const out: MutableConstraints = {};
1011
+ for (const kind of KNOWN_CONSTRAINT_KINDS) {
1012
+ const value = this.#nonNullable(tags.get(kind));
1013
+ if (!value) continue;
1014
+ if (kind === 'pattern') {
1015
+ const pattern = literalOf(value);
1016
+ if (typeof pattern === 'string') out.pattern = pattern;
1017
+ else this.#refuse(kind, 'Pattern<S> needs a string literal argument', this.#print(value));
1018
+ continue;
1019
+ }
1020
+ const bound = numberOf(value);
1021
+ if (bound !== undefined) out[kind] = bound;
1022
+ else this.#refuse(kind, `${kind} needs a number literal argument`, this.#print(value));
1023
+ }
1024
+ return out;
1025
+ }
1026
+
1027
+ #sqlOf(tags: ReadonlyMap<TagField, Type>): SqlType | undefined {
1028
+ const declared = literalOf(this.#nonNullable(tags.get('sql')));
1029
+ if (typeof declared !== 'string') return undefined;
1030
+ if (!isSqlType(declared)) {
1031
+ this.#refuse('sql', `\`${declared}\` is not a SQL type; expected one of ${SQL_TYPES.join(', ')}`);
1032
+ return undefined;
1033
+ }
1034
+ return declared;
1035
+ }
1036
+
1037
+ #extensionOf(property: string, tags: ReadonlyMap<TagField, Type>): ExtensionType | undefined {
1038
+ const spec = this.#nonNullable(tags.get('extension'));
1039
+ if (spec === undefined) return undefined;
1040
+ if (!this.#checker.isTupleType(spec)) {
1041
+ this.#refuse(property, 'Ext<E, N, A> needs an extension name, type name and argument tuple', this.#print(spec));
1042
+ return undefined;
1043
+ }
1044
+
1045
+ const [extensionType, nameType, argsType] = this.#typeArguments(spec);
1046
+ const extension = literalOf(extensionType);
1047
+ const name = literalOf(nameType);
1048
+ if (typeof extension !== 'string' || typeof name !== 'string' || argsType === undefined) {
1049
+ this.#refuse(
1050
+ property,
1051
+ 'Ext<E, N, A> needs literal extension and type names plus an argument tuple',
1052
+ this.#print(spec),
1053
+ );
1054
+ return undefined;
1055
+ }
1056
+ if (!SQL_IDENTIFIER.test(name)) {
1057
+ this.#refuse(property, `extension type name \`${name}\` is not a SQL identifier`, name);
1058
+ return undefined;
1059
+ }
1060
+ if (!this.#checker.isTupleType(argsType)) {
1061
+ this.#refuse(
1062
+ property,
1063
+ 'Ext<E, N, A> arguments must be a tuple of string or number literals',
1064
+ this.#print(argsType),
1065
+ );
1066
+ return undefined;
1067
+ }
1068
+
1069
+ const args: (string | number)[] = [];
1070
+ for (const argumentType of this.#typeArguments(argsType)) {
1071
+ const argument = literalOf(argumentType);
1072
+ if (typeof argument === 'number' && Number.isFinite(argument)) {
1073
+ args.push(argument);
1074
+ continue;
1075
+ }
1076
+ if (typeof argument === 'string' && SQL_IDENTIFIER.test(argument)) {
1077
+ args.push(argument);
1078
+ continue;
1079
+ }
1080
+ this.#refuse(
1081
+ property,
1082
+ 'extension type arguments must be finite number literals or SQL identifiers',
1083
+ this.#print(argumentType),
1084
+ );
1085
+ return undefined;
1086
+ }
1087
+
1088
+ return { extension, name, ...(args.length === 0 ? {} : { args }) };
1089
+ }
1090
+
1091
+ #protoScalarOf(tags: ReadonlyMap<TagField, Type>): ProtoScalar | undefined {
1092
+ const tagged = this.#nonNullable(tags.get('protoScalar'));
1093
+ const declared = literalOf(tagged);
1094
+ if (declared === undefined) return undefined;
1095
+ if (typeof declared === 'string' && isProtoScalar(declared)) return declared;
1096
+ this.#refuse(
1097
+ 'protoScalar',
1098
+ `Proto<K> needs one protobuf scalar literal; expected one of ${PROTO_SCALARS.join(', ')}`,
1099
+ tagged === undefined ? undefined : this.#print(tagged),
1100
+ );
1101
+ return undefined;
1102
+ }
1103
+
1104
+ // -------------------------------------------------------------------------
1105
+ // Protobuf field numbering
1106
+ // -------------------------------------------------------------------------
1107
+
1108
+ #validateProtoNumbers(node: TypeIR, path: string, seen: Set<ObjectIR>): void {
1109
+ switch (node.kind) {
1110
+ case 'object': {
1111
+ if (seen.has(node)) return;
1112
+ seen.add(node);
1113
+ const message = node.name ?? path;
1114
+ const numbered = new Map<number, PropertyIR[]>();
1115
+
1116
+ for (const property of node.properties) {
1117
+ const propertyPath = `${message}.${property.name}`;
1118
+ const number = property.protoField;
1119
+ if (number === undefined) {
1120
+ this.#refuse(
1121
+ propertyPath,
1122
+ `protobuf message \`${message}\` property \`${property.name}\` has no ProtoField<N> field number`,
1123
+ );
1124
+ } else if (!Number.isInteger(number) || number < 1 || number > 536_870_911) {
1125
+ this.#refuse(
1126
+ propertyPath,
1127
+ `protobuf field number ${number} on \`${message}.${property.name}\` is outside the valid range 1 … 536870911`,
1128
+ );
1129
+ } else if (number >= 19_000 && number <= 19_999) {
1130
+ this.#refuse(
1131
+ propertyPath,
1132
+ `protobuf field number ${number} on \`${message}.${property.name}\` is in the reserved range 19000 … 19999`,
1133
+ );
1134
+ } else {
1135
+ const group = numbered.get(number);
1136
+ if (group) group.push(property);
1137
+ else numbered.set(number, [property]);
1138
+ }
1139
+ this.#validateProtoNumbers(property.type, propertyPath, seen);
1140
+ }
1141
+
1142
+ for (const [number, properties] of numbered) {
1143
+ if (properties.length < 2) continue;
1144
+ const names = properties.map(property => `\`${property.name}\``).join(', ');
1145
+ for (const property of properties) {
1146
+ this.#refuse(
1147
+ `${message}.${property.name}`,
1148
+ `protobuf field number ${number} is duplicated by properties ${names} in message \`${message}\``,
1149
+ );
1150
+ }
1151
+ }
1152
+ return;
1153
+ }
1154
+ case 'array':
1155
+ this.#validateProtoNumbers(node.element, `${path}[]`, seen);
1156
+ return;
1157
+ case 'tuple':
1158
+ for (const [index, element] of node.elements.entries()) {
1159
+ this.#validateProtoNumbers(element, `${path}[${index}]`, seen);
1160
+ }
1161
+ return;
1162
+ case 'union':
1163
+ for (const member of node.members) this.#validateProtoNumbers(member, path, seen);
1164
+ return;
1165
+ default:
1166
+ return;
1167
+ }
1168
+ }
1169
+
1170
+ // -------------------------------------------------------------------------
1171
+ // Columns and relations
1172
+ // -------------------------------------------------------------------------
1173
+
1174
+ #relationOf(property: string, tags: ReadonlyMap<TagField, Type>): RelationIR | undefined {
1175
+ const spec = this.#nonNullable(tags.get('relation'));
1176
+ if (!spec) return undefined;
1177
+
1178
+ const read = (name: string): unknown => {
1179
+ const symbol = this.#checker.getPropertyOfType(spec, name);
1180
+ if (!symbol) return undefined;
1181
+ const type = this.#typeOf(symbol);
1182
+ return type ? literalOf(type) : undefined;
1183
+ };
1184
+ const kind = read('kind');
1185
+ const target = read('target');
1186
+ // `manyToMany` carries a join table, the other three carry a foreign key. One IR
1187
+ // field (`via`) covers both, because every back-end wants "the thing that joins".
1188
+ const via = read('fk') ?? read('through');
1189
+
1190
+ if (typeof kind !== 'string' || typeof target !== 'string' || typeof via !== 'string') {
1191
+ this.#refuse(property, 'a relation tag needs literal target and foreign-key arguments', this.#print(spec));
1192
+ return undefined;
1193
+ }
1194
+ // Each of the four tags fixes `kind` to a literal, so reaching this refusal means a
1195
+ // hand-written `[zmdbRelation]` payload. Checked anyway, for the reason `#sqlOf` checks:
1196
+ // an unrecognised cardinality would otherwise reach the SQL back-ends as one, and be
1197
+ // read there as whichever branch fell through.
1198
+ if (!isRelationKind(kind)) {
1199
+ this.#refuse(property, `\`${kind}\` is not a relation kind; expected one of ${RELATION_KINDS.join(', ')}`);
1200
+ return undefined;
1201
+ }
1202
+ return { name: property, relation: kind, target, via };
1203
+ }
1204
+
1205
+ #foreignKeysOf(
1206
+ table: string,
1207
+ tags: ReadonlyMap<TagField, Type>,
1208
+ columns: readonly ColumnIR[],
1209
+ ): readonly ForeignKeyIR[] {
1210
+ const spec = this.#nonNullable(tags.get('foreignKeys'));
1211
+ if (!spec) return [];
1212
+
1213
+ const read = (name: string): unknown => {
1214
+ const symbol = this.#checker.getPropertyOfType(spec, name);
1215
+ if (!symbol) return undefined;
1216
+ const type = this.#typeOf(symbol);
1217
+ return type ? literalOf(type) : undefined;
1218
+ };
1219
+ const local = read('columns');
1220
+ const targetTable = read('targetTable');
1221
+ const target = read('targetColumns');
1222
+ if (typeof local !== 'string' || typeof targetTable !== 'string' || typeof target !== 'string') {
1223
+ this.#refuse(
1224
+ table,
1225
+ 'ForeignKey<LocalColumns, TargetTable, TargetColumns> needs three string-literal arguments',
1226
+ this.#print(spec),
1227
+ );
1228
+ return [];
1229
+ }
1230
+
1231
+ const split = (value: string): readonly string[] => value.split(',').map(column => column.trim());
1232
+ const localColumns = split(local);
1233
+ const targetColumns = split(target);
1234
+ if (
1235
+ localColumns.length !== targetColumns.length ||
1236
+ localColumns.some(column => column.length === 0) ||
1237
+ targetColumns.some(column => column.length === 0)
1238
+ ) {
1239
+ this.#refuse(
1240
+ table,
1241
+ `ForeignKey declares ${localColumns.length} local ${localColumns.length === 1 ? 'column' : 'columns'} ` +
1242
+ `and ${targetColumns.length} target ${targetColumns.length === 1 ? 'column' : 'columns'}; ` +
1243
+ 'the lists must be positionally paired and have equal lengths',
1244
+ );
1245
+ return [];
1246
+ }
1247
+
1248
+ const declared = new Set(columns.map(column => column.name));
1249
+ const missing = localColumns.filter(column => !declared.has(column));
1250
+ if (missing.length > 0) {
1251
+ this.#refuse(
1252
+ table,
1253
+ `ForeignKey names ${missing.map(column => `\`${column}\``).join(', ')}, ` +
1254
+ `${missing.length === 1 ? 'which is not a column' : 'which are not columns'} on \`${table}\``,
1255
+ );
1256
+ return [];
1257
+ }
1258
+
1259
+ return [{ columns: localColumns, targetTable, targetColumns }];
1260
+ }
1261
+
1262
+ #referentialAction(
1263
+ property: string,
1264
+ tag: 'OnDelete' | 'OnUpdate',
1265
+ declared: Type | undefined,
1266
+ ): ReferentialAction | undefined {
1267
+ const spec = this.#nonNullable(declared);
1268
+ if (!spec) return undefined;
1269
+ const value = literalOf(spec);
1270
+ if (typeof value === 'string' && isReferentialAction(value)) return value;
1271
+ this.#refuse(
1272
+ property,
1273
+ `${tag}<Action> needs one of ${[...REFERENTIAL_ACTION_SET].map(action => `'${action}'`).join(', ')}`,
1274
+ this.#print(spec),
1275
+ );
1276
+ return undefined;
1277
+ }
1278
+
1279
+ #column(property: string, split: NullableSplit, tags: ReadonlyMap<TagField, Type>, declaredTable?: string): ColumnIR {
1280
+ const { nullable, rest } = split;
1281
+ const explicitPhysicalName = this.#physicalNameFrom(rest);
1282
+ const physicalName =
1283
+ explicitPhysicalName ??
1284
+ (declaredTable === undefined || this.#naming.column === undefined
1285
+ ? property
1286
+ : this.#naming.column(property, { table: declaredTable }));
1287
+
1288
+ // `('admin' | 'viewer') & Sql<'jsonEnum'>` does not stay written that way: an
1289
+ // intersection containing a union normalises to a union of intersections, so each
1290
+ // member arrives with the tag attached. Stripping the tag parts is what makes the
1291
+ // literal union visible again.
1292
+ const data = rest.map(member => this.#dataPart(member));
1293
+
1294
+ // A column whose data part is nothing but tags. Worth its own message because the
1295
+ // way a reader gets here is not by writing `Sql<'json'>` on its own — it is by
1296
+ // writing `unknown & Sql<'json'>` and not knowing that `unknown & X` *is* `X`, so
1297
+ // the type they think they declared is gone before the reflection ever sees it.
1298
+ const [first] = data;
1299
+ if (first !== undefined && data.every(member => this.#isTagOnly(member))) {
1300
+ this.#refuse(
1301
+ property,
1302
+ "the tags carry no type: `unknown & X` collapses to `X` — an unshaped JSON payload is `object & Sql<'json'>`",
1303
+ this.#print(first),
1304
+ );
1305
+ }
1306
+
1307
+ const enumValues = literalUnion(data);
1308
+ const declaredSql = this.#sqlOf(tags);
1309
+ const extension = this.#extensionOf(property, tags);
1310
+ if (declaredSql !== undefined && extension !== undefined) {
1311
+ this.#refuse(property, 'a column cannot carry both Sql<…> and Ext<…>; choose one database type');
1312
+ }
1313
+ const constraints = this.#constraintsFromTags(tags);
1314
+ const length = numberOf(this.#nonNullable(tags.get('length')));
1315
+ const precision = this.#precisionOf(tags);
1316
+ const references = literalOf(this.#nonNullable(tags.get('references')));
1317
+ const onDelete = this.#referentialAction(property, 'OnDelete', tags.get('onDelete'));
1318
+ const onUpdate = this.#referentialAction(property, 'OnUpdate', tags.get('onUpdate'));
1319
+ const codec = literalOf(this.#nonNullable(tags.get('codec')));
1320
+ const wire = this.#nonNullable(tags.get('wire'));
1321
+
1322
+ // `Serial` implies a database default. Not an inference for convenience: a generated
1323
+ // column *does* have one, and `hasDefault` is what keeps it out of `CreateDTO` — so a
1324
+ // `Serial` that only set `serial` would demand the key the database is about to make.
1325
+ const serial = tags.has('serial');
1326
+ const hasDefault = serial || tags.has('hasDefault');
1327
+ if (serial && extension !== undefined) {
1328
+ this.#refuse(property, 'Serial cannot be combined with an extension-backed column type');
1329
+ }
1330
+ for (const [tag, action] of [
1331
+ ['OnDelete', onDelete],
1332
+ ['OnUpdate', onUpdate],
1333
+ ] as const) {
1334
+ if (action === 'set null' && !nullable) {
1335
+ this.#refuse(
1336
+ property,
1337
+ `${tag}<'set null'> on a NOT NULL column; a referential action would have to write NULL into ` +
1338
+ "a column that forbids it — make the column nullable, or use 'cascade' or 'restrict'",
1339
+ );
1340
+ }
1341
+ if (action === 'set default' && !hasDefault) {
1342
+ this.#refuse(
1343
+ property,
1344
+ `${tag}<'set default'> on a column with no default; add HasDefault or choose an action ` +
1345
+ 'that does not write a missing default',
1346
+ );
1347
+ }
1348
+ }
1349
+ if ((onDelete !== undefined || onUpdate !== undefined) && typeof references !== 'string') {
1350
+ this.#refuse(property, 'OnDelete and OnUpdate require References<…> on the same foreign-key column');
1351
+ }
1352
+
1353
+ // A generated `integer` is what `serial` means, and the declaration says it in two tags
1354
+ // rather than one because the old `Sql<'serial'>` made a serial key's value unassignable
1355
+ // to an `integer` foreign key — see `ColumnSqlType` in `@zmdb/schema/tags`. The IR
1356
+ // keeps the one-word spelling, because that is the word two of the three dialects want
1357
+ // in the DDL and every renderer reads it.
1358
+ const coreSql = declaredSql ?? (extension === undefined ? this.#inferSql(property, data, enumValues) : undefined);
1359
+ const sql: ColumnIR['sql'] =
1360
+ extension ??
1361
+ (serial && coreSql === 'integer' ? 'serial' : (coreSql ?? this.#inferSql(property, data, enumValues)));
1362
+
1363
+ const payload = this.#declaredApp(property, data, sql, typeof codec === 'string');
1364
+
1365
+ return {
1366
+ name: property,
1367
+ physicalName,
1368
+ sql,
1369
+ nullable,
1370
+ primaryKey: tags.has('primaryKey'),
1371
+ serial,
1372
+ unique: tags.has('unique'),
1373
+ hasDefault,
1374
+ sensitive: tags.has('sensitive'),
1375
+ ...(length === undefined ? {} : { length }),
1376
+ ...(precision === undefined ? {} : { precision }),
1377
+ ...(sql === 'jsonEnum' && enumValues !== undefined ? { enum: enumValues } : {}),
1378
+ ...(typeof references === 'string' ? { references } : {}),
1379
+ ...(onDelete === undefined ? {} : { onDelete }),
1380
+ ...(onUpdate === undefined ? {} : { onUpdate }),
1381
+ ...(typeof codec === 'string' ? { codec } : {}),
1382
+ // `WireAs<W>` is the one tag whose payload is a type rather than a literal, so it
1383
+ // is reflected like data instead of read with `literalOf`. Only the declaration
1384
+ // can say what a codec puts on the wire — see `wireTypeOf`, which refuses a codec
1385
+ // column without it rather than assuming the app type crosses unchanged.
1386
+ ...(wire === undefined ? {} : { wire: this.#type(wire, property, 1) }),
1387
+ constraints,
1388
+ rules: this.#rulesOf(tags),
1389
+ ...(payload === undefined ? {} : { payload }),
1390
+ };
1391
+ }
1392
+
1393
+ /**
1394
+ * The app type, where only a tagged declaration can say it.
1395
+ *
1396
+ * Two cases, one IR field. A `json` column's payload shape: `ColumnMeta` records
1397
+ * `sql: 'json'` and has nowhere to put the shape, so a consumer reading the column map
1398
+ * gets "an object, unspecified". And the type behind a codec: `Money & Sql<'integer'> &
1399
+ * Codec<'Money'>` is an integer in the database and a `Money` in the app, and a
1400
+ * validator that checked `integer` would reject every valid value. Both are facts only
1401
+ * the declaration has, and both are why `CoreSchema` carries its IR.
1402
+ *
1403
+ * A codec over a *scalar* is left alone deliberately. `string & Sql<'text'> &
1404
+ * Length<80> & Codec<'currency'>` is a string on both sides, and recording the bare
1405
+ * data part would drop the constraints the tags carry, which `appTypeOf` reads off the
1406
+ * column instead. So the field is set only where the app type is a shape the SQL type
1407
+ * cannot describe at all.
1408
+ *
1409
+ * `object & Sql<'json'>` is the declared spelling of a payload-free `json()`, so it
1410
+ * leaves the field unset like `unknown` does. That is not a shortcut: `object` means
1411
+ * "not a primitive", which is precisely the check an unshaped `json` column emits, so
1412
+ * the type and the validator say the same thing rather than one of them saying more.
1413
+ */
1414
+ #declaredApp(
1415
+ property: string,
1416
+ data: readonly Type[],
1417
+ sql: SqlType | ExtensionType,
1418
+ codec: boolean,
1419
+ ): TypeIR | undefined {
1420
+ if (typeof sql !== 'string') {
1421
+ const [only] = data;
1422
+ if (data.length !== 1 || only === undefined || isUnknown(only) || isNonPrimitive(only)) return undefined;
1423
+ return this.#type(only, property, 1);
1424
+ }
1425
+ if (!codec && sql !== 'json') return undefined;
1426
+ const [only] = data;
1427
+ if (data.length !== 1 || only === undefined || isUnknown(only) || isNonPrimitive(only)) return undefined;
1428
+ const node = this.#type(only, property, 1);
1429
+ if (sql === 'json') return node;
1430
+ return node.kind === 'scalar' || node.kind === 'literal' ? undefined : node;
1431
+ }
1432
+
1433
+ /**
1434
+ * `Sql<T>` is required only where TypeScript is genuinely ambiguous. That is `number`,
1435
+ * which is both `integer` and `numeric`, and `string`, which is both `text` and
1436
+ * `varchar` — and for `string` there is a defensible default, so only `number` is
1437
+ * refused outright. Everywhere else the type says it, and asking for a second spelling
1438
+ * would be asking for two sources of truth (REQ-TF-2).
1439
+ */
1440
+ #inferSql(property: string, members: readonly Type[], enumValues: readonly string[] | undefined): SqlType {
1441
+ if (enumValues !== undefined) return 'jsonEnum';
1442
+ const only = members.length === 1 ? members[0] : undefined;
1443
+ if (!only) return 'json';
1444
+ if (only.isIntrinsicType()) {
1445
+ switch (only.intrinsicName) {
1446
+ case 'string':
1447
+ return 'text';
1448
+ case 'boolean':
1449
+ return 'boolean';
1450
+ case 'bigint':
1451
+ return 'bigint';
1452
+ case 'number':
1453
+ this.#refuse(
1454
+ property,
1455
+ "a `number` column needs Sql<'integer'> or Sql<'numeric'> — TypeScript spells both `number`",
1456
+ );
1457
+ return 'numeric';
1458
+ default:
1459
+ break;
1460
+ }
1461
+ }
1462
+ if (only.isBooleanLiteralType()) return 'boolean';
1463
+ if (only.getSymbol()?.name === 'Date') return 'timestamp';
1464
+ return 'json';
1465
+ }
1466
+
1467
+ #precisionOf(tags: ReadonlyMap<TagField, Type>): readonly [number, number] | undefined {
1468
+ const spec = this.#nonNullable(tags.get('precision'));
1469
+ if (!spec) return undefined;
1470
+ const parts = this.#checker.isTupleType(spec) ? this.#typeArguments(spec) : [];
1471
+ const [p, s] = parts.map(part => numberOf(part));
1472
+ if (p === undefined || s === undefined) {
1473
+ this.#refuse('precision', 'Numeric<P, S> needs two number literals', this.#print(spec));
1474
+ return undefined;
1475
+ }
1476
+ return [p, s];
1477
+ }
1478
+
1479
+ #tableColumnList(label: 'ShardKey' | 'SortKey', tagged: Type | undefined): readonly string[] | undefined {
1480
+ const spec = this.#nonNullable(tagged);
1481
+ if (!spec) return undefined;
1482
+ const parts = this.#checker.isTupleType(spec) ? this.#typeArguments(spec) : [];
1483
+ const columns = parts.map(part => literalOf(part));
1484
+ if (columns.length === 0 || columns.some(column => typeof column !== 'string')) {
1485
+ this.#refuse(label, `${label}<Columns> needs a non-empty tuple of string literals`, this.#print(spec));
1486
+ return undefined;
1487
+ }
1488
+ const names = columns.filter((column): column is string => typeof column === 'string');
1489
+ if (new Set(names).size !== names.length) {
1490
+ this.#refuse(label, `${label}<Columns> names each column once`, this.#print(spec));
1491
+ return undefined;
1492
+ }
1493
+ return names;
1494
+ }
1495
+
1496
+ /**
1497
+ * `Rule<'luhn'>`, or `Rule<'luhn' | 'checksum'>` for more than one. A second
1498
+ * `Rule<>` in the same intersection would reuse the same symbol slot and intersect
1499
+ * the arguments to `never`, so the union is the spelling — hence a union is read
1500
+ * here rather than refused.
1501
+ */
1502
+ #rulesOf(tags: ReadonlyMap<TagField, Type>): readonly string[] {
1503
+ const spec = this.#nonNullable(tags.get('rules'));
1504
+ if (!spec) return [];
1505
+ const members = spec.isUnionType() ? spec.getTypes() : [spec];
1506
+ const names = literalUnion(members);
1507
+ if (names === undefined) {
1508
+ this.#refuse(
1509
+ 'rules',
1510
+ "Rule<Name> needs a string literal, or a union of them: Rule<'a' | 'b'>",
1511
+ this.#print(spec),
1512
+ );
1513
+ return [];
1514
+ }
1515
+ return names;
1516
+ }
1517
+
1518
+ // -------------------------------------------------------------------------
1519
+ // Checker helpers
1520
+ // -------------------------------------------------------------------------
1521
+
1522
+ #typeOf(symbol: TsSymbol): Type | undefined {
1523
+ return this.#checker.getTypeOfSymbolAtLocation(symbol, this.#location);
1524
+ }
1525
+
1526
+ #grpcMember(type: Type, method: string, member: 'request' | 'response'): Type | undefined {
1527
+ const symbol = this.#checker.getPropertyOfType(type, member);
1528
+ if (symbol === undefined) {
1529
+ this.#refuse(`${method}.${member}`, `a gRPC method must declare its ${member} type`);
1530
+ return undefined;
1531
+ }
1532
+ const value = this.#typeOf(symbol);
1533
+ if (value === undefined) {
1534
+ this.#refuse(`${method}.${member}`, `the checker did not resolve this gRPC ${member} type`);
1535
+ }
1536
+ return value;
1537
+ }
1538
+
1539
+ #grpcStreamFlag(type: Type, method: string, member: 'requestStream' | 'responseStream'): boolean {
1540
+ const symbol = this.#checker.getPropertyOfType(type, member);
1541
+ if (symbol === undefined) return false;
1542
+ const value = this.#typeOf(symbol);
1543
+ if (value === undefined || literalOf(this.#nonNullable(value)) !== true) {
1544
+ this.#refuse(`${method}.${member}`, `a gRPC stream flag must be the literal type \`true\` when present`);
1545
+ }
1546
+ return true;
1547
+ }
1548
+
1549
+ /**
1550
+ * The type arguments of an array, tuple or other generic reference.
1551
+ *
1552
+ * boundary: `getTypeArguments` takes a `TypeReference`, but `isArrayType` and
1553
+ * `isTupleType` answer with a plain `boolean` rather than a predicate, so the check that
1554
+ * makes the call sound cannot narrow the argument. Every caller runs one of those two
1555
+ * first; the cast lives here, once, instead of at each of them. A reference this is
1556
+ * called on wrongly answers with an empty list, and each caller already handles that —
1557
+ * an array with no element type is a refusal, and a tuple with none is an empty tuple.
1558
+ */
1559
+ #typeArguments(type: Type): readonly Type[] {
1560
+ return this.#checker.getTypeArguments(type as TypeReference);
1561
+ }
1562
+
1563
+ /** Strips `| undefined` off an optional tag slot's type. */
1564
+ #nonNullable(type: Type | undefined): Type | undefined {
1565
+ if (!type) return undefined;
1566
+ return this.#checker.getNonNullableType(type) ?? type;
1567
+ }
1568
+
1569
+ #hasIndexSignature(type: Type): boolean {
1570
+ try {
1571
+ return this.#checker.getIndexInfosOfType(type).length > 0;
1572
+ } catch {
1573
+ // The client throws while marshalling an `IndexInfo` whose `keyType` it cannot
1574
+ // resolve. It only gets that far when there IS one, so a throw is a positive
1575
+ // answer, not an error to swallow.
1576
+ return true;
1577
+ }
1578
+ }
1579
+
1580
+ #print(type: Type): string {
1581
+ return this.#checker.typeToString(type);
1582
+ }
1583
+
1584
+ #refuse(path: string, reason: string, source?: string): void {
1585
+ this.diagnostics.push({ path, reason, ...(source === undefined ? {} : { source }) });
1586
+ }
1587
+
1588
+ #unsupported(path: string, reason: string, source?: string): TypeIR {
1589
+ this.#refuse(path, reason, source);
1590
+ return { kind: 'unsupported', reason, ...(source === undefined ? {} : { source }) };
1591
+ }
1592
+
1593
+ #claimName(preferred: string, id: number): string {
1594
+ const owner = this.#names.get(preferred);
1595
+ if (owner === undefined) {
1596
+ this.#names.set(preferred, id);
1597
+ return preferred;
1598
+ }
1599
+ if (owner === id) return preferred;
1600
+ // Two declarations, one name. Suffixing keeps the emitted helpers distinct; the
1601
+ // alternative is one helper silently checking the other's shape.
1602
+ let n = 2;
1603
+ while (this.#names.has(`${preferred}_${n}`) && this.#names.get(`${preferred}_${n}`) !== id) n++;
1604
+ this.#names.set(`${preferred}_${n}`, id);
1605
+ return `${preferred}_${n}`;
1606
+ }
1607
+ }
1608
+
1609
+ // ---------------------------------------------------------------------------
1610
+ // Free functions
1611
+ // ---------------------------------------------------------------------------
1612
+
1613
+ export interface ReflectResult<T> {
1614
+ readonly ir: T;
1615
+ readonly diagnostics: readonly ReflectDiagnostic[];
1616
+ }
1617
+
1618
+ /** One type, one IR, plus whatever the reflection had to refuse along the way. */
1619
+ export function irFromType(
1620
+ checker: Checker,
1621
+ type: Type,
1622
+ location: Node,
1623
+ options?: ReflectOptions,
1624
+ ): ReflectResult<TypeIR> {
1625
+ const reflector = new Reflector(checker, location, options);
1626
+ return { ir: reflector.typeIR(type), diagnostics: reflector.diagnostics };
1627
+ }
1628
+
1629
+ /** A tagged entity type, read to the `SchemaIR` every back-end takes. */
1630
+ export function schemaIrFromType(
1631
+ checker: Checker,
1632
+ type: Type,
1633
+ location: Node,
1634
+ options?: ReflectOptions,
1635
+ ): ReflectResult<SchemaIR> {
1636
+ const reflector = new Reflector(checker, location, options);
1637
+ return { ir: reflector.schemaIR(type), diagnostics: reflector.diagnostics };
1638
+ }
1639
+
1640
+ // ---------------------------------------------------------------------------
1641
+ // Small pure helpers
1642
+ // ---------------------------------------------------------------------------
1643
+
1644
+ function isUnknown(type: Type): boolean {
1645
+ return type.isIntrinsicType() && (type.intrinsicName === 'unknown' || type.intrinsicName === 'any');
1646
+ }
1647
+
1648
+ /** The `object` keyword: any non-primitive, which is any JSON object or array. */
1649
+ function isNonPrimitive(type: Type): boolean {
1650
+ return type.isIntrinsicType() && type.intrinsicName === 'object';
1651
+ }
1652
+
1653
+ function literalOf(type: Type | undefined): string | number | boolean | undefined {
1654
+ if (!type) return undefined;
1655
+ if (type.isStringLiteralType() || type.isNumberLiteralType() || type.isBooleanLiteralType()) return type.value;
1656
+ // `Fts<true>` and a bare `PrimaryKey` both carry the `true` type, which the checker
1657
+ // may report as the `boolean` union rather than a literal depending on how it was
1658
+ // written; treat that as `true` because `false` is not a spelling any tag admits.
1659
+ if (type.isIntrinsicType() && type.intrinsicName === 'true') return true;
1660
+ return undefined;
1661
+ }
1662
+
1663
+ function numberOf(type: Type | undefined): number | undefined {
1664
+ const value = literalOf(type);
1665
+ return typeof value === 'number' ? value : undefined;
1666
+ }
1667
+
1668
+ /**
1669
+ * `'a' | 'b'` → `['a', 'b']`, sorted; anything else → `undefined`.
1670
+ *
1671
+ * Sorted because the order we are handed is not the order the author wrote. The checker
1672
+ * normalises union members, so `'free' | 'pro' | 'enterprise'` arrives as `enterprise`,
1673
+ * `free`, `pro`. Sorting is what makes the answer stable across an edit that only reorders
1674
+ * the union, which is not a change to the table. See `ColumnIR.enum`.
1675
+ */
1676
+ function literalUnion(members: readonly Type[]): readonly string[] | undefined {
1677
+ if (members.length === 0) return undefined;
1678
+ const values: string[] = [];
1679
+ for (const member of members) {
1680
+ if (!member.isStringLiteralType()) return undefined;
1681
+ values.push(member.value);
1682
+ }
1683
+ return values.toSorted();
1684
+ }
1685
+
1686
+ function escapeRegExp(text: string): string {
1687
+ return text.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
1688
+ }
1689
+
1690
+ function pascalIdentifier(text: string): string {
1691
+ const words = text.split(/[^A-Za-z0-9]+/).filter(word => word.length > 0);
1692
+ if (words.length === 0) return 'Method';
1693
+ return words.map(word => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`).join('');
1694
+ }
1695
+
1696
+ /**
1697
+ * The regex source for one `${…}` span of a template literal type, or `undefined` when
1698
+ * there is no *exact* equivalent.
1699
+ *
1700
+ * `${number}` is deliberately not derivable. TypeScript's own rule for what text is
1701
+ * assignable to it covers exponents, `Infinity` and leading signs, so any regex short
1702
+ * enough to write here is either stricter than the type — rejecting values the type
1703
+ * accepts — or looser. Refusing and asking for an explicit `Pattern<…>` lets the author
1704
+ * pick the numeric grammar they actually mean.
1705
+ *
1706
+ * There is no union case because the checker never leaves one here: `` `v${1 | 2}` ``
1707
+ * is normalised to two template literal types before we see it.
1708
+ */
1709
+ function placeholderPattern(span: Type): string | undefined {
1710
+ if (span.isStringLiteralType() || span.isNumberLiteralType()) return escapeRegExp(String(span.value));
1711
+ if (span.isIntrinsicType() && span.intrinsicName === 'string') return String.raw`[\s\S]*`;
1712
+ return undefined;
1713
+ }
1714
+
1715
+ /**
1716
+ * The declared name of a type, or `undefined` for an anonymous one. `__type` and
1717
+ * `__object` are the checker's placeholders for a type literal, not names.
1718
+ */
1719
+ function typeName(type: Type): string | undefined {
1720
+ const name = type.getAliasSymbol()?.name ?? type.getSymbol()?.name;
1721
+ if (name === undefined || name.startsWith('__')) return undefined;
1722
+ return name;
1723
+ }