@syncular/typegen 0.1.3 → 0.2.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/README.md +436 -94
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +159 -73
- package/dist/emit-dart.d.ts +17 -0
- package/dist/emit-dart.js +201 -0
- package/dist/emit-kotlin.d.ts +16 -0
- package/dist/emit-kotlin.js +201 -0
- package/dist/emit-queries-dart.d.ts +2 -0
- package/dist/emit-queries-dart.js +160 -0
- package/dist/emit-queries-kotlin.d.ts +2 -0
- package/dist/emit-queries-kotlin.js +162 -0
- package/dist/emit-queries-swift.d.ts +2 -0
- package/dist/emit-queries-swift.js +146 -0
- package/dist/emit-queries.d.ts +2 -0
- package/dist/emit-queries.js +129 -0
- package/dist/emit-swift.d.ts +18 -0
- package/dist/emit-swift.js +235 -0
- package/dist/emit.d.ts +16 -0
- package/dist/emit.js +191 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/generate.d.ts +53 -18
- package/dist/generate.js +391 -77
- package/dist/index.d.ts +17 -14
- package/dist/index.js +16 -13
- package/dist/init.d.ts +12 -0
- package/dist/init.js +85 -0
- package/dist/ir.d.ts +90 -0
- package/dist/ir.js +94 -0
- package/dist/manifest.d.ts +76 -0
- package/dist/manifest.js +303 -0
- package/dist/query.d.ts +96 -0
- package/dist/query.js +719 -0
- package/dist/sql.d.ts +13 -0
- package/dist/sql.js +440 -0
- package/package.json +14 -34
- package/src/cli.ts +161 -82
- package/src/emit-dart.ts +245 -0
- package/src/emit-kotlin.ts +257 -0
- package/src/emit-queries-dart.ts +203 -0
- package/src/emit-queries-kotlin.ts +209 -0
- package/src/emit-queries-swift.ts +198 -0
- package/src/emit-queries.ts +183 -0
- package/src/emit-swift.ts +295 -0
- package/src/emit.ts +239 -0
- package/src/errors.ts +11 -0
- package/src/generate.ts +574 -105
- package/src/index.ts +16 -13
- package/src/init.ts +100 -0
- package/src/ir.ts +197 -0
- package/src/manifest.ts +445 -0
- package/src/query.ts +918 -0
- package/src/sql.ts +533 -0
- package/dist/app-contract.d.ts +0 -154
- package/dist/app-contract.d.ts.map +0 -1
- package/dist/app-contract.js +0 -250
- package/dist/app-contract.js.map +0 -1
- package/dist/checksums.d.ts +0 -6
- package/dist/checksums.d.ts.map +0 -1
- package/dist/checksums.js +0 -173
- package/dist/checksums.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/introspect-postgres.d.ts +0 -8
- package/dist/introspect-postgres.d.ts.map +0 -1
- package/dist/introspect-postgres.js +0 -94
- package/dist/introspect-postgres.js.map +0 -1
- package/dist/introspect-sqlite.d.ts +0 -10
- package/dist/introspect-sqlite.d.ts.map +0 -1
- package/dist/introspect-sqlite.js +0 -97
- package/dist/introspect-sqlite.js.map +0 -1
- package/dist/introspect.d.ts +0 -8
- package/dist/introspect.d.ts.map +0 -1
- package/dist/introspect.js +0 -18
- package/dist/introspect.js.map +0 -1
- package/dist/map-types.d.ts +0 -20
- package/dist/map-types.d.ts.map +0 -1
- package/dist/map-types.js +0 -151
- package/dist/map-types.js.map +0 -1
- package/dist/render.d.ts +0 -23
- package/dist/render.d.ts.map +0 -1
- package/dist/render.js +0 -140
- package/dist/render.js.map +0 -1
- package/dist/types.d.ts +0 -122
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/app-contract.ts +0 -531
- package/src/checksums.ts +0 -257
- package/src/introspect-postgres.ts +0 -149
- package/src/introspect-sqlite.ts +0 -156
- package/src/introspect.ts +0 -36
- package/src/map-types.ts +0 -189
- package/src/render.ts +0 -196
- package/src/types.ts +0 -137
package/src/generate.ts
CHANGED
|
@@ -1,123 +1,592 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Orchestration: manifest + migrations → IR → generated TS module, plus
|
|
3
|
+
* the byte-exact `--check` freshness comparison.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from './
|
|
6
|
+
import { Database } from 'bun:sqlite';
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
15
|
+
import { emitModule } from './emit';
|
|
16
|
+
import { emitDartModule } from './emit-dart';
|
|
17
|
+
import { emitKotlinModule } from './emit-kotlin';
|
|
18
|
+
import { emitQueriesModule } from './emit-queries';
|
|
19
|
+
import { emitQueriesDartModule } from './emit-queries-dart';
|
|
20
|
+
import { emitQueriesKotlinModule } from './emit-queries-kotlin';
|
|
21
|
+
import { emitQueriesSwiftModule } from './emit-queries-swift';
|
|
22
|
+
import { emitSwiftModule } from './emit-swift';
|
|
23
|
+
import { TypegenError } from './errors';
|
|
24
|
+
import {
|
|
25
|
+
canonicalizeExtensions,
|
|
26
|
+
IR_VERSION,
|
|
27
|
+
type IrColumn,
|
|
28
|
+
type IrDocument,
|
|
29
|
+
type IrScope,
|
|
30
|
+
type IrScopeValue,
|
|
31
|
+
type IrSubscription,
|
|
32
|
+
type IrSubscriptionScope,
|
|
33
|
+
type IrTable,
|
|
34
|
+
irHash,
|
|
35
|
+
serializeIr,
|
|
36
|
+
} from './ir';
|
|
37
|
+
import {
|
|
38
|
+
MANIFEST_FILENAME,
|
|
39
|
+
type Manifest,
|
|
40
|
+
type ManifestScopeSpec,
|
|
41
|
+
parseManifest,
|
|
42
|
+
} from './manifest';
|
|
43
|
+
import {
|
|
44
|
+
type AnalyzedQuery,
|
|
45
|
+
analyzeQueryFile,
|
|
46
|
+
type QueryDb,
|
|
47
|
+
synthesizeDdl,
|
|
48
|
+
} from './query';
|
|
49
|
+
import { applyMigrationSql, type ParsedTable } from './sql';
|
|
17
50
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
):
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
export interface MigrationInput {
|
|
52
|
+
/** Directory name, e.g. `0001_initial`. */
|
|
53
|
+
readonly name: string;
|
|
54
|
+
/** Contents of its `up.sql`. */
|
|
55
|
+
readonly sql: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Same grammar the server compiles (§3.1): `prefix:{variable}`. */
|
|
59
|
+
const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
|
|
60
|
+
/** Whole-value placeholder: `{paramName}`. */
|
|
61
|
+
const PARAM_RE = /^\{([A-Za-z_$][A-Za-z0-9_$]*)\}$/;
|
|
62
|
+
|
|
63
|
+
function resolveScope(table: ParsedTable, spec: ManifestScopeSpec): IrScope {
|
|
64
|
+
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
65
|
+
const match = PATTERN_RE.exec(pattern);
|
|
66
|
+
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
67
|
+
throw new TypegenError(
|
|
68
|
+
MANIFEST_FILENAME,
|
|
69
|
+
`table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}' with exactly one variable (§3.1)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const variable = match[2];
|
|
73
|
+
const column = typeof spec === 'string' ? variable : spec.column;
|
|
74
|
+
if (!table.columns.some((c) => c.name === column)) {
|
|
75
|
+
throw new TypegenError(
|
|
76
|
+
MANIFEST_FILENAME,
|
|
77
|
+
`table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { pattern, variable, column };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildTable(
|
|
84
|
+
manifestTable: {
|
|
85
|
+
readonly name: string;
|
|
86
|
+
readonly scopes: readonly ManifestScopeSpec[];
|
|
87
|
+
readonly extensions: Readonly<Record<string, unknown>>;
|
|
88
|
+
readonly encryptedColumns: readonly string[];
|
|
89
|
+
},
|
|
90
|
+
parsed: ParsedTable,
|
|
91
|
+
): IrTable {
|
|
92
|
+
const scopes = manifestTable.scopes.map((spec) => resolveScope(parsed, spec));
|
|
93
|
+
for (const scope of scopes) {
|
|
94
|
+
const clash = scopes.find(
|
|
95
|
+
(s) => s.variable === scope.variable && s.column !== scope.column,
|
|
96
|
+
);
|
|
97
|
+
if (clash !== undefined) {
|
|
98
|
+
throw new TypegenError(
|
|
99
|
+
MANIFEST_FILENAME,
|
|
100
|
+
`table ${parsed.name}: variable ${JSON.stringify(scope.variable)} maps to two different columns (§3.1)`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const columns = applyEncryption(
|
|
105
|
+
parsed,
|
|
106
|
+
scopes,
|
|
107
|
+
manifestTable.encryptedColumns,
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
name: parsed.name,
|
|
111
|
+
primaryKey: parsed.primaryKey,
|
|
112
|
+
columns,
|
|
113
|
+
scopes,
|
|
114
|
+
// Indexes flow through from the migration parser (already validated:
|
|
115
|
+
// columns exist, names unique per schema) in declaration order.
|
|
116
|
+
indexes: parsed.indexes,
|
|
117
|
+
extensions: canonicalizeExtensions(manifestTable.extensions) as Record<
|
|
118
|
+
string,
|
|
119
|
+
unknown
|
|
120
|
+
>,
|
|
121
|
+
};
|
|
58
122
|
}
|
|
59
123
|
|
|
60
124
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* import { generateTypes } from '@syncular/typegen';
|
|
66
|
-
* import { migrations } from './migrations';
|
|
67
|
-
*
|
|
68
|
-
* await generateTypes({
|
|
69
|
-
* migrations,
|
|
70
|
-
* output: './src/db.generated.ts',
|
|
71
|
-
* });
|
|
72
|
-
* ```
|
|
125
|
+
* §5.11: flip each `encryptedColumns` entry to wire type `bytes`, recording
|
|
126
|
+
* `encrypted` + `declaredType`. Enforces the hard generate-time errors:
|
|
127
|
+
* encrypted scope column, encrypted crdt column, encrypted primary key, and
|
|
128
|
+
* an unknown/already-bytes column.
|
|
73
129
|
*/
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
130
|
+
function applyEncryption(
|
|
131
|
+
parsed: ParsedTable,
|
|
132
|
+
scopes: readonly { readonly column: string }[],
|
|
133
|
+
encryptedColumns: readonly string[],
|
|
134
|
+
): readonly IrColumn[] {
|
|
135
|
+
if (encryptedColumns.length === 0) return parsed.columns;
|
|
136
|
+
const encrypted = new Set(encryptedColumns);
|
|
137
|
+
const scopeColumns = new Set(scopes.map((s) => s.column));
|
|
138
|
+
for (const name of encrypted) {
|
|
139
|
+
const column = parsed.columns.find((c) => c.name === name);
|
|
140
|
+
if (column === undefined) {
|
|
141
|
+
throw new TypegenError(
|
|
142
|
+
MANIFEST_FILENAME,
|
|
143
|
+
`table ${parsed.name}: encryptedColumns names unknown column ${JSON.stringify(name)}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (name === parsed.primaryKey) {
|
|
147
|
+
throw new TypegenError(
|
|
148
|
+
MANIFEST_FILENAME,
|
|
149
|
+
`table ${parsed.name}: primary key ${JSON.stringify(name)} cannot be encrypted — it renders the plaintext rowId (§2.2, §5.11)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (scopeColumns.has(name)) {
|
|
153
|
+
throw new TypegenError(
|
|
154
|
+
MANIFEST_FILENAME,
|
|
155
|
+
`table ${parsed.name}: scope column ${JSON.stringify(name)} cannot be encrypted — scopes are extracted server-side and MUST stay plaintext (§3.1, §5.11)`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (column.type === 'crdt') {
|
|
159
|
+
throw new TypegenError(
|
|
160
|
+
MANIFEST_FILENAME,
|
|
161
|
+
`table ${parsed.name}: crdt column ${JSON.stringify(name)} cannot be encrypted — the server merges crdt bytes plaintext (§5.10.3, §5.11)`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return parsed.columns.map((column): IrColumn => {
|
|
166
|
+
if (!encrypted.has(column.name)) return column;
|
|
167
|
+
// Flip wire type to bytes; keep the declared type for app-side emitters.
|
|
168
|
+
return {
|
|
169
|
+
name: column.name,
|
|
170
|
+
type: 'bytes',
|
|
171
|
+
nullable: column.nullable,
|
|
172
|
+
encrypted: true,
|
|
173
|
+
declaredType: column.type,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildSubscription(
|
|
179
|
+
sub: Manifest['subscriptions'][number],
|
|
180
|
+
tables: readonly IrTable[],
|
|
181
|
+
): IrSubscription {
|
|
182
|
+
const table = tables.find((t) => t.name === sub.table);
|
|
183
|
+
if (table === undefined) {
|
|
184
|
+
throw new TypegenError(
|
|
185
|
+
MANIFEST_FILENAME,
|
|
186
|
+
`subscription ${sub.name}: unknown table ${JSON.stringify(sub.table)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const declared = new Set(table.scopes.map((s) => s.variable));
|
|
190
|
+
const scopes: IrSubscriptionScope[] = Object.entries(sub.scopes)
|
|
191
|
+
.map(([variable, values]): IrSubscriptionScope => {
|
|
192
|
+
if (!declared.has(variable)) {
|
|
193
|
+
throw new TypegenError(
|
|
194
|
+
MANIFEST_FILENAME,
|
|
195
|
+
`subscription ${sub.name}: ${JSON.stringify(variable)} is not a scope variable of table ${sub.table} (declared: ${[...declared].join(', ')})`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const irValues: IrScopeValue[] = values.map((value) => {
|
|
199
|
+
const param = PARAM_RE.exec(value);
|
|
200
|
+
if (param?.[1] !== undefined) {
|
|
201
|
+
return { kind: 'parameter', name: param[1] };
|
|
202
|
+
}
|
|
203
|
+
if (value.includes('{') || value.includes('}')) {
|
|
204
|
+
throw new TypegenError(
|
|
205
|
+
MANIFEST_FILENAME,
|
|
206
|
+
`subscription ${sub.name}: scope value ${JSON.stringify(value)} mixes literals and placeholders — a value is either a literal or exactly '{param}'`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (value === '*') {
|
|
210
|
+
throw new TypegenError(
|
|
211
|
+
MANIFEST_FILENAME,
|
|
212
|
+
`subscription ${sub.name}: '*' is rejected in requested scopes (§3.2)`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { kind: 'literal', value };
|
|
216
|
+
});
|
|
217
|
+
return { variable, values: irValues };
|
|
218
|
+
})
|
|
219
|
+
.sort((a, b) => (a.variable < b.variable ? -1 : 1));
|
|
220
|
+
return { name: sub.name, table: sub.table, scopes };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildSchemaVersions(
|
|
224
|
+
manifest: Manifest,
|
|
225
|
+
migrations: readonly MigrationInput[],
|
|
226
|
+
): IrDocument['schemaVersions'] {
|
|
227
|
+
const names = migrations.map((m) => m.name);
|
|
228
|
+
let cursor = 0;
|
|
229
|
+
const out = manifest.schemaVersions.map((entry) => {
|
|
230
|
+
const index = names.indexOf(entry.through, cursor);
|
|
231
|
+
if (index === -1) {
|
|
232
|
+
throw new TypegenError(
|
|
233
|
+
MANIFEST_FILENAME,
|
|
234
|
+
`schemaVersions: version ${entry.version} names migration ${JSON.stringify(entry.through)}, which does not exist after ${JSON.stringify(names[cursor - 1] ?? '(start)')} (migrations: ${names.join(', ')})`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const covered = names.slice(cursor, index + 1);
|
|
238
|
+
cursor = index + 1;
|
|
239
|
+
return { version: entry.version, migrations: covered };
|
|
240
|
+
});
|
|
241
|
+
if (cursor !== names.length) {
|
|
242
|
+
throw new TypegenError(
|
|
243
|
+
MANIFEST_FILENAME,
|
|
244
|
+
`schemaVersions: migrations ${names.slice(cursor).join(', ')} are not covered by any schema version — the last entry's "through" must be the last migration`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Pure IR construction — the testable heart of the generator. */
|
|
251
|
+
export function buildIr(
|
|
252
|
+
manifest: Manifest,
|
|
253
|
+
migrations: readonly MigrationInput[],
|
|
254
|
+
): IrDocument {
|
|
255
|
+
if (migrations.length === 0) {
|
|
256
|
+
throw new TypegenError(MANIFEST_FILENAME, 'no migrations found');
|
|
257
|
+
}
|
|
258
|
+
const parsedTables = new Map<string, ParsedTable>();
|
|
259
|
+
for (const migration of migrations) {
|
|
260
|
+
applyMigrationSql(parsedTables, migration.sql, `${migration.name}/up.sql`);
|
|
261
|
+
}
|
|
262
|
+
const schemaVersions = buildSchemaVersions(manifest, migrations);
|
|
263
|
+
const tables = manifest.tables.map((manifestTable) => {
|
|
264
|
+
const parsed = parsedTables.get(manifestTable.name);
|
|
265
|
+
if (parsed === undefined) {
|
|
266
|
+
throw new TypegenError(
|
|
267
|
+
MANIFEST_FILENAME,
|
|
268
|
+
`table ${JSON.stringify(manifestTable.name)} is not created by any migration`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return buildTable(manifestTable, parsed);
|
|
272
|
+
});
|
|
273
|
+
const listed = new Set(manifest.tables.map((t) => t.name));
|
|
274
|
+
for (const name of parsedTables.keys()) {
|
|
275
|
+
if (!listed.has(name)) {
|
|
276
|
+
throw new TypegenError(
|
|
277
|
+
MANIFEST_FILENAME,
|
|
278
|
+
`migrated table ${JSON.stringify(name)} is missing from the manifest's tables list — every migrated table must be declared (fail loud; internal tables are a future manifest extension)`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const subscriptions = manifest.subscriptions.map((sub) =>
|
|
283
|
+
buildSubscription(sub, tables),
|
|
100
284
|
);
|
|
285
|
+
const last = manifest.schemaVersions[manifest.schemaVersions.length - 1];
|
|
286
|
+
if (last === undefined) throw new Error('unreachable: schemaVersions');
|
|
287
|
+
return {
|
|
288
|
+
irVersion: IR_VERSION,
|
|
289
|
+
schemaVersion: last.version,
|
|
290
|
+
schemaVersions,
|
|
291
|
+
tables,
|
|
292
|
+
subscriptions,
|
|
293
|
+
extensions: canonicalizeExtensions(manifest.extensions) as Record<
|
|
294
|
+
string,
|
|
295
|
+
unknown
|
|
296
|
+
>,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const MIGRATION_DIR_RE = /^(\d+)_[A-Za-z0-9_-]+$/;
|
|
101
301
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
302
|
+
/** Load `NNNN_name/up.sql` migrations, ordered by numeric prefix. */
|
|
303
|
+
export function loadMigrations(migrationsDir: string): MigrationInput[] {
|
|
304
|
+
if (!existsSync(migrationsDir)) {
|
|
305
|
+
throw new TypegenError(
|
|
306
|
+
migrationsDir,
|
|
307
|
+
'migrations directory does not exist',
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const entries = readdirSync(migrationsDir, { withFileTypes: true })
|
|
311
|
+
.filter((entry) => entry.isDirectory())
|
|
312
|
+
.map((entry) => entry.name);
|
|
313
|
+
const parsed = entries.map((name) => {
|
|
314
|
+
const match = MIGRATION_DIR_RE.exec(name);
|
|
315
|
+
if (match === null || match[1] === undefined) {
|
|
316
|
+
throw new TypegenError(
|
|
317
|
+
migrationsDir,
|
|
318
|
+
`migration directory ${JSON.stringify(name)} does not match NNNN_name`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return { name, order: Number.parseInt(match[1], 10) };
|
|
322
|
+
});
|
|
323
|
+
parsed.sort((a, b) => a.order - b.order);
|
|
324
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
325
|
+
const prev = parsed[i - 1];
|
|
326
|
+
const next = parsed[i];
|
|
327
|
+
if (prev !== undefined && next !== undefined && prev.order === next.order) {
|
|
328
|
+
throw new TypegenError(
|
|
329
|
+
migrationsDir,
|
|
330
|
+
`migrations ${prev.name} and ${next.name} share the ordinal ${next.order}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return parsed.map(({ name }) => {
|
|
335
|
+
const upPath = join(migrationsDir, name, 'up.sql');
|
|
336
|
+
if (!existsSync(upPath)) {
|
|
337
|
+
throw new TypegenError(join(name, 'up.sql'), 'missing migration file');
|
|
338
|
+
}
|
|
339
|
+
return { name, sql: readFileSync(upPath, 'utf8') };
|
|
107
340
|
});
|
|
341
|
+
}
|
|
108
342
|
|
|
109
|
-
|
|
110
|
-
|
|
343
|
+
/** One `.sql` named-query file: path (relative to the queries root, forward
|
|
344
|
+
* slashes) + raw contents. A file may hold multiple statements. */
|
|
345
|
+
export interface QueryInput {
|
|
346
|
+
/** Path relative to the queries root, forward-slashed (e.g.
|
|
347
|
+
* `billing/invoices/list.sql`). Drives the path-derived default name. */
|
|
348
|
+
readonly file: string;
|
|
349
|
+
readonly sql: string;
|
|
350
|
+
}
|
|
111
351
|
|
|
112
|
-
|
|
113
|
-
|
|
352
|
+
/** Recursively collect `*.sql` paths under `dir`, relative to `root`
|
|
353
|
+
* (forward-slashed), sorted for deterministic emission. */
|
|
354
|
+
function collectSqlFiles(root: string, dir: string): string[] {
|
|
355
|
+
const out: string[] = [];
|
|
356
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
357
|
+
const abs = join(dir, entry.name);
|
|
358
|
+
if (entry.isDirectory()) {
|
|
359
|
+
out.push(...collectSqlFiles(root, abs));
|
|
360
|
+
} else if (entry.isFile() && entry.name.endsWith('.sql')) {
|
|
361
|
+
out.push(relative(root, abs).split(sep).join('/'));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
114
366
|
|
|
115
|
-
|
|
367
|
+
/** Load `queries/**\/*.sql` recursively, ordered by relative path
|
|
368
|
+
* (deterministic emission). Folders are pure organization. */
|
|
369
|
+
export function loadQueries(queriesDir: string): QueryInput[] {
|
|
370
|
+
if (!existsSync(queriesDir)) return [];
|
|
371
|
+
return collectSqlFiles(queriesDir, queriesDir)
|
|
372
|
+
.sort()
|
|
373
|
+
.map((file) => ({
|
|
374
|
+
file,
|
|
375
|
+
sql: readFileSync(join(queriesDir, file), 'utf8'),
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
116
378
|
|
|
379
|
+
/** A {@link QueryDb} backed by an in-memory bun:sqlite built from the IR DDL.
|
|
380
|
+
* `analyze` prepares the statement (validating references) and reads its
|
|
381
|
+
* column names + declared types; it executes once against the empty DB so
|
|
382
|
+
* `declaredTypes` (decltype) is populated. */
|
|
383
|
+
function makeQueryDb(ir: IrDocument): { db: QueryDb; close: () => void } {
|
|
384
|
+
const sqlite = new Database(':memory:');
|
|
385
|
+
sqlite.run(synthesizeDdl(ir));
|
|
386
|
+
const db: QueryDb = {
|
|
387
|
+
analyze(sql: string) {
|
|
388
|
+
const stmt = sqlite.prepare(sql);
|
|
389
|
+
try {
|
|
390
|
+
const columnNames = stmt.columnNames;
|
|
391
|
+
// Execute once against the empty DB to populate decltype; result is [].
|
|
392
|
+
(stmt as unknown as { all: () => unknown[] }).all();
|
|
393
|
+
const declaredTypes = (
|
|
394
|
+
stmt as unknown as { declaredTypes: (string | null)[] }
|
|
395
|
+
).declaredTypes;
|
|
396
|
+
const paramsCount = (stmt as unknown as { paramsCount: number })
|
|
397
|
+
.paramsCount;
|
|
398
|
+
return { columnNames, declaredTypes, paramsCount };
|
|
399
|
+
} finally {
|
|
400
|
+
stmt.finalize();
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
return { db, close: () => sqlite.close() };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Analyze every query file against the IR (SELECT-only, typed, deps). */
|
|
408
|
+
export function analyzeQueries(
|
|
409
|
+
ir: IrDocument,
|
|
410
|
+
queries: readonly QueryInput[],
|
|
411
|
+
): AnalyzedQuery[] {
|
|
412
|
+
if (queries.length === 0) return [];
|
|
413
|
+
const { db, close } = makeQueryDb(ir);
|
|
414
|
+
try {
|
|
415
|
+
// Each file may hold multiple statements; flatten in path order (stable).
|
|
416
|
+
const analyzed = queries.flatMap((q) =>
|
|
417
|
+
analyzeQueryFile(q.file, q.sql, ir, db),
|
|
418
|
+
);
|
|
419
|
+
// Names must be unique across the whole manifest — the filesystem no longer
|
|
420
|
+
// guarantees uniqueness once `-- name:` overrides exist. Report BOTH source
|
|
421
|
+
// locations (file + statement position) on a collision.
|
|
422
|
+
const seen = new Map<string, string>();
|
|
423
|
+
for (const q of analyzed) {
|
|
424
|
+
const prev = seen.get(q.name);
|
|
425
|
+
if (prev !== undefined) {
|
|
426
|
+
throw new TypegenError(
|
|
427
|
+
q.file,
|
|
428
|
+
`duplicate query name ${JSON.stringify(q.name)} — defined at ${prev} and ${q.file}. Rename one (a file's path-derived default or its \`-- name:\` override).`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
seen.set(q.name, q.file);
|
|
432
|
+
}
|
|
433
|
+
return analyzed;
|
|
434
|
+
} finally {
|
|
435
|
+
close();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** One generated artifact: an absolute output path and its exact bytes. */
|
|
440
|
+
export interface GeneratedOutput {
|
|
441
|
+
readonly path: string;
|
|
442
|
+
readonly content: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export interface GenerateResult {
|
|
446
|
+
readonly ir: IrDocument;
|
|
447
|
+
/** Serialized IR document (the exact bytes of the `.ir.json` output). */
|
|
448
|
+
readonly irJson: string;
|
|
449
|
+
readonly hash: string;
|
|
450
|
+
/** Generated TS module source (the exact bytes of the `.ts` output). */
|
|
451
|
+
readonly module: string;
|
|
452
|
+
readonly irPath: string;
|
|
453
|
+
readonly modulePath: string;
|
|
454
|
+
/** Analyzed named queries (empty when no `queries/` files / no query output). */
|
|
455
|
+
readonly queries: readonly AnalyzedQuery[];
|
|
456
|
+
/** Every artifact this manifest emits (IR + TS + any opt-in native), in a
|
|
457
|
+
* stable order — the single list `writeOutputs`/`checkOutputs` iterate. */
|
|
458
|
+
readonly outputs: readonly GeneratedOutput[];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
|
|
462
|
+
export function generate(manifestDir: string): GenerateResult {
|
|
463
|
+
const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
|
|
464
|
+
if (!existsSync(manifestPath)) {
|
|
465
|
+
throw new TypegenError(manifestPath, 'manifest not found');
|
|
466
|
+
}
|
|
467
|
+
let raw: unknown;
|
|
468
|
+
try {
|
|
469
|
+
raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
470
|
+
} catch (error) {
|
|
471
|
+
throw new TypegenError(
|
|
472
|
+
manifestPath,
|
|
473
|
+
`invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
const manifest = parseManifest(raw);
|
|
477
|
+
const migrations = loadMigrations(resolve(manifestDir, manifest.migrations));
|
|
478
|
+
const ir = buildIr(manifest, migrations);
|
|
479
|
+
const irJson = serializeIr(ir);
|
|
480
|
+
const hash = irHash(irJson);
|
|
481
|
+
const module = emitModule(ir, hash);
|
|
482
|
+
const irPath = resolve(manifestDir, manifest.output.ir);
|
|
483
|
+
const modulePath = resolve(manifestDir, manifest.output.module);
|
|
484
|
+
const outputs: GeneratedOutput[] = [
|
|
485
|
+
{ path: irPath, content: irJson },
|
|
486
|
+
{ path: modulePath, content: module },
|
|
487
|
+
];
|
|
488
|
+
// Opt-in native emitters — each present only when the manifest requests it.
|
|
489
|
+
const { queries: tsQueriesPath, swift, kotlin, dart } = manifest.output;
|
|
490
|
+
|
|
491
|
+
// Named queries: analyzed once (SELECT-only, typed against the IR via
|
|
492
|
+
// SQLite), then emitted per-language into its OWN file so schema-only
|
|
493
|
+
// consumers never churn. Only analyzed when SOME query output is requested.
|
|
494
|
+
const wantsQueries =
|
|
495
|
+
tsQueriesPath !== undefined ||
|
|
496
|
+
swift?.queriesPath !== undefined ||
|
|
497
|
+
kotlin?.queriesPath !== undefined ||
|
|
498
|
+
dart?.queriesPath !== undefined;
|
|
499
|
+
const analyzedQueries = wantsQueries
|
|
500
|
+
? analyzeQueries(ir, loadQueries(resolve(manifestDir, manifest.queries)))
|
|
501
|
+
: [];
|
|
502
|
+
if (wantsQueries && analyzedQueries.length === 0) {
|
|
503
|
+
throw new TypegenError(
|
|
504
|
+
resolve(manifestDir, manifest.queries),
|
|
505
|
+
`an output requests named queries but no .sql files were found in ${manifest.queries} — add a query file or drop the queries output`,
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (tsQueriesPath !== undefined) {
|
|
510
|
+
outputs.push({
|
|
511
|
+
path: resolve(manifestDir, tsQueriesPath),
|
|
512
|
+
content: emitQueriesModule(analyzedQueries, hash, ir.irVersion),
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
if (swift !== undefined) {
|
|
516
|
+
outputs.push({
|
|
517
|
+
path: resolve(manifestDir, swift.path),
|
|
518
|
+
content: emitSwiftModule(ir, hash, swift.enumName),
|
|
519
|
+
});
|
|
520
|
+
if (swift.queriesPath !== undefined) {
|
|
521
|
+
outputs.push({
|
|
522
|
+
path: resolve(manifestDir, swift.queriesPath),
|
|
523
|
+
content: emitQueriesSwiftModule(
|
|
524
|
+
analyzedQueries,
|
|
525
|
+
hash,
|
|
526
|
+
ir.irVersion,
|
|
527
|
+
swift.enumName,
|
|
528
|
+
),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (kotlin !== undefined) {
|
|
533
|
+
outputs.push({
|
|
534
|
+
path: resolve(manifestDir, kotlin.path),
|
|
535
|
+
content: emitKotlinModule(ir, hash, kotlin.package, kotlin.objectName),
|
|
536
|
+
});
|
|
537
|
+
if (kotlin.queriesPath !== undefined) {
|
|
538
|
+
outputs.push({
|
|
539
|
+
path: resolve(manifestDir, kotlin.queriesPath),
|
|
540
|
+
content: emitQueriesKotlinModule(
|
|
541
|
+
analyzedQueries,
|
|
542
|
+
hash,
|
|
543
|
+
ir.irVersion,
|
|
544
|
+
kotlin.package,
|
|
545
|
+
kotlin.objectName,
|
|
546
|
+
),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (dart !== undefined) {
|
|
551
|
+
outputs.push({
|
|
552
|
+
path: resolve(manifestDir, dart.path),
|
|
553
|
+
content: emitDartModule(ir, hash),
|
|
554
|
+
});
|
|
555
|
+
if (dart.queriesPath !== undefined) {
|
|
556
|
+
outputs.push({
|
|
557
|
+
path: resolve(manifestDir, dart.queriesPath),
|
|
558
|
+
content: emitQueriesDartModule(analyzedQueries, hash, ir.irVersion),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
117
562
|
return {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
563
|
+
ir,
|
|
564
|
+
irJson,
|
|
565
|
+
hash,
|
|
566
|
+
module,
|
|
567
|
+
irPath,
|
|
568
|
+
modulePath,
|
|
569
|
+
queries: analyzedQueries,
|
|
570
|
+
outputs,
|
|
122
571
|
};
|
|
123
572
|
}
|
|
573
|
+
|
|
574
|
+
export function writeOutputs(result: GenerateResult): void {
|
|
575
|
+
for (const { path, content } of result.outputs) {
|
|
576
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
577
|
+
writeFileSync(path, content, 'utf8');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Byte-exact freshness check; returns human-readable drift descriptions. */
|
|
582
|
+
export function checkOutputs(result: GenerateResult): string[] {
|
|
583
|
+
const drift: string[] = [];
|
|
584
|
+
for (const { path, content } of result.outputs) {
|
|
585
|
+
if (!existsSync(path)) {
|
|
586
|
+
drift.push(`${path}: missing — run generate`);
|
|
587
|
+
} else if (readFileSync(path, 'utf8') !== content) {
|
|
588
|
+
drift.push(`${path}: stale — run generate`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return drift;
|
|
592
|
+
}
|