@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.
Files changed (99) hide show
  1. package/README.md +436 -94
  2. package/dist/cli.d.ts +1 -2
  3. package/dist/cli.js +159 -73
  4. package/dist/emit-dart.d.ts +17 -0
  5. package/dist/emit-dart.js +201 -0
  6. package/dist/emit-kotlin.d.ts +16 -0
  7. package/dist/emit-kotlin.js +201 -0
  8. package/dist/emit-queries-dart.d.ts +2 -0
  9. package/dist/emit-queries-dart.js +160 -0
  10. package/dist/emit-queries-kotlin.d.ts +2 -0
  11. package/dist/emit-queries-kotlin.js +162 -0
  12. package/dist/emit-queries-swift.d.ts +2 -0
  13. package/dist/emit-queries-swift.js +146 -0
  14. package/dist/emit-queries.d.ts +2 -0
  15. package/dist/emit-queries.js +129 -0
  16. package/dist/emit-swift.d.ts +18 -0
  17. package/dist/emit-swift.js +235 -0
  18. package/dist/emit.d.ts +16 -0
  19. package/dist/emit.js +191 -0
  20. package/dist/errors.d.ts +6 -0
  21. package/dist/errors.js +10 -0
  22. package/dist/generate.d.ts +53 -18
  23. package/dist/generate.js +391 -77
  24. package/dist/index.d.ts +17 -14
  25. package/dist/index.js +16 -13
  26. package/dist/init.d.ts +12 -0
  27. package/dist/init.js +85 -0
  28. package/dist/ir.d.ts +90 -0
  29. package/dist/ir.js +94 -0
  30. package/dist/manifest.d.ts +76 -0
  31. package/dist/manifest.js +303 -0
  32. package/dist/query.d.ts +96 -0
  33. package/dist/query.js +719 -0
  34. package/dist/sql.d.ts +13 -0
  35. package/dist/sql.js +440 -0
  36. package/package.json +14 -34
  37. package/src/cli.ts +161 -82
  38. package/src/emit-dart.ts +245 -0
  39. package/src/emit-kotlin.ts +257 -0
  40. package/src/emit-queries-dart.ts +203 -0
  41. package/src/emit-queries-kotlin.ts +209 -0
  42. package/src/emit-queries-swift.ts +198 -0
  43. package/src/emit-queries.ts +183 -0
  44. package/src/emit-swift.ts +295 -0
  45. package/src/emit.ts +239 -0
  46. package/src/errors.ts +11 -0
  47. package/src/generate.ts +574 -105
  48. package/src/index.ts +16 -13
  49. package/src/init.ts +100 -0
  50. package/src/ir.ts +197 -0
  51. package/src/manifest.ts +445 -0
  52. package/src/query.ts +918 -0
  53. package/src/sql.ts +533 -0
  54. package/dist/app-contract.d.ts +0 -154
  55. package/dist/app-contract.d.ts.map +0 -1
  56. package/dist/app-contract.js +0 -250
  57. package/dist/app-contract.js.map +0 -1
  58. package/dist/checksums.d.ts +0 -6
  59. package/dist/checksums.d.ts.map +0 -1
  60. package/dist/checksums.js +0 -173
  61. package/dist/checksums.js.map +0 -1
  62. package/dist/cli.d.ts.map +0 -1
  63. package/dist/cli.js.map +0 -1
  64. package/dist/generate.d.ts.map +0 -1
  65. package/dist/generate.js.map +0 -1
  66. package/dist/index.d.ts.map +0 -1
  67. package/dist/index.js.map +0 -1
  68. package/dist/introspect-postgres.d.ts +0 -8
  69. package/dist/introspect-postgres.d.ts.map +0 -1
  70. package/dist/introspect-postgres.js +0 -94
  71. package/dist/introspect-postgres.js.map +0 -1
  72. package/dist/introspect-sqlite.d.ts +0 -10
  73. package/dist/introspect-sqlite.d.ts.map +0 -1
  74. package/dist/introspect-sqlite.js +0 -97
  75. package/dist/introspect-sqlite.js.map +0 -1
  76. package/dist/introspect.d.ts +0 -8
  77. package/dist/introspect.d.ts.map +0 -1
  78. package/dist/introspect.js +0 -18
  79. package/dist/introspect.js.map +0 -1
  80. package/dist/map-types.d.ts +0 -20
  81. package/dist/map-types.d.ts.map +0 -1
  82. package/dist/map-types.js +0 -151
  83. package/dist/map-types.js.map +0 -1
  84. package/dist/render.d.ts +0 -23
  85. package/dist/render.d.ts.map +0 -1
  86. package/dist/render.js +0 -140
  87. package/dist/render.js.map +0 -1
  88. package/dist/types.d.ts +0 -122
  89. package/dist/types.d.ts.map +0 -1
  90. package/dist/types.js +0 -5
  91. package/dist/types.js.map +0 -1
  92. package/src/app-contract.ts +0 -531
  93. package/src/checksums.ts +0 -257
  94. package/src/introspect-postgres.ts +0 -149
  95. package/src/introspect-sqlite.ts +0 -156
  96. package/src/introspect.ts +0 -36
  97. package/src/map-types.ts +0 -189
  98. package/src/render.ts +0 -196
  99. package/src/types.ts +0 -137
