@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,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
|
+
}
|