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