@@ -1,20 +1,55 @@
1
1
  /**
2
- * @syncular/typegen - Type generation entry point
2
+ * Orchestration: manifest + migrations IR → generated TS module, plus
3
+ * the byte-exact `--check` freshness comparison.
3
4
  */
4
- import type { GenerateTypesOptions, GenerateTypesResult } from './types';
5
- /**
6
- * Generate TypeScript types from migrations.
7
- *
8
- * @example
9
- * ```typescript
10
- * import { generateTypes } from '@syncular/typegen';
11
- * import { migrations } from './migrations';
12
- *
13
- * await generateTypes({
14
- * migrations,
15
- * output: './src/db.generated.ts',
16
- * });
17
- * ```
18
- */
19
- export declare function generateTypes<DB>(options: GenerateTypesOptions<DB>): Promise<GenerateTypesResult>;
20
- //# sourceMappingURL=generate.d.ts.map
5
+ import { type IrDocument } from './ir.js';
6
+ import { type Manifest } from './manifest.js';
7
+ import { type AnalyzedQuery } from './query.js';
8
+ export interface MigrationInput {
9
+ /** Directory name, e.g. `0001_initial`. */
10
+ readonly name: string;
11
+ /** Contents of its `up.sql`. */
12
+ readonly sql: string;
13
+ }
14
+ /** Pure IR construction — the testable heart of the generator. */
15
+ export declare function buildIr(manifest: Manifest, migrations: readonly MigrationInput[]): IrDocument;
16
+ /** Load `NNNN_name/up.sql` migrations, ordered by numeric prefix. */
17
+ export declare function loadMigrations(migrationsDir: string): MigrationInput[];
18
+ /** One `.sql` named-query file: path (relative to the queries root, forward
19
+ * slashes) + raw contents. A file may hold multiple statements. */
20
+ export interface QueryInput {
21
+ /** Path relative to the queries root, forward-slashed (e.g.
22
+ * `billing/invoices/list.sql`). Drives the path-derived default name. */
23
+ readonly file: string;
24
+ readonly sql: string;
25
+ }
26
+ /** Load `queries/**\/*.sql` recursively, ordered by relative path
27
+ * (deterministic emission). Folders are pure organization. */
28
+ export declare function loadQueries(queriesDir: string): QueryInput[];
29
+ /** Analyze every query file against the IR (SELECT-only, typed, deps). */
30
+ export declare function analyzeQueries(ir: IrDocument, queries: readonly QueryInput[]): AnalyzedQuery[];
31
+ /** One generated artifact: an absolute output path and its exact bytes. */
32
+ export interface GeneratedOutput {
33
+ readonly path: string;
34
+ readonly content: string;
35
+ }
36
+ export interface GenerateResult {
37
+ readonly ir: IrDocument;
38
+ /** Serialized IR document (the exact bytes of the `.ir.json` output). */
39
+ readonly irJson: string;
40
+ readonly hash: string;
41
+ /** Generated TS module source (the exact bytes of the `.ts` output). */
42
+ readonly module: string;
43
+ readonly irPath: string;
44
+ readonly modulePath: string;
45
+ /** Analyzed named queries (empty when no `queries/` files / no query output). */
46
+ readonly queries: readonly AnalyzedQuery[];
47
+ /** Every artifact this manifest emits (IR + TS + any opt-in native), in a
48
+ * stable order — the single list `writeOutputs`/`checkOutputs` iterate. */
49
+ readonly outputs: readonly GeneratedOutput[];
50
+ }
51
+ /** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
52
+ export declare function generate(manifestDir: string): GenerateResult;
53
+ export declare function writeOutputs(result: GenerateResult): void;
54
+ /** Byte-exact freshness check; returns human-readable drift descriptions. */
55
+ export declare function checkOutputs(result: GenerateResult): string[];
package/dist/generate.js CHANGED
@@ -1,86 +1,400 @@
1
1
  /**
2
- * @syncular/typegen - Type generation entry point
2
+ * Orchestration: manifest + migrations IR → generated TS module, plus
3
+ * the byte-exact `--check` freshness comparison.
3
4
  */
4
- import { mkdir, writeFile } from 'node:fs/promises';
5
- import { dirname } from 'node:path';
6
- import { introspectAllVersions, introspectCurrentSchema } from './introspect.js';
7
- import { resolveColumnType } from './map-types.js';
8
- import { renderTypes } from './render.js';
9
- /**
10
- * Apply type mapping to all columns in the schemas.
11
- * Returns the schemas with tsType filled in and any custom imports collected.
12
- */
13
- function applyTypeMappings(schemas, dialect, codecs) {
14
- const allImports = [];
15
- const mapped = schemas.map((schema) => ({
16
- ...schema,
17
- tables: schema.tables.map((table) => ({
18
- ...table,
19
- columns: table.columns.map((col) => {
20
- const columnInfo = {
21
- table: table.name,
22
- column: col.name,
23
- sqlType: col.sqlType,
24
- nullable: col.nullable,
25
- isPrimaryKey: col.isPrimaryKey,
26
- hasDefault: col.hasDefault,
27
- dialect,
28
- };
29
- const codec = codecs?.(columnInfo);
30
- const resolved = resolveColumnType(columnInfo, codec?.ts);
31
- allImports.push(...resolved.imports);
32
- return {
33
- ...col,
34
- tsType: resolved.tsType,
35
- };
36
- }),
37
- })),
38
- }));
39
- return { schemas: mapped, customImports: allImports };
5
+ import { Database } from 'bun:sqlite';
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
+ import { emitModule } from './emit.js';
9
+ import { emitDartModule } from './emit-dart.js';
10
+ import { emitKotlinModule } from './emit-kotlin.js';
11
+ import { emitQueriesModule } from './emit-queries.js';
12
+ import { emitQueriesDartModule } from './emit-queries-dart.js';
13
+ import { emitQueriesKotlinModule } from './emit-queries-kotlin.js';
14
+ import { emitQueriesSwiftModule } from './emit-queries-swift.js';
15
+ import { emitSwiftModule } from './emit-swift.js';
16
+ import { TypegenError } from './errors.js';
17
+ import { canonicalizeExtensions, IR_VERSION, irHash, serializeIr, } from './ir.js';
18
+ import { MANIFEST_FILENAME, parseManifest, } from './manifest.js';
19
+ import { analyzeQueryFile, synthesizeDdl, } from './query.js';
20
+ import { applyMigrationSql } from './sql.js';
21
+ /** Same grammar the server compiles (§3.1): `prefix:{variable}`. */
22
+ const PATTERN_RE = /^([^{}]+):\{([^{}:]+)\}$/;
23
+ /** Whole-value placeholder: `{paramName}`. */
24
+ const PARAM_RE = /^\{([A-Za-z_$][A-Za-z0-9_$]*)\}$/;
25
+ function resolveScope(table, spec) {
26
+ const pattern = typeof spec === 'string' ? spec : spec.pattern;
27
+ const match = PATTERN_RE.exec(pattern);
28
+ if (match === null || match[1] === undefined || match[2] === undefined) {
29
+ throw new TypegenError(MANIFEST_FILENAME, `table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}' with exactly one variable (§3.1)`);
30
+ }
31
+ const variable = match[2];
32
+ const column = typeof spec === 'string' ? variable : spec.column;
33
+ if (!table.columns.some((c) => c.name === column)) {
34
+ throw new TypegenError(MANIFEST_FILENAME, `table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
35
+ }
36
+ return { pattern, variable, column };
37
+ }
38
+ function buildTable(manifestTable, parsed) {
39
+ const scopes = manifestTable.scopes.map((spec) => resolveScope(parsed, spec));
40
+ for (const scope of scopes) {
41
+ const clash = scopes.find((s) => s.variable === scope.variable && s.column !== scope.column);
42
+ if (clash !== undefined) {
43
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: variable ${JSON.stringify(scope.variable)} maps to two different columns (§3.1)`);
44
+ }
45
+ }
46
+ const columns = applyEncryption(parsed, scopes, manifestTable.encryptedColumns);
47
+ return {
48
+ name: parsed.name,
49
+ primaryKey: parsed.primaryKey,
50
+ columns,
51
+ scopes,
52
+ // Indexes flow through from the migration parser (already validated:
53
+ // columns exist, names unique per schema) in declaration order.
54
+ indexes: parsed.indexes,
55
+ extensions: canonicalizeExtensions(manifestTable.extensions),
56
+ };
40
57
  }
41
58
  /**
42
- * Generate TypeScript types from migrations.
43
- *
44
- * @example
45
- * ```typescript
46
- * import { generateTypes } from '@syncular/typegen';
47
- * import { migrations } from './migrations';
48
- *
49
- * await generateTypes({
50
- * migrations,
51
- * output: './src/db.generated.ts',
52
- * });
53
- * ```
59
+ * §5.11: flip each `encryptedColumns` entry to wire type `bytes`, recording
60
+ * `encrypted` + `declaredType`. Enforces the hard generate-time errors:
61
+ * encrypted scope column, encrypted crdt column, encrypted primary key, and
62
+ * an unknown/already-bytes column.
54
63
  */
55
- export async function generateTypes(options) {
56
- const { migrations, output, includeVersionHistory, tables, dialect = 'sqlite', codecs, } = options;
57
- // Introspect schemas (raw SQL types, no TS mapping yet)
58
- let rawSchemas;
59
- if (includeVersionHistory) {
60
- rawSchemas = await introspectAllVersions(migrations, dialect, tables);
61
- }
62
- else {
63
- const current = await introspectCurrentSchema(migrations, dialect, tables);
64
- rawSchemas = [current];
65
- }
66
- // Apply type mapping (default + column codec overrides)
67
- const { schemas, customImports } = applyTypeMappings(rawSchemas, dialect, codecs);
68
- // Render TypeScript code
69
- const code = renderTypes({
70
- schemas,
71
- includeVersionHistory,
72
- customImports,
64
+ function applyEncryption(parsed, scopes, encryptedColumns) {
65
+ if (encryptedColumns.length === 0)
66
+ return parsed.columns;
67
+ const encrypted = new Set(encryptedColumns);
68
+ const scopeColumns = new Set(scopes.map((s) => s.column));
69
+ for (const name of encrypted) {
70
+ const column = parsed.columns.find((c) => c.name === name);
71
+ if (column === undefined) {
72
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: encryptedColumns names unknown column ${JSON.stringify(name)}`);
73
+ }
74
+ if (name === parsed.primaryKey) {
75
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: primary key ${JSON.stringify(name)} cannot be encrypted — it renders the plaintext rowId (§2.2, §5.11)`);
76
+ }
77
+ if (scopeColumns.has(name)) {
78
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: scope column ${JSON.stringify(name)} cannot be encrypted — scopes are extracted server-side and MUST stay plaintext (§3.1, §5.11)`);
79
+ }
80
+ if (column.type === 'crdt') {
81
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: crdt column ${JSON.stringify(name)} cannot be encrypted — the server merges crdt bytes plaintext (§5.10.3, §5.11)`);
82
+ }
83
+ }
84
+ return parsed.columns.map((column) => {
85
+ if (!encrypted.has(column.name))
86
+ return column;
87
+ // Flip wire type to bytes; keep the declared type for app-side emitters.
88
+ return {
89
+ name: column.name,
90
+ type: 'bytes',
91
+ nullable: column.nullable,
92
+ encrypted: true,
93
+ declaredType: column.type,
94
+ };
73
95
  });
74
- // Ensure output directory exists
75
- await mkdir(dirname(output), { recursive: true });
76
- // Write the file
77
- await writeFile(output, code, 'utf-8');
78
- const latestSchema = schemas[schemas.length - 1];
96
+ }
97
+ function buildSubscription(sub, tables) {
98
+ const table = tables.find((t) => t.name === sub.table);
99
+ if (table === undefined) {
100
+ throw new TypegenError(MANIFEST_FILENAME, `subscription ${sub.name}: unknown table ${JSON.stringify(sub.table)}`);
101
+ }
102
+ const declared = new Set(table.scopes.map((s) => s.variable));
103
+ const scopes = Object.entries(sub.scopes)
104
+ .map(([variable, values]) => {
105
+ if (!declared.has(variable)) {
106
+ throw new TypegenError(MANIFEST_FILENAME, `subscription ${sub.name}: ${JSON.stringify(variable)} is not a scope variable of table ${sub.table} (declared: ${[...declared].join(', ')})`);
107
+ }
108
+ const irValues = values.map((value) => {
109
+ const param = PARAM_RE.exec(value);
110
+ if (param?.[1] !== undefined) {
111
+ return { kind: 'parameter', name: param[1] };
112
+ }
113
+ if (value.includes('{') || value.includes('}')) {
114
+ throw new TypegenError(MANIFEST_FILENAME, `subscription ${sub.name}: scope value ${JSON.stringify(value)} mixes literals and placeholders — a value is either a literal or exactly '{param}'`);
115
+ }
116
+ if (value === '*') {
117
+ throw new TypegenError(MANIFEST_FILENAME, `subscription ${sub.name}: '*' is rejected in requested scopes (§3.2)`);
118
+ }
119
+ return { kind: 'literal', value };
120
+ });
121
+ return { variable, values: irValues };
122
+ })
123
+ .sort((a, b) => (a.variable < b.variable ? -1 : 1));
124
+ return { name: sub.name, table: sub.table, scopes };
125
+ }
126
+ function buildSchemaVersions(manifest, migrations) {
127
+ const names = migrations.map((m) => m.name);
128
+ let cursor = 0;
129
+ const out = manifest.schemaVersions.map((entry) => {
130
+ const index = names.indexOf(entry.through, cursor);
131
+ if (index === -1) {
132
+ throw new TypegenError(MANIFEST_FILENAME, `schemaVersions: version ${entry.version} names migration ${JSON.stringify(entry.through)}, which does not exist after ${JSON.stringify(names[cursor - 1] ?? '(start)')} (migrations: ${names.join(', ')})`);
133
+ }
134
+ const covered = names.slice(cursor, index + 1);
135
+ cursor = index + 1;
136
+ return { version: entry.version, migrations: covered };
137
+ });
138
+ if (cursor !== names.length) {
139
+ throw new TypegenError(MANIFEST_FILENAME, `schemaVersions: migrations ${names.slice(cursor).join(', ')} are not covered by any schema version — the last entry's "through" must be the last migration`);
140
+ }
141
+ return out;
142
+ }
143
+ /** Pure IR construction — the testable heart of the generator. */
144
+ export function buildIr(manifest, migrations) {
145
+ if (migrations.length === 0) {
146
+ throw new TypegenError(MANIFEST_FILENAME, 'no migrations found');
147
+ }
148
+ const parsedTables = new Map();
149
+ for (const migration of migrations) {
150
+ applyMigrationSql(parsedTables, migration.sql, `${migration.name}/up.sql`);
151
+ }
152
+ const schemaVersions = buildSchemaVersions(manifest, migrations);
153
+ const tables = manifest.tables.map((manifestTable) => {
154
+ const parsed = parsedTables.get(manifestTable.name);
155
+ if (parsed === undefined) {
156
+ throw new TypegenError(MANIFEST_FILENAME, `table ${JSON.stringify(manifestTable.name)} is not created by any migration`);
157
+ }
158
+ return buildTable(manifestTable, parsed);
159
+ });
160
+ const listed = new Set(manifest.tables.map((t) => t.name));
161
+ for (const name of parsedTables.keys()) {
162
+ if (!listed.has(name)) {
163
+ throw new TypegenError(MANIFEST_FILENAME, `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)`);
164
+ }
165
+ }
166
+ const subscriptions = manifest.subscriptions.map((sub) => buildSubscription(sub, tables));
167
+ const last = manifest.schemaVersions[manifest.schemaVersions.length - 1];
168
+ if (last === undefined)
169
+ throw new Error('unreachable: schemaVersions');
79
170
  return {
80
- outputPath: output,
81
- currentVersion: migrations.currentVersion,
82
- tableCount: latestSchema?.tables.length ?? 0,
83
- code,
171
+ irVersion: IR_VERSION,
172
+ schemaVersion: last.version,
173
+ schemaVersions,
174
+ tables,
175
+ subscriptions,
176
+ extensions: canonicalizeExtensions(manifest.extensions),
84
177
  };
85
178
  }
86
- //# sourceMappingURL=generate.js.map
179
+ const MIGRATION_DIR_RE = /^(\d+)_[A-Za-z0-9_-]+$/;
180
+ /** Load `NNNN_name/up.sql` migrations, ordered by numeric prefix. */
181
+ export function loadMigrations(migrationsDir) {
182
+ if (!existsSync(migrationsDir)) {
183
+ throw new TypegenError(migrationsDir, 'migrations directory does not exist');
184
+ }
185
+ const entries = readdirSync(migrationsDir, { withFileTypes: true })
186
+ .filter((entry) => entry.isDirectory())
187
+ .map((entry) => entry.name);
188
+ const parsed = entries.map((name) => {
189
+ const match = MIGRATION_DIR_RE.exec(name);
190
+ if (match === null || match[1] === undefined) {
191
+ throw new TypegenError(migrationsDir, `migration directory ${JSON.stringify(name)} does not match NNNN_name`);
192
+ }
193
+ return { name, order: Number.parseInt(match[1], 10) };
194
+ });
195
+ parsed.sort((a, b) => a.order - b.order);
196
+ for (let i = 1; i < parsed.length; i++) {
197
+ const prev = parsed[i - 1];
198
+ const next = parsed[i];
199
+ if (prev !== undefined && next !== undefined && prev.order === next.order) {
200
+ throw new TypegenError(migrationsDir, `migrations ${prev.name} and ${next.name} share the ordinal ${next.order}`);
201
+ }
202
+ }
203
+ return parsed.map(({ name }) => {
204
+ const upPath = join(migrationsDir, name, 'up.sql');
205
+ if (!existsSync(upPath)) {
206
+ throw new TypegenError(join(name, 'up.sql'), 'missing migration file');
207
+ }
208
+ return { name, sql: readFileSync(upPath, 'utf8') };
209
+ });
210
+ }
211
+ /** Recursively collect `*.sql` paths under `dir`, relative to `root`
212
+ * (forward-slashed), sorted for deterministic emission. */
213
+ function collectSqlFiles(root, dir) {
214
+ const out = [];
215
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
216
+ const abs = join(dir, entry.name);
217
+ if (entry.isDirectory()) {
218
+ out.push(...collectSqlFiles(root, abs));
219
+ }
220
+ else if (entry.isFile() && entry.name.endsWith('.sql')) {
221
+ out.push(relative(root, abs).split(sep).join('/'));
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+ /** Load `queries/**\/*.sql` recursively, ordered by relative path
227
+ * (deterministic emission). Folders are pure organization. */
228
+ export function loadQueries(queriesDir) {
229
+ if (!existsSync(queriesDir))
230
+ return [];
231
+ return collectSqlFiles(queriesDir, queriesDir)
232
+ .sort()
233
+ .map((file) => ({
234
+ file,
235
+ sql: readFileSync(join(queriesDir, file), 'utf8'),
236
+ }));
237
+ }
238
+ /** A {@link QueryDb} backed by an in-memory bun:sqlite built from the IR DDL.
239
+ * `analyze` prepares the statement (validating references) and reads its
240
+ * column names + declared types; it executes once against the empty DB so
241
+ * `declaredTypes` (decltype) is populated. */
242
+ function makeQueryDb(ir) {
243
+ const sqlite = new Database(':memory:');
244
+ sqlite.run(synthesizeDdl(ir));
245
+ const db = {
246
+ analyze(sql) {
247
+ const stmt = sqlite.prepare(sql);
248
+ try {
249
+ const columnNames = stmt.columnNames;
250
+ // Execute once against the empty DB to populate decltype; result is [].
251
+ stmt.all();
252
+ const declaredTypes = stmt.declaredTypes;
253
+ const paramsCount = stmt
254
+ .paramsCount;
255
+ return { columnNames, declaredTypes, paramsCount };
256
+ }
257
+ finally {
258
+ stmt.finalize();
259
+ }
260
+ },
261
+ };
262
+ return { db, close: () => sqlite.close() };
263
+ }
264
+ /** Analyze every query file against the IR (SELECT-only, typed, deps). */
265
+ export function analyzeQueries(ir, queries) {
266
+ if (queries.length === 0)
267
+ return [];
268
+ const { db, close } = makeQueryDb(ir);
269
+ try {
270
+ // Each file may hold multiple statements; flatten in path order (stable).
271
+ const analyzed = queries.flatMap((q) => analyzeQueryFile(q.file, q.sql, ir, db));
272
+ // Names must be unique across the whole manifest — the filesystem no longer
273
+ // guarantees uniqueness once `-- name:` overrides exist. Report BOTH source
274
+ // locations (file + statement position) on a collision.
275
+ const seen = new Map();
276
+ for (const q of analyzed) {
277
+ const prev = seen.get(q.name);
278
+ if (prev !== undefined) {
279
+ throw new TypegenError(q.file, `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).`);
280
+ }
281
+ seen.set(q.name, q.file);
282
+ }
283
+ return analyzed;
284
+ }
285
+ finally {
286
+ close();
287
+ }
288
+ }
289
+ /** Read `syncular.json` + migrations under `manifestDir`; build outputs. */
290
+ export function generate(manifestDir) {
291
+ const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
292
+ if (!existsSync(manifestPath)) {
293
+ throw new TypegenError(manifestPath, 'manifest not found');
294
+ }
295
+ let raw;
296
+ try {
297
+ raw = JSON.parse(readFileSync(manifestPath, 'utf8'));
298
+ }
299
+ catch (error) {
300
+ throw new TypegenError(manifestPath, `invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
301
+ }
302
+ const manifest = parseManifest(raw);
303
+ const migrations = loadMigrations(resolve(manifestDir, manifest.migrations));
304
+ const ir = buildIr(manifest, migrations);
305
+ const irJson = serializeIr(ir);
306
+ const hash = irHash(irJson);
307
+ const module = emitModule(ir, hash);
308
+ const irPath = resolve(manifestDir, manifest.output.ir);
309
+ const modulePath = resolve(manifestDir, manifest.output.module);
310
+ const outputs = [
311
+ { path: irPath, content: irJson },
312
+ { path: modulePath, content: module },
313
+ ];
314
+ // Opt-in native emitters — each present only when the manifest requests it.
315
+ const { queries: tsQueriesPath, swift, kotlin, dart } = manifest.output;
316
+ // Named queries: analyzed once (SELECT-only, typed against the IR via
317
+ // SQLite), then emitted per-language into its OWN file so schema-only
318
+ // consumers never churn. Only analyzed when SOME query output is requested.
319
+ const wantsQueries = tsQueriesPath !== undefined ||
320
+ swift?.queriesPath !== undefined ||
321
+ kotlin?.queriesPath !== undefined ||
322
+ dart?.queriesPath !== undefined;
323
+ const analyzedQueries = wantsQueries
324
+ ? analyzeQueries(ir, loadQueries(resolve(manifestDir, manifest.queries)))
325
+ : [];
326
+ if (wantsQueries && analyzedQueries.length === 0) {
327
+ throw new TypegenError(resolve(manifestDir, manifest.queries), `an output requests named queries but no .sql files were found in ${manifest.queries} — add a query file or drop the queries output`);
328
+ }
329
+ if (tsQueriesPath !== undefined) {
330
+ outputs.push({
331
+ path: resolve(manifestDir, tsQueriesPath),
332
+ content: emitQueriesModule(analyzedQueries, hash, ir.irVersion),
333
+ });
334
+ }
335
+ if (swift !== undefined) {
336
+ outputs.push({
337
+ path: resolve(manifestDir, swift.path),
338
+ content: emitSwiftModule(ir, hash, swift.enumName),
339
+ });
340
+ if (swift.queriesPath !== undefined) {
341
+ outputs.push({
342
+ path: resolve(manifestDir, swift.queriesPath),
343
+ content: emitQueriesSwiftModule(analyzedQueries, hash, ir.irVersion, swift.enumName),
344
+ });
345
+ }
346
+ }
347
+ if (kotlin !== undefined) {
348
+ outputs.push({
349
+ path: resolve(manifestDir, kotlin.path),
350
+ content: emitKotlinModule(ir, hash, kotlin.package, kotlin.objectName),
351
+ });
352
+ if (kotlin.queriesPath !== undefined) {
353
+ outputs.push({
354
+ path: resolve(manifestDir, kotlin.queriesPath),
355
+ content: emitQueriesKotlinModule(analyzedQueries, hash, ir.irVersion, kotlin.package, kotlin.objectName),
356
+ });
357
+ }
358
+ }
359
+ if (dart !== undefined) {
360
+ outputs.push({
361
+ path: resolve(manifestDir, dart.path),
362
+ content: emitDartModule(ir, hash),
363
+ });
364
+ if (dart.queriesPath !== undefined) {
365
+ outputs.push({
366
+ path: resolve(manifestDir, dart.queriesPath),
367
+ content: emitQueriesDartModule(analyzedQueries, hash, ir.irVersion),
368
+ });
369
+ }
370
+ }
371
+ return {
372
+ ir,
373
+ irJson,
374
+ hash,
375
+ module,
376
+ irPath,
377
+ modulePath,
378
+ queries: analyzedQueries,
379
+ outputs,
380
+ };
381
+ }
382
+ export function writeOutputs(result) {
383
+ for (const { path, content } of result.outputs) {
384
+ mkdirSync(dirname(path), { recursive: true });
385
+ writeFileSync(path, content, 'utf8');
386
+ }
387
+ }
388
+ /** Byte-exact freshness check; returns human-readable drift descriptions. */
389
+ export function checkOutputs(result) {
390
+ const drift = [];
391
+ for (const { path, content } of result.outputs) {
392
+ if (!existsSync(path)) {
393
+ drift.push(`${path}: missing — run generate`);
394
+ }
395
+ else if (readFileSync(path, 'utf8') !== content) {
396
+ drift.push(`${path}: stale — run generate`);
397
+ }
398
+ }
399
+ return drift;
400
+ }
package/dist/index.d.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  /**
2
- * @syncular/typegen - Generate TypeScript types from migrations
3
- *
4
- * Creates type definitions by:
5
- * 1. Applying migrations to an in-memory database (SQLite or PostgreSQL)
6
- * 2. Introspecting the resulting schema
7
- * 3. Generating TypeScript interfaces
2
+ * @syncular/typegen SQL migrations + syncular.json → neutral schema
3
+ * IR (JSON) → generated TS module (REVISE B5). Dependency-free; the CLI
4
+ * lives in `src/cli.ts` (bin `syncular`).
8
5
  */
9
- export * from './app-contract';
10
- export * from './checksums';
11
- export * from './generate';
12
- export * from './introspect';
13
- export * from './map-types';
14
- export * from './render';
15
- export * from './types';
16
- //# sourceMappingURL=index.d.ts.map
6
+ export * from './emit.js';
7
+ export * from './emit-dart.js';
8
+ export * from './emit-kotlin.js';
9
+ export * from './emit-queries.js';
10
+ export * from './emit-queries-dart.js';
11
+ export * from './emit-queries-kotlin.js';
12
+ export * from './emit-queries-swift.js';
13
+ export * from './emit-swift.js';
14
+ export * from './errors.js';
15
+ export * from './generate.js';
16
+ export * from './ir.js';
17
+ export * from './manifest.js';
18
+ export * from './query.js';
19
+ export * from './sql.js';
package/dist/index.js CHANGED
@@ -1,16 +1,19 @@
1
1
  /**
2
- * @syncular/typegen - Generate TypeScript types from migrations
3
- *
4
- * Creates type definitions by:
5
- * 1. Applying migrations to an in-memory database (SQLite or PostgreSQL)
6
- * 2. Introspecting the resulting schema
7
- * 3. Generating TypeScript interfaces
2
+ * @syncular/typegen SQL migrations + syncular.json → neutral schema
3
+ * IR (JSON) → generated TS module (REVISE B5). Dependency-free; the CLI
4
+ * lives in `src/cli.ts` (bin `syncular`).
8
5
  */
9
- export * from './app-contract.js';
10
- export * from './checksums.js';
6
+ export * from './emit.js';
7
+ export * from './emit-dart.js';
8
+ export * from './emit-kotlin.js';
9
+ export * from './emit-queries.js';
10
+ export * from './emit-queries-dart.js';
11
+ export * from './emit-queries-kotlin.js';
12
+ export * from './emit-queries-swift.js';
13
+ export * from './emit-swift.js';
14
+ export * from './errors.js';
11
15
  export * from './generate.js';
12
- export * from './introspect.js';
13
- export * from './map-types.js';
14
- export * from './render.js';
15
- export * from './types.js';
16
- //# sourceMappingURL=index.js.map
16
+ export * from './ir.js';
17
+ export * from './manifest.js';
18
+ export * from './query.js';
19
+ export * from './sql.js';
package/dist/init.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export interface InitResult {
2
+ readonly written: readonly string[];
3
+ }
4
+ /** Errors that carry a friendly, actionable message for the CLI. */
5
+ export declare class InitError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /**
9
+ * Create the starter files under `dir`. Refuses to overwrite an existing
10
+ * manifest or `migrations/0001_initial/up.sql`.
11
+ */
12
+ export declare function initProject(dir: string): InitResult;