@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.
- package/LICENSE +674 -0
- package/README.md +40 -0
- package/dist/codegen/index.d.ts +50 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +364 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/codegen/scan.d.ts +105 -0
- package/dist/codegen/scan.d.ts.map +1 -0
- package/dist/codegen/scan.js +450 -0
- package/dist/codegen/scan.js.map +1 -0
- package/dist/codegen/witness.d.ts +76 -0
- package/dist/codegen/witness.d.ts.map +1 -0
- package/dist/codegen/witness.js +690 -0
- package/dist/codegen/witness.js.map +1 -0
- package/dist/config/contract.d.ts +41 -0
- package/dist/config/contract.d.ts.map +1 -0
- package/dist/config/contract.js +5 -0
- package/dist/config/contract.js.map +1 -0
- package/dist/config/index.d.ts +53 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +277 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/index.zmdb.generated.d.ts +12 -0
- package/dist/config/index.zmdb.generated.js +28 -0
- package/dist/config/index.zmdb.witness.d.ts +3 -0
- package/dist/config/index.zmdb.witness.d.ts.map +1 -0
- package/dist/config/index.zmdb.witness.js +20 -0
- package/dist/config/index.zmdb.witness.js.map +1 -0
- package/dist/configured-plugin.d.ts +11 -0
- package/dist/configured-plugin.d.ts.map +1 -0
- package/dist/configured-plugin.js +27 -0
- package/dist/configured-plugin.js.map +1 -0
- package/dist/emit/index.d.ts +103 -0
- package/dist/emit/index.d.ts.map +1 -0
- package/dist/emit/index.js +1356 -0
- package/dist/emit/index.js.map +1 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/dist/lint/ast.d.ts +17 -0
- package/dist/lint/ast.d.ts.map +1 -0
- package/dist/lint/ast.js +77 -0
- package/dist/lint/ast.js.map +1 -0
- package/dist/lint/host-types.d.ts +9 -0
- package/dist/lint/host-types.d.ts.map +1 -0
- package/dist/lint/host-types.js +2 -0
- package/dist/lint/host-types.js.map +1 -0
- package/dist/lint/index.d.ts +23 -0
- package/dist/lint/index.d.ts.map +1 -0
- package/dist/lint/index.js +53 -0
- package/dist/lint/index.js.map +1 -0
- package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
- package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
- package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
- package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
- package/dist/lint/rules/no-empty-patch.d.ts +3 -0
- package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
- package/dist/lint/rules/no-empty-patch.js +26 -0
- package/dist/lint/rules/no-empty-patch.js.map +1 -0
- package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
- package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
- package/dist/lint/rules/no-interpolated-sql.js +35 -0
- package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
- package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
- package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
- package/dist/lint/rules/no-unbounded-find.js +26 -0
- package/dist/lint/rules/no-unbounded-find.js.map +1 -0
- package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
- package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
- package/dist/lint/rules/no-unknown-json-column.js +35 -0
- package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
- package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
- package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
- package/dist/lint/rules/require-sql-on-number.js +34 -0
- package/dist/lint/rules/require-sql-on-number.js.map +1 -0
- package/dist/lint/types.d.ts +12 -0
- package/dist/lint/types.d.ts.map +1 -0
- package/dist/lint/types.js +2 -0
- package/dist/lint/types.js.map +1 -0
- package/dist/metro/metro.d.ts +22 -0
- package/dist/metro/metro.d.ts.map +1 -0
- package/dist/metro/metro.js +242 -0
- package/dist/metro/metro.js.map +1 -0
- package/dist/protobuf/decode.d.ts +20 -0
- package/dist/protobuf/decode.d.ts.map +1 -0
- package/dist/protobuf/decode.js +289 -0
- package/dist/protobuf/decode.js.map +1 -0
- package/dist/protobuf/descriptor.d.ts +16 -0
- package/dist/protobuf/descriptor.d.ts.map +1 -0
- package/dist/protobuf/descriptor.js +303 -0
- package/dist/protobuf/descriptor.js.map +1 -0
- package/dist/protobuf/encode.d.ts +20 -0
- package/dist/protobuf/encode.d.ts.map +1 -0
- package/dist/protobuf/encode.js +203 -0
- package/dist/protobuf/encode.js.map +1 -0
- package/dist/protobuf/grpc-ir.d.ts +16 -0
- package/dist/protobuf/grpc-ir.d.ts.map +1 -0
- package/dist/protobuf/grpc-ir.js +2 -0
- package/dist/protobuf/grpc-ir.js.map +1 -0
- package/dist/protobuf/plan.d.ts +42 -0
- package/dist/protobuf/plan.d.ts.map +1 -0
- package/dist/protobuf/plan.js +158 -0
- package/dist/protobuf/plan.js.map +1 -0
- package/dist/reflect/callsites.d.ts +40 -0
- package/dist/reflect/callsites.d.ts.map +1 -0
- package/dist/reflect/callsites.js +153 -0
- package/dist/reflect/callsites.js.map +1 -0
- package/dist/reflect/index.d.ts +106 -0
- package/dist/reflect/index.d.ts.map +1 -0
- package/dist/reflect/index.js +1421 -0
- package/dist/reflect/index.js.map +1 -0
- package/dist/reflect/session.d.ts +118 -0
- package/dist/reflect/session.d.ts.map +1 -0
- package/dist/reflect/session.js +228 -0
- package/dist/reflect/session.js.map +1 -0
- package/dist/testing/index.d.ts +69 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +210 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/transform/index.d.ts +74 -0
- package/dist/transform/index.d.ts.map +1 -0
- package/dist/transform/index.js +572 -0
- package/dist/transform/index.js.map +1 -0
- package/dist/unplugin/index.d.ts +53 -0
- package/dist/unplugin/index.d.ts.map +1 -0
- package/dist/unplugin/index.js +130 -0
- package/dist/unplugin/index.js.map +1 -0
- package/dist/unplugin/inline-bench.d.ts +29 -0
- package/dist/unplugin/inline-bench.d.ts.map +1 -0
- package/dist/unplugin/inline-bench.js +82 -0
- package/dist/unplugin/inline-bench.js.map +1 -0
- package/package.json +110 -0
- package/src/codegen/index.ts +451 -0
- package/src/codegen/scan.ts +574 -0
- package/src/codegen/witness.ts +819 -0
- package/src/config/contract.ts +48 -0
- package/src/config/index.ts +384 -0
- package/src/config/index.zmdb.generated.d.ts +12 -0
- package/src/config/index.zmdb.generated.js +28 -0
- package/src/config/index.zmdb.witness.ts +22 -0
- package/src/configured-plugin.ts +35 -0
- package/src/emit/index.ts +1496 -0
- package/src/errors.ts +6 -0
- package/src/index.ts +336 -0
- package/src/lint/ast.ts +96 -0
- package/src/lint/host-types.ts +9 -0
- package/src/lint/index.ts +80 -0
- package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
- package/src/lint/rules/no-empty-patch.ts +26 -0
- package/src/lint/rules/no-interpolated-sql.ts +45 -0
- package/src/lint/rules/no-unbounded-find.ts +26 -0
- package/src/lint/rules/no-unknown-json-column.ts +36 -0
- package/src/lint/rules/require-sql-on-number.ts +34 -0
- package/src/lint/types.ts +11 -0
- package/src/metro/metro.ts +313 -0
- package/src/protobuf/decode.ts +375 -0
- package/src/protobuf/descriptor.ts +353 -0
- package/src/protobuf/encode.ts +277 -0
- package/src/protobuf/grpc-ir.ts +17 -0
- package/src/protobuf/plan.ts +244 -0
- package/src/reflect/callsites.ts +198 -0
- package/src/reflect/index.ts +1723 -0
- package/src/reflect/session.ts +276 -0
- package/src/testing/index.ts +298 -0
- package/src/transform/index.ts +690 -0
- package/src/unplugin/index.ts +182 -0
- 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
|
+
}
|