@zmdb/compiler 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +40 -0
  3. package/dist/codegen/index.d.ts +50 -0
  4. package/dist/codegen/index.d.ts.map +1 -0
  5. package/dist/codegen/index.js +364 -0
  6. package/dist/codegen/index.js.map +1 -0
  7. package/dist/codegen/scan.d.ts +105 -0
  8. package/dist/codegen/scan.d.ts.map +1 -0
  9. package/dist/codegen/scan.js +450 -0
  10. package/dist/codegen/scan.js.map +1 -0
  11. package/dist/codegen/witness.d.ts +76 -0
  12. package/dist/codegen/witness.d.ts.map +1 -0
  13. package/dist/codegen/witness.js +690 -0
  14. package/dist/codegen/witness.js.map +1 -0
  15. package/dist/config/contract.d.ts +41 -0
  16. package/dist/config/contract.d.ts.map +1 -0
  17. package/dist/config/contract.js +5 -0
  18. package/dist/config/contract.js.map +1 -0
  19. package/dist/config/index.d.ts +53 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +277 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/index.zmdb.generated.d.ts +12 -0
  24. package/dist/config/index.zmdb.generated.js +28 -0
  25. package/dist/config/index.zmdb.witness.d.ts +3 -0
  26. package/dist/config/index.zmdb.witness.d.ts.map +1 -0
  27. package/dist/config/index.zmdb.witness.js +20 -0
  28. package/dist/config/index.zmdb.witness.js.map +1 -0
  29. package/dist/configured-plugin.d.ts +11 -0
  30. package/dist/configured-plugin.d.ts.map +1 -0
  31. package/dist/configured-plugin.js +27 -0
  32. package/dist/configured-plugin.js.map +1 -0
  33. package/dist/emit/index.d.ts +103 -0
  34. package/dist/emit/index.d.ts.map +1 -0
  35. package/dist/emit/index.js +1356 -0
  36. package/dist/emit/index.js.map +1 -0
  37. package/dist/errors.d.ts +7 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +2 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +46 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +254 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lint/ast.d.ts +17 -0
  46. package/dist/lint/ast.d.ts.map +1 -0
  47. package/dist/lint/ast.js +77 -0
  48. package/dist/lint/ast.js.map +1 -0
  49. package/dist/lint/host-types.d.ts +9 -0
  50. package/dist/lint/host-types.d.ts.map +1 -0
  51. package/dist/lint/host-types.js +2 -0
  52. package/dist/lint/host-types.js.map +1 -0
  53. package/dist/lint/index.d.ts +23 -0
  54. package/dist/lint/index.d.ts.map +1 -0
  55. package/dist/lint/index.js +53 -0
  56. package/dist/lint/index.js.map +1 -0
  57. package/dist/lint/rules/no-distributed-nullable-tags.d.ts +3 -0
  58. package/dist/lint/rules/no-distributed-nullable-tags.d.ts.map +1 -0
  59. package/dist/lint/rules/no-distributed-nullable-tags.js +71 -0
  60. package/dist/lint/rules/no-distributed-nullable-tags.js.map +1 -0
  61. package/dist/lint/rules/no-empty-patch.d.ts +3 -0
  62. package/dist/lint/rules/no-empty-patch.d.ts.map +1 -0
  63. package/dist/lint/rules/no-empty-patch.js +26 -0
  64. package/dist/lint/rules/no-empty-patch.js.map +1 -0
  65. package/dist/lint/rules/no-interpolated-sql.d.ts +3 -0
  66. package/dist/lint/rules/no-interpolated-sql.d.ts.map +1 -0
  67. package/dist/lint/rules/no-interpolated-sql.js +35 -0
  68. package/dist/lint/rules/no-interpolated-sql.js.map +1 -0
  69. package/dist/lint/rules/no-unbounded-find.d.ts +3 -0
  70. package/dist/lint/rules/no-unbounded-find.d.ts.map +1 -0
  71. package/dist/lint/rules/no-unbounded-find.js +26 -0
  72. package/dist/lint/rules/no-unbounded-find.js.map +1 -0
  73. package/dist/lint/rules/no-unknown-json-column.d.ts +3 -0
  74. package/dist/lint/rules/no-unknown-json-column.d.ts.map +1 -0
  75. package/dist/lint/rules/no-unknown-json-column.js +35 -0
  76. package/dist/lint/rules/no-unknown-json-column.js.map +1 -0
  77. package/dist/lint/rules/require-sql-on-number.d.ts +3 -0
  78. package/dist/lint/rules/require-sql-on-number.d.ts.map +1 -0
  79. package/dist/lint/rules/require-sql-on-number.js +34 -0
  80. package/dist/lint/rules/require-sql-on-number.js.map +1 -0
  81. package/dist/lint/types.d.ts +12 -0
  82. package/dist/lint/types.d.ts.map +1 -0
  83. package/dist/lint/types.js +2 -0
  84. package/dist/lint/types.js.map +1 -0
  85. package/dist/metro/metro.d.ts +22 -0
  86. package/dist/metro/metro.d.ts.map +1 -0
  87. package/dist/metro/metro.js +242 -0
  88. package/dist/metro/metro.js.map +1 -0
  89. package/dist/protobuf/decode.d.ts +20 -0
  90. package/dist/protobuf/decode.d.ts.map +1 -0
  91. package/dist/protobuf/decode.js +289 -0
  92. package/dist/protobuf/decode.js.map +1 -0
  93. package/dist/protobuf/descriptor.d.ts +16 -0
  94. package/dist/protobuf/descriptor.d.ts.map +1 -0
  95. package/dist/protobuf/descriptor.js +303 -0
  96. package/dist/protobuf/descriptor.js.map +1 -0
  97. package/dist/protobuf/encode.d.ts +20 -0
  98. package/dist/protobuf/encode.d.ts.map +1 -0
  99. package/dist/protobuf/encode.js +203 -0
  100. package/dist/protobuf/encode.js.map +1 -0
  101. package/dist/protobuf/grpc-ir.d.ts +16 -0
  102. package/dist/protobuf/grpc-ir.d.ts.map +1 -0
  103. package/dist/protobuf/grpc-ir.js +2 -0
  104. package/dist/protobuf/grpc-ir.js.map +1 -0
  105. package/dist/protobuf/plan.d.ts +42 -0
  106. package/dist/protobuf/plan.d.ts.map +1 -0
  107. package/dist/protobuf/plan.js +158 -0
  108. package/dist/protobuf/plan.js.map +1 -0
  109. package/dist/reflect/callsites.d.ts +40 -0
  110. package/dist/reflect/callsites.d.ts.map +1 -0
  111. package/dist/reflect/callsites.js +153 -0
  112. package/dist/reflect/callsites.js.map +1 -0
  113. package/dist/reflect/index.d.ts +106 -0
  114. package/dist/reflect/index.d.ts.map +1 -0
  115. package/dist/reflect/index.js +1421 -0
  116. package/dist/reflect/index.js.map +1 -0
  117. package/dist/reflect/session.d.ts +118 -0
  118. package/dist/reflect/session.d.ts.map +1 -0
  119. package/dist/reflect/session.js +228 -0
  120. package/dist/reflect/session.js.map +1 -0
  121. package/dist/testing/index.d.ts +69 -0
  122. package/dist/testing/index.d.ts.map +1 -0
  123. package/dist/testing/index.js +210 -0
  124. package/dist/testing/index.js.map +1 -0
  125. package/dist/transform/index.d.ts +74 -0
  126. package/dist/transform/index.d.ts.map +1 -0
  127. package/dist/transform/index.js +572 -0
  128. package/dist/transform/index.js.map +1 -0
  129. package/dist/unplugin/index.d.ts +53 -0
  130. package/dist/unplugin/index.d.ts.map +1 -0
  131. package/dist/unplugin/index.js +130 -0
  132. package/dist/unplugin/index.js.map +1 -0
  133. package/dist/unplugin/inline-bench.d.ts +29 -0
  134. package/dist/unplugin/inline-bench.d.ts.map +1 -0
  135. package/dist/unplugin/inline-bench.js +82 -0
  136. package/dist/unplugin/inline-bench.js.map +1 -0
  137. package/package.json +110 -0
  138. package/src/codegen/index.ts +451 -0
  139. package/src/codegen/scan.ts +574 -0
  140. package/src/codegen/witness.ts +819 -0
  141. package/src/config/contract.ts +48 -0
  142. package/src/config/index.ts +384 -0
  143. package/src/config/index.zmdb.generated.d.ts +12 -0
  144. package/src/config/index.zmdb.generated.js +28 -0
  145. package/src/config/index.zmdb.witness.ts +22 -0
  146. package/src/configured-plugin.ts +35 -0
  147. package/src/emit/index.ts +1496 -0
  148. package/src/errors.ts +6 -0
  149. package/src/index.ts +336 -0
  150. package/src/lint/ast.ts +96 -0
  151. package/src/lint/host-types.ts +9 -0
  152. package/src/lint/index.ts +80 -0
  153. package/src/lint/rules/no-distributed-nullable-tags.ts +76 -0
  154. package/src/lint/rules/no-empty-patch.ts +26 -0
  155. package/src/lint/rules/no-interpolated-sql.ts +45 -0
  156. package/src/lint/rules/no-unbounded-find.ts +26 -0
  157. package/src/lint/rules/no-unknown-json-column.ts +36 -0
  158. package/src/lint/rules/require-sql-on-number.ts +34 -0
  159. package/src/lint/types.ts +11 -0
  160. package/src/metro/metro.ts +313 -0
  161. package/src/protobuf/decode.ts +375 -0
  162. package/src/protobuf/descriptor.ts +353 -0
  163. package/src/protobuf/encode.ts +277 -0
  164. package/src/protobuf/grpc-ir.ts +17 -0
  165. package/src/protobuf/plan.ts +244 -0
  166. package/src/reflect/callsites.ts +198 -0
  167. package/src/reflect/index.ts +1723 -0
  168. package/src/reflect/session.ts +276 -0
  169. package/src/testing/index.ts +298 -0
  170. package/src/transform/index.ts +690 -0
  171. package/src/unplugin/index.ts +182 -0
  172. package/src/unplugin/inline-bench.ts +92 -0
@@ -0,0 +1,276 @@
1
+ // The one place that talks to the TypeScript compiler.
2
+ //
3
+ // `typescript@7` is the Go compiler with a thin JS client: `import('typescript')`
4
+ // resolves to exactly two keys (`version`, `versionMajorMinor`) and the real surface
5
+ // lives behind subpath exports. The checker is `typescript/unstable/sync`, and every
6
+ // call round-trips to a `tsgo` child process over a synchronous pipe.
7
+ //
8
+ // Two consequences shape this file:
9
+ //
10
+ // 1. **One session per build, not one per type.** Spawning the server and loading a
11
+ // project is the expensive part; a checker call is cheap. Every consumer takes a
12
+ // `ReflectSession` and holds it for the whole run.
13
+ // 2. **`close()` is not optional.** The child process outlives the import if nobody
14
+ // closes it, which in a test runner means a hung worker. `using` works, and
15
+ // `withSession` is there for the cases that cannot use it.
16
+ //
17
+ // `unstable` in the specifier is the compiler's word, not ours: the API is not
18
+ // covered by TypeScript's stability guarantee, so it is contained here rather than
19
+ // spread over the reflection and the emitter.
20
+
21
+ import { API } from 'typescript/unstable/sync';
22
+ import type { Checker, Diagnostic, Program } from 'typescript/unstable/sync';
23
+
24
+ /** A parsed source file, as the checker's client models one. */
25
+ export type SourceFileHandle = NonNullable<ReturnType<Program['getSourceFile']>>;
26
+
27
+ export interface SessionOptions {
28
+ /** Absolute path to the `tsconfig.json` that defines the program. */
29
+ readonly project: string;
30
+ /** Working directory for module resolution. Defaults to the project's directory. */
31
+ readonly cwd?: string;
32
+ }
33
+
34
+ interface ParsedProjectConfig {
35
+ readonly fileNames: readonly string[];
36
+ }
37
+
38
+ type PrepareProject = (config: ParsedProjectConfig) => string;
39
+ const PREPARED_OPEN = Symbol('preparedOpen');
40
+
41
+ /** What a snapshot update was for. The session keeps the log; see `updates`. */
42
+ export type SessionUpdate = 'open' | 'refresh' | 'invalidate';
43
+
44
+ // The three things a watcher can report about a file. Mutable arrays, and not because
45
+ // anything here mutates them: the compiler's own `FileChangeSummary` declares them mutable,
46
+ // and under `exactOptionalPropertyTypes` a `readonly string[]` will not go in.
47
+ interface FileEdits {
48
+ changed?: string[];
49
+ created?: string[];
50
+ deleted?: string[];
51
+ }
52
+
53
+ /**
54
+ * How many compiler servers this process has started.
55
+ *
56
+ * REQ-TF-11 asks for one `API` instance per build, which is the difference between a
57
+ * build that loads the project once and one that loads it per file. A claim like that
58
+ * needs a number behind it, so the counter lives here and the build-budget test reads
59
+ * it.
60
+ */
61
+ let apiInstances = 0;
62
+
63
+ export function apiInstanceCount(): number {
64
+ return apiInstances;
65
+ }
66
+
67
+ /**
68
+ * An open compiler session: one server process, one loaded project, one checker.
69
+ *
70
+ * ```ts
71
+ * using session = ReflectSession.open({ project: '/abs/tsconfig.json' });
72
+ * const ir = irFromType({ checker: session.checker, location: sf }, type);
73
+ * ```
74
+ */
75
+ export class ReflectSession implements Disposable {
76
+ readonly project: string;
77
+
78
+ #api: API;
79
+ #program: Program;
80
+ #checker: Checker;
81
+ #closed = false;
82
+ readonly #updates: SessionUpdate[] = ['open'];
83
+
84
+ private constructor(api: API, project: string, program: Program, checker: Checker) {
85
+ this.#api = api;
86
+ this.project = project;
87
+ this.#program = program;
88
+ this.#checker = checker;
89
+ }
90
+
91
+ /**
92
+ * The checker and program come from the *current* snapshot, so they are getters
93
+ * rather than fields: after `refresh` the old pair belongs to a disposed snapshot,
94
+ * and a caller holding one would be reading a program that no longer exists.
95
+ */
96
+ get checker(): Checker {
97
+ return this.#checker;
98
+ }
99
+
100
+ get program(): Program {
101
+ return this.#program;
102
+ }
103
+
104
+ /** Every snapshot update this session has performed, in order. See `SessionUpdate`. */
105
+ get updates(): readonly SessionUpdate[] {
106
+ return this.#updates;
107
+ }
108
+
109
+ static open(options: SessionOptions): ReflectSession {
110
+ return ReflectSession[PREPARED_OPEN](options);
111
+ }
112
+
113
+ static [PREPARED_OPEN](options: SessionOptions, prepare?: PrepareProject): ReflectSession {
114
+ const cwd = options.cwd ?? options.project.replace(/[/\\][^/\\]*$/, '');
115
+ apiInstances++;
116
+ const api = new API({ cwd });
117
+ try {
118
+ const projectPath =
119
+ prepare === undefined
120
+ ? options.project
121
+ : prepare({ fileNames: api.parseConfigFile(options.project).fileNames });
122
+ // `getProjects()` is empty rather than throwing when the config does not parse,
123
+ // and an empty project list would otherwise surface much later as "every type is
124
+ // `unsupported`" — a wrong answer dressed up as a supported one.
125
+ const project = api.updateSnapshot({ openProjects: [projectPath] }).getProjects()[0];
126
+ if (!project) throw new Error(`could not load a TypeScript project from ${projectPath}`);
127
+ return new ReflectSession(api, projectPath, project.program, project.checker);
128
+ } catch (error: unknown) {
129
+ api.close();
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Pick up edits to `changed` without reloading the project.
136
+ *
137
+ * This is the whole of watch-mode support, and the reason it is one method rather
138
+ * than "open a new session" is cost: `openProjects` re-reads the config and re-walks
139
+ * the import graph, which is the expensive half of a build. `fileChanges` re-checks
140
+ * the files that changed. A watch that reopened the project per keystroke would make
141
+ * the AOT path slower than the runtime one it replaces.
142
+ */
143
+ refresh(changed: readonly string[]): void {
144
+ this.#files({ changed: [...changed] });
145
+ }
146
+
147
+ /**
148
+ * Tell the program about files that did not exist when it loaded.
149
+ *
150
+ * A `changed` notification for a file the program has never seen is a no-op — measured:
151
+ * `getSourceFile` keeps returning `undefined` — so a new module has to arrive as
152
+ * `created` or it stays invisible for the rest of the build. That is exactly the shape
153
+ * of "add a file in watch mode", so it is not an edge case.
154
+ */
155
+ created(files: readonly string[]): void {
156
+ this.#files({ created: [...files] });
157
+ }
158
+
159
+ deleted(files: readonly string[]): void {
160
+ this.#files({ deleted: [...files] });
161
+ }
162
+
163
+ #files(changes: FileEdits): void {
164
+ if (Object.values(changes).every(names => names.length === 0)) return;
165
+ this.#update('refresh', { fileChanges: changes });
166
+ }
167
+
168
+ /** For the cases a watcher cannot describe — a config change, a new dependency. */
169
+ invalidateAll(): void {
170
+ this.#update('invalidate', { fileChanges: { invalidateAll: true } });
171
+ }
172
+
173
+ #update(kind: SessionUpdate, params: Parameters<API['updateSnapshot']>[0]): void {
174
+ if (this.#closed) throw new Error('the compiler session is closed');
175
+ // Deliberately no `openProjects` here: the open is ref-counted and persists across
176
+ // snapshots, so naming it again would both leak a reference and reload the project.
177
+ const snapshot = this.#api.updateSnapshot(params);
178
+ const project = snapshot.getProject(this.project) ?? snapshot.getProjects()[0];
179
+ if (!project) throw new Error(`the TypeScript project ${this.project} disappeared from the snapshot`);
180
+ this.#program = project.program;
181
+ this.#checker = project.checker;
182
+ this.#updates.push(kind);
183
+ }
184
+
185
+ /** The parsed file, or `undefined` when it is not part of the program. */
186
+ sourceFile(fileName: string): SourceFileHandle | undefined {
187
+ return this.#program.getSourceFile(fileName);
188
+ }
189
+
190
+ /**
191
+ * Every file in the program, dependencies and lib files included.
192
+ *
193
+ * Project compilation needs this and the plugin does not: a bundler hands over one module
194
+ * at a time, so the plugin never has to ask what the project contains. A project compile
195
+ * covers the whole build, so "which files might have a call site in them" is its first question.
196
+ *
197
+ * `getSourceFiles()` — the name the old compiler used — is not on the client at all;
198
+ * only the names marshal, and the caller has to filter `node_modules` and `.d.ts` for
199
+ * itself.
200
+ */
201
+ sourceFileNames(): readonly string[] {
202
+ return this.#program.getSourceFileNames();
203
+ }
204
+
205
+ /**
206
+ * The project's resolved compiler options.
207
+ *
208
+ * Read for one thing: whether a generated module may import `./models.ts` with the
209
+ * extension it has on disk. Guessing wrong writes a project that does not compile,
210
+ * which is a worse failure than refusing.
211
+ */
212
+ compilerOptions(): Readonly<Record<string, unknown>> {
213
+ // boundary: the client types this as `CompilerOptions`, an interface with a declared
214
+ // property per known flag. Widening it to a record is the safe direction — every value
215
+ // becomes `unknown` and the caller has to test what it reads — and it is the only way to
216
+ // ask about a flag by name, which is what a caller that only wants one flag needs.
217
+ return this.#program.getCompilerOptions() as Readonly<Record<string, unknown>>;
218
+ }
219
+
220
+ /**
221
+ * Semantic diagnostics for one file. The reflection reads types, and a type in a
222
+ * file that does not compile is a guess, so callers check this before trusting
223
+ * anything derived from it.
224
+ */
225
+ diagnostics(fileName: string): readonly Diagnostic[] {
226
+ return this.#program.getSemanticDiagnostics(fileName);
227
+ }
228
+
229
+ close(): void {
230
+ if (this.#closed) return;
231
+ this.#closed = true;
232
+ this.#api.close();
233
+ }
234
+
235
+ [Symbol.dispose](): void {
236
+ this.close();
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Open one session after preparing a disposable project from the compiler's parsed
242
+ * root-file inventory.
243
+ *
244
+ * Project compilation uses this to admit generated witness files even when the consumer
245
+ * config has an explicit `files`/`include` list. The preparation and project open share one
246
+ * API instance, so this remains one compiler session rather than a discovery pass followed
247
+ * by a second compiler process.
248
+ *
249
+ * This helper is intentionally not re-exported from `@zmdb/compiler/reflect`.
250
+ */
251
+ export function openPreparedSession(options: SessionOptions, prepare: PrepareProject): ReflectSession {
252
+ return ReflectSession[PREPARED_OPEN](options, prepare);
253
+ }
254
+
255
+ /**
256
+ * The concrete source-file set selected by a tsconfig.
257
+ *
258
+ * Config loading needs this narrower fact without retaining a compiler process:
259
+ * schema globs are allowed to select files only when the configured project
260
+ * includes them. Copy the names before closing because the program belongs to
261
+ * the session snapshot.
262
+ */
263
+ export function projectSourceFileNames(project: string): readonly string[] {
264
+ using session = ReflectSession.open({ project });
265
+ return [...session.sourceFileNames()];
266
+ }
267
+
268
+ /** `try`/`finally` around a session, for callers that cannot use `using`. */
269
+ export function withSession<T>(options: SessionOptions, fn: (session: ReflectSession) => T): T {
270
+ const session = ReflectSession.open(options);
271
+ try {
272
+ return fn(session);
273
+ } finally {
274
+ session.close();
275
+ }
276
+ }
@@ -0,0 +1,298 @@
1
+ // `@zmdb/compiler/testing` — tagged schema values, without a transform step.
2
+ //
3
+ // `schemaOf<User>()` is compiled away by the transform, and it has no runtime: a type
4
+ // argument does not survive to runtime, so a build that skipped the transform gets a thrown
5
+ // error rather than a plausible-looking empty schema. That is the right behaviour in an
6
+ // application and an awkward one in a test, because a unit test usually runs off the
7
+ // TypeScript source with no bundler in the way — `vitest`, `node --strip-types`, `tsx`. Ask
8
+ // for the schema there and you get the error, correctly, and no way forward.
9
+ //
10
+ // So this does through the compiler API what the transform does during a build: open the
11
+ // project, read exported tagged interfaces, and turn their IR into schema values. Tests can
12
+ // name individual exports with `schemasFrom`; build tools whose config already resolved a
13
+ // concrete file set use `schemasFromFiles`. Both return what the transform would have inlined
14
+ // — literally the same `schemaFromIR(schemaIrFromType(...))` expression — rather than a
15
+ // stand-in for the shipped path.
16
+ //
17
+ // ```ts
18
+ // import { schemasFrom } from '@zmdb/compiler/testing';
19
+ //
20
+ // export interface User extends Table<'users'> {
21
+ // id: number & Sql<'integer'> & Serial & PrimaryKey;
22
+ // email: string & Sql<'varchar'> & Length<255> & Unique;
23
+ // }
24
+ //
25
+ // const { User: users } = schemasFrom(import.meta.url, ['User']);
26
+ // ```
27
+ //
28
+ // The interfaces can live in the test file itself, which is the point: a fixture two
29
+ // directories away is a fixture nobody reads. They do have to be `export`ed, because the
30
+ // module's export table is how a name is resolved to a symbol.
31
+ //
32
+ // ## What it costs
33
+ //
34
+ // One compiler session per call — about 80ms to load a package-sized project, and about 3ms
35
+ // to resolve a type out of it. So a test should call once at module scope with every name it
36
+ // needs, while a command should pass its whole configured file set once. The session is
37
+ // closed before the call returns, because an open one holds a child process and a process
38
+ // that leaks those hangs on exit.
39
+ //
40
+ // It does check the module's own diagnostics first, which costs about 6ms and earns it back
41
+ // the first time somebody mistypes an import. A type read out of a file that does not compile
42
+ // comes back as an error type, and the reflection reports an error type as "the checker could
43
+ // not resolve this type" — true, unhelpful, and repeated once per column. The compile error is
44
+ // the thing that happened, so that is what gets raised.
45
+
46
+ import { existsSync } from 'node:fs';
47
+ import { dirname, join, resolve } from 'node:path';
48
+ import { fileURLToPath } from 'node:url';
49
+
50
+ import { type CoreSchema, type TaggedSchema } from '@zmdb/schema';
51
+ import { schemaFromIR, type SchemaIR } from '@zmdb/schema/ir';
52
+ import { type NamingStrategy } from '@zmdb/schema/naming';
53
+
54
+ import { schemaIrFromType, type ReflectDiagnostic, type ReflectOptions } from '../reflect/index.js';
55
+ import { ReflectSession } from '../reflect/session.js';
56
+
57
+ export interface SchemasFromOptions {
58
+ /**
59
+ * The `tsconfig.json` to read the module from. Defaults to the nearest one at or above the
60
+ * module's own directory, which is the right answer for a test inside its package.
61
+ */
62
+ readonly project?: string | undefined;
63
+ /**
64
+ * What to do with a reflection that had to refuse something. The default throws, because a
65
+ * schema with a column quietly missing is the failure mode this whole design exists to
66
+ * prevent; pass a function to inspect them instead.
67
+ */
68
+ readonly onDiagnostics?: ((diagnostics: readonly ReflectDiagnostic[]) => void) | undefined;
69
+ /** The already-resolved build-time naming strategy. */
70
+ readonly naming?: NamingStrategy | undefined;
71
+ }
72
+
73
+ export interface SchemasFromFilesOptions extends SchemasFromOptions {
74
+ /** The project that owns every selected declaration file. */
75
+ readonly project: string;
76
+ }
77
+
78
+ function reflectOptions(options: SchemasFromOptions): ReflectOptions | undefined {
79
+ return options.naming === undefined ? undefined : { naming: options.naming };
80
+ }
81
+
82
+ /**
83
+ * The schema values for exported tagged interfaces in one module.
84
+ *
85
+ * `module` is a path or a `file:` URL — `import.meta.url` for the calling test file, which is
86
+ * the usual case. The result is keyed by interface name, so it destructures.
87
+ */
88
+ export function schemasFrom<const Names extends readonly string[]>(
89
+ module: string,
90
+ names: Names,
91
+ options?: SchemasFromOptions,
92
+ ): { [Name in Names[number]]: CoreSchema<string> };
93
+ /**
94
+ * The same, with each schema remembering the type it came from.
95
+ *
96
+ * The reflection knows which interface it read, but a function cannot return a different type
97
+ * per string it was handed, so the mapping is stated: `schemasFrom<{ User: User }>(…)`. What
98
+ * comes back is a `TaggedSchema<User>`, exactly what `schemaOf<User>()` would have been, which
99
+ * is what makes `Entity<…>`, `CreateDTO<…>` and a typed repository work off it.
100
+ *
101
+ * The name appears twice, once as a type and once as a string. That is the honest amount: the
102
+ * string is what gets looked up at runtime and the type is what the compiler needs, and the
103
+ * constraint ties them together so a typo in either is an error rather than an `undefined`.
104
+ * Tests that only read the schema as data — IR, OpenAPI, seeding — want the erased overload
105
+ * above and should not pay the extra line.
106
+ */
107
+ export function schemasFrom<Types extends Record<string, object>>(
108
+ module: string,
109
+ names: readonly (keyof Types & string)[],
110
+ options?: SchemasFromOptions,
111
+ ): { [Name in keyof Types]: TaggedSchema<Types[Name]> };
112
+ export function schemasFrom(
113
+ module: string,
114
+ names: readonly string[],
115
+ options: SchemasFromOptions = {},
116
+ ): Record<string, CoreSchema<string>> {
117
+ const irs: Record<string, SchemaIR> = schemaIrsFrom(module, names, options);
118
+ const schemas: Record<string, CoreSchema<string>> = {};
119
+ for (const [name, ir] of Object.entries(irs)) schemas[name] = schemaFromIR(ir);
120
+ return schemas;
121
+ }
122
+
123
+ /**
124
+ * Every exported tagged table declaration in a concrete file set.
125
+ *
126
+ * Config loading has already expanded and project-checked these files. This
127
+ * bridge keeps the remaining compiler work in the reflection library: one
128
+ * session for the project, one pass over each module's exports, and the same
129
+ * `schemaFromIR` conversion used by `schemaOf<T>()` and `schemasFrom()`.
130
+ */
131
+ export function schemasFromFiles(
132
+ files: readonly string[],
133
+ options: SchemasFromFilesOptions,
134
+ ): readonly CoreSchema<string>[] {
135
+ if (files.length === 0) throw new Error('the configured schema file set is empty');
136
+
137
+ using session = ReflectSession.open({ project: options.project });
138
+ const schemas = new Map<string, CoreSchema<string>>();
139
+ const diagnostics: ReflectDiagnostic[] = [];
140
+
141
+ for (const file of files.toSorted()) {
142
+ const sourceFile = session.sourceFile(file);
143
+ if (!sourceFile) {
144
+ throw new Error(
145
+ `${file} is not part of ${options.project}; the configured schema files must belong to the project`,
146
+ );
147
+ }
148
+
149
+ const broken = session.diagnostics(file);
150
+ if (broken.length > 0) {
151
+ throw new Error(
152
+ `${file} does not compile, so its table declarations cannot be read (${broken.length} diagnostic(s)):\n` +
153
+ broken
154
+ .slice(0, 5)
155
+ .map(one => ` TS${String(one.code)}: ${one.text}`)
156
+ .join('\n'),
157
+ );
158
+ }
159
+
160
+ const moduleSymbol = session.checker.getSymbolAtLocation(sourceFile);
161
+ if (!moduleSymbol) throw new Error(`${file} has no module symbol, so it exports nothing to read`);
162
+ const exported = session.checker
163
+ .getExportsOfModule(moduleSymbol)
164
+ .toSorted((left, right) => left.name.localeCompare(right.name));
165
+
166
+ for (const symbol of exported) {
167
+ const type = session.checker.getDeclaredTypeOfSymbol(symbol);
168
+ const reflected = schemaIrFromType(session.checker, type, sourceFile, reflectOptions(options));
169
+ const isTable = reflected.diagnostics.every(diagnostic => !diagnostic.reason.includes("no Table<'name'> tag"));
170
+ if (!isTable) continue;
171
+
172
+ diagnostics.push(...reflected.diagnostics);
173
+ const schema = schemaFromIR(reflected.ir);
174
+ const previous = schemas.get(schema.ir.table);
175
+ if (previous !== undefined) {
176
+ if (JSON.stringify(previous.ir) !== JSON.stringify(schema.ir)) {
177
+ throw new Error(`configured schema files export conflicting declarations for table ${schema.ir.table}`);
178
+ }
179
+ continue;
180
+ }
181
+ schemas.set(schema.ir.table, schema);
182
+ }
183
+ }
184
+
185
+ if (diagnostics.length > 0) {
186
+ if (options.onDiagnostics) options.onDiagnostics(diagnostics);
187
+ else {
188
+ throw new Error(
189
+ `the reflection refused ${diagnostics.length} thing(s) in the configured schema files:\n` +
190
+ diagnostics.map(one => ` ${one.path ? `${one.path}: ` : ''}${one.reason}`).join('\n'),
191
+ );
192
+ }
193
+ }
194
+ if (schemas.size === 0) {
195
+ throw new Error(`the configured schema files export no tagged table declarations: ${files.toSorted().join(', ')}`);
196
+ }
197
+ return [...schemas.values()].toSorted((left, right) => left.ir.table.localeCompare(right.ir.table));
198
+ }
199
+
200
+ /**
201
+ * The same, stopping at the IR.
202
+ *
203
+ * Separate because the IR is the spine and a test about the front-end wants to see it
204
+ * directly — the schema value is one conversion further on, and `schemaFromIR` is not the
205
+ * thing under test.
206
+ */
207
+ export function schemaIrsFrom<const Names extends readonly string[]>(
208
+ module: string,
209
+ names: Names,
210
+ options: SchemasFromOptions = {},
211
+ ): { [Name in Names[number]]: SchemaIR } {
212
+ const file = module.startsWith('file:') ? fileURLToPath(module) : resolve(module);
213
+ const project = options.project ?? nearestProject(file);
214
+
215
+ using session = ReflectSession.open({ project });
216
+ const sourceFile = session.sourceFile(file);
217
+ if (!sourceFile) {
218
+ throw new Error(
219
+ `${file} is not part of ${project}. The tagged interfaces have to be in the program for ` +
220
+ 'the checker to have a declared type for them.',
221
+ );
222
+ }
223
+
224
+ // Before anything is read off the type: a file that does not compile has error types in it,
225
+ // and an error type reflects as a refusal per column rather than as the one problem it is.
226
+ const broken = session.diagnostics(file);
227
+ if (broken.length > 0) {
228
+ throw new Error(
229
+ `${file} does not compile, so its types cannot be read (${broken.length} diagnostic(s)):\n` +
230
+ broken
231
+ .slice(0, 5)
232
+ .map(one => ` TS${String(one.code)}: ${one.text}`)
233
+ .join('\n'),
234
+ );
235
+ }
236
+
237
+ const { checker } = session;
238
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
239
+ if (!moduleSymbol) throw new Error(`${file} has no module symbol, so it exports nothing to read`);
240
+ const exported = new Map(checker.getExportsOfModule(moduleSymbol).map(symbol => [symbol.name, symbol]));
241
+
242
+ const irs: Record<string, SchemaIR> = {};
243
+ const diagnostics: ReflectDiagnostic[] = [];
244
+ for (const name of names) {
245
+ const symbol = exported.get(name);
246
+ if (!symbol) {
247
+ // Naming the exports is worth the line: the usual cause is a missing `export`, and the
248
+ // message "User is not exported" is not obviously that when the interface is right there.
249
+ throw new Error(
250
+ `${file} exports no \`${name}\`. It has to be \`export interface ${name}\` — the module's ` +
251
+ `export table is how the name is resolved. Exports found: ${[...exported.keys()].join(', ') || 'none'}.`,
252
+ );
253
+ }
254
+ const type = checker.getDeclaredTypeOfSymbol(symbol);
255
+ // One reflector per name, which `schemaIrFromType` gives us: the node budget and the
256
+ // helper-name table are per-reflection state, and sharing them across unrelated tables
257
+ // would make one table's refusals show up against another's.
258
+ const result = schemaIrFromType(checker, type, sourceFile, reflectOptions(options));
259
+ irs[name] = result.ir;
260
+ diagnostics.push(...result.diagnostics);
261
+ }
262
+
263
+ if (diagnostics.length > 0) {
264
+ if (options.onDiagnostics) options.onDiagnostics(diagnostics);
265
+ else {
266
+ throw new Error(
267
+ `the reflection refused ${diagnostics.length} thing(s) in ${file}:\n` +
268
+ diagnostics.map(one => ` ${one.path ? `${one.path}: ` : ''}${one.reason}`).join('\n'),
269
+ );
270
+ }
271
+ }
272
+
273
+ // boundary: `irs` is filled in by the loop above, one key per member of `names`, and the
274
+ // loop throws rather than skipping when a name is not exported — so every key the return
275
+ // type promises is present by the time this runs. What the assertion buys is the *literal*
276
+ // keys: built from a `Record<string, SchemaIR>` because the names arrive as values, and no
277
+ // amount of building it differently makes the compiler read them back off the array.
278
+ return irs as { [Name in Names[number]]: SchemaIR };
279
+ }
280
+
281
+ /**
282
+ * The nearest `tsconfig.json` at or above a file.
283
+ *
284
+ * The same rule `tsc` uses for a bare invocation, and the same one the codegen CLI documents,
285
+ * so a caller who has not thought about it gets the project they would have guessed.
286
+ */
287
+ function nearestProject(file: string): string {
288
+ let directory = dirname(file);
289
+ for (;;) {
290
+ const candidate = join(directory, 'tsconfig.json');
291
+ if (existsSync(candidate)) return candidate;
292
+ const parent = dirname(directory);
293
+ if (parent === directory) {
294
+ throw new Error(`no tsconfig.json at or above ${file}; pass \`project\` explicitly`);
295
+ }
296
+ directory = parent;
297
+ }
298
+ }