@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
package/dist/query.js ADDED
@@ -0,0 +1,719 @@
1
+ /**
2
+ * Named-query analysis (the sqlc/SQLDelight tier).
3
+ *
4
+ * A `queries/` dir next to migrations is walked RECURSIVELY for `.sql` files;
5
+ * folders are pure organization. A statement's default name is its path
6
+ * relative to the queries root — segments + filename, joined and camelCased
7
+ * (`billing/invoices/list.sql` → `billingInvoicesList`; a flat `list-todos.sql`
8
+ * → `listTodos`, unchanged). A `-- name: ident` marker in a statement's leading
9
+ * comment block overrides the FULL name verbatim. A file may hold MULTIPLE
10
+ * statements (split on top-level `;`); a single-statement file may omit
11
+ * `-- name:` (path-derived), a multi-statement file requires it on EVERY
12
+ * statement. SELECT-only — writes stay `mutate()` (SPEC §7.1). Each query is
13
+ * type-checked BY SQLITE
14
+ * ITSELF: we synthesize the schema's DDL from the IR (the reverse of the SQL
15
+ * parser), build an in-memory `bun:sqlite` DB, and `prepare()` the query. That
16
+ * validates syntax + every table/column reference for free, and yields the
17
+ * result column names + their declared SQLite types (`declaredTypes` =
18
+ * `sqlite3_column_decltype`).
19
+ *
20
+ * ## Typing fidelity (the honest boundary — what bun:sqlite actually exposes)
21
+ *
22
+ * bun:sqlite exposes, per prepared statement: `columnNames`, `declaredTypes`
23
+ * (decltype, once executed once — we `.all()` against the empty DB), and
24
+ * `paramsCount`. It does NOT expose column ORIGIN (table/column) nor param
25
+ * NAMES nor NOT-NULL flags. So:
26
+ *
27
+ * - **Plain column ref** (`title`, `t.title`, `title AS x`): decltype is the
28
+ * origin column's exact declared type. We map it back through the SAME
29
+ * TYPE_MAP the migration parser uses → the exact IR column type. For
30
+ * NULLABILITY we resolve the ref against the IR ourselves (parse the SELECT
31
+ * list + FROM/JOIN, match `name`/`alias.name` to an IR table column) — an
32
+ * IR-exact non-nullable/nullable answer. This is the drift-kill: same type
33
+ * AND nullability as the schema, guaranteed by SQLite's own reference check.
34
+ * - **Computed expression** (`count(*)`, `done + 1`, `:label AS l`): decltype
35
+ * is null. We fall back to a documented honest type from the raw column
36
+ * name/shape: aggregate/arith → nullable number, anything else → nullable
37
+ * string. Marked `nullable: true` always (an expression's nullability is not
38
+ * knowable from decltype). Callers who want an exact type give the column an
39
+ * alias whose ref is plain, or accept the honest fallback.
40
+ *
41
+ * ## Parameters
42
+ *
43
+ * Named `:name` placeholders. Types are inferred where the param compares
44
+ * against a plain column ref (`WHERE list_id = :listId` / `col IN (:a, :b)`) —
45
+ * the param takes that column's IR type. Ambiguous params (compared to an
46
+ * expression, used only in a projection, etc.) require a
47
+ * `-- param :name <type>` header comment; missing both is a generate-time
48
+ * error listing the fix. bun:sqlite can't name params, so we parse `:name`
49
+ * tokens from the SQL ourselves (first-occurrence order = positional order).
50
+ *
51
+ * ## The tables set (for useSyncQuery `{tables}` — exact invalidation)
52
+ *
53
+ * bun:sqlite exposes no authorizer and no statement table-list; EXPLAIN
54
+ * opcodes are fragile. The honest mechanism: the FROM/JOIN table set we
55
+ * already resolve for column typing, VALIDATED against the IR by the
56
+ * SQLite prepare() (an unknown table would have thrown). Boundary: tables
57
+ * referenced ONLY inside a subquery/`WHERE EXISTS (...)` are still captured
58
+ * because we scan every `FROM`/`JOIN` identifier in the whole statement and
59
+ * keep those that name an IR table; a table that appears under neither keyword
60
+ * (impossible for a real reference) is not captured. Conservative + correct
61
+ * for the query shapes this tier supports.
62
+ */
63
+ import { TypegenError } from './errors.js';
64
+ /** The SQL decltype keyword → §2.4 type map (mirrors sql.ts TYPE_MAP so a
65
+ * plain column ref's decltype resolves to the exact IR type). */
66
+ const DECLTYPE_MAP = {
67
+ TEXT: 'string',
68
+ INTEGER: 'integer',
69
+ INT: 'integer',
70
+ BIGINT: 'integer',
71
+ SMALLINT: 'integer',
72
+ REAL: 'float',
73
+ FLOAT: 'float',
74
+ DOUBLE: 'float',
75
+ BOOLEAN: 'boolean',
76
+ BOOL: 'boolean',
77
+ JSON: 'json',
78
+ JSONB: 'json',
79
+ BLOB: 'bytes',
80
+ BYTEA: 'bytes',
81
+ BLOB_REF: 'blob_ref',
82
+ BLOBREF: 'blob_ref',
83
+ CRDT: 'crdt',
84
+ };
85
+ // -- path → name --------------------------------------------------------------
86
+ /** A single kebab-case path segment (folder) or filename stem. */
87
+ const KEBAB_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
88
+ /** A valid camelCase identifier — what a `-- name:` override must be. */
89
+ const CAMEL_IDENT_RE = /^[a-z][A-Za-z0-9]*$/;
90
+ /** Split a kebab word list into a camelCase suffix (used per segment). */
91
+ function kebabWords(segment) {
92
+ return segment.split('-');
93
+ }
94
+ /**
95
+ * Path-relative default name. `billing/invoices/list.sql` → `billingInvoicesList`;
96
+ * a flat `list-todos.sql` → `listTodos` (unchanged). Every folder segment AND
97
+ * the filename stem must be lowercase kebab-case — rejects loudly, naming the
98
+ * offending segment, so a stray `List.sql` or `Billing/` fails at generate time.
99
+ */
100
+ export function queryNameFromPath(relPath) {
101
+ const norm = relPath.replace(/\\/g, '/');
102
+ if (!norm.endsWith('.sql')) {
103
+ throw new TypegenError(relPath, `query file must end in .sql, got ${JSON.stringify(relPath)}`);
104
+ }
105
+ const withoutExt = norm.slice(0, -'.sql'.length);
106
+ const segments = withoutExt.split('/').filter((s) => s.length > 0);
107
+ if (segments.length === 0) {
108
+ throw new TypegenError(relPath, `invalid query path ${JSON.stringify(relPath)}`);
109
+ }
110
+ const words = [];
111
+ for (const segment of segments) {
112
+ if (!KEBAB_SEGMENT_RE.test(segment)) {
113
+ throw new TypegenError(relPath, `path segment ${JSON.stringify(segment)} in ${JSON.stringify(norm)} must be lowercase kebab-case (e.g. billing/invoices/list-open.sql → billingInvoicesListOpen)`);
114
+ }
115
+ words.push(...kebabWords(segment));
116
+ }
117
+ const first = words[0];
118
+ return (first +
119
+ words
120
+ .slice(1)
121
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
122
+ .join(''));
123
+ }
124
+ /** Back-compat flat-file helper: `list-todos.sql` → `listTodos`. Rejects a
125
+ * path with folders (use {@link queryNameFromPath} for nested queries). */
126
+ export function queryNameFromFile(file) {
127
+ if (file.includes('/') || file.includes('\\')) {
128
+ throw new TypegenError(file, `queryNameFromFile takes a flat filename; got a path ${JSON.stringify(file)} — use queryNameFromPath`);
129
+ }
130
+ return queryNameFromPath(file);
131
+ }
132
+ /** Validate a `-- name:` override is a camelCase identifier (loud otherwise). */
133
+ export function validateOverrideName(name, location) {
134
+ if (!CAMEL_IDENT_RE.test(name)) {
135
+ throw new TypegenError(location, `-- name: ${JSON.stringify(name)} must be a camelCase identifier (start lowercase, letters/digits only, e.g. billingInvoicesList)`);
136
+ }
137
+ return name;
138
+ }
139
+ /** Split file content into statements on top-level `;` (string/comment-aware).
140
+ * Blank/comment-only tails are dropped; each statement keeps its own leading
141
+ * comment block (everything between the previous `;` and this SQL). */
142
+ export function splitStatements(content) {
143
+ const parts = [];
144
+ let i = 0;
145
+ let start = 0;
146
+ while (i < content.length) {
147
+ const ch = content[i];
148
+ const next = content[i + 1];
149
+ if (ch === '-' && next === '-') {
150
+ const end = content.indexOf('\n', i);
151
+ i = end === -1 ? content.length : end;
152
+ }
153
+ else if (ch === '/' && next === '*') {
154
+ const end = content.indexOf('*/', i + 2);
155
+ i = end === -1 ? content.length : end + 2;
156
+ }
157
+ else if (ch === "'") {
158
+ let j = i + 1;
159
+ for (;;) {
160
+ if (j >= content.length)
161
+ break;
162
+ if (content[j] === "'") {
163
+ if (content[j + 1] === "'")
164
+ j += 2;
165
+ else {
166
+ j += 1;
167
+ break;
168
+ }
169
+ }
170
+ else
171
+ j += 1;
172
+ }
173
+ i = j;
174
+ }
175
+ else if (ch === ';') {
176
+ parts.push({ text: content.slice(start, i), startOffset: start });
177
+ i += 1;
178
+ start = i;
179
+ }
180
+ else {
181
+ i += 1;
182
+ }
183
+ }
184
+ if (start < content.length) {
185
+ parts.push({ text: content.slice(start), startOffset: start });
186
+ }
187
+ const out = [];
188
+ for (const part of parts) {
189
+ // Drop parts whose SQL body (comments/whitespace stripped) is empty — a
190
+ // trailing `;` or a comment-only tail is not a statement.
191
+ if (stripCommentsAndStrings(part.text).trim().length === 0)
192
+ continue;
193
+ // Report the line of the statement's first NON-blank character (skip the
194
+ // blank/whitespace run after the previous `;`), so the location points at
195
+ // real content, not the terminator's trailing newline.
196
+ const lead = part.text.length - part.text.replace(/^\s+/, '').length;
197
+ const before = content.slice(0, part.startOffset + lead);
198
+ const startLine = before.split('\n').length;
199
+ out.push({ text: part.text.replace(/^\s+/, ''), startLine });
200
+ }
201
+ return out;
202
+ }
203
+ /** The leading comment block of a statement: the run of blank + line/block
204
+ * comment lines before the first SQL token. `-- name:` / `-- param` markers are
205
+ * scoped HERE (to the statement they directly precede), not file-wide. Returns
206
+ * the `--` comment lines (block comments carry no markers we parse). */
207
+ function leadingCommentBlock(statementText) {
208
+ const lines = [];
209
+ let i = 0;
210
+ while (i < statementText.length) {
211
+ // Skip leading whitespace on the line.
212
+ while (i < statementText.length &&
213
+ /[ \t\r]/.test(statementText[i])) {
214
+ i += 1;
215
+ }
216
+ const ch = statementText[i];
217
+ const next = statementText[i + 1];
218
+ if (ch === '\n') {
219
+ i += 1;
220
+ continue;
221
+ }
222
+ if (ch === '-' && next === '-') {
223
+ const end = statementText.indexOf('\n', i);
224
+ const line = statementText.slice(i, end === -1 ? undefined : end);
225
+ lines.push(line);
226
+ i = end === -1 ? statementText.length : end + 1;
227
+ continue;
228
+ }
229
+ if (ch === '/' && next === '*') {
230
+ const end = statementText.indexOf('*/', i + 2);
231
+ // Block comments carry no markers we parse; skip and keep scanning for
232
+ // more leading comments (but they don't contribute -- name:/-- param).
233
+ i = end === -1 ? statementText.length : end + 2;
234
+ continue;
235
+ }
236
+ break; // first SQL token — the comment block ends here
237
+ }
238
+ return lines;
239
+ }
240
+ const NAME_MARKER_RE = /^\s*--\s*name:\s*(\S+)\s*$/;
241
+ /** The `-- name:` override in a statement's leading comment block, or null. */
242
+ export function parseStatementName(statementText, location) {
243
+ const lines = leadingCommentBlock(statementText);
244
+ let found = null;
245
+ for (const line of lines) {
246
+ const m = NAME_MARKER_RE.exec(line);
247
+ if (m === null)
248
+ continue;
249
+ if (found !== null) {
250
+ throw new TypegenError(location, `two \`-- name:\` markers on one statement (${JSON.stringify(found)} and ${JSON.stringify(m[1])}) — a statement is named once`);
251
+ }
252
+ found = validateOverrideName(m[1], location);
253
+ }
254
+ return found;
255
+ }
256
+ const PARAM_COMMENT_RE = /^\s*--\s*param\s+:([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_]+)\s*$/;
257
+ const PARAM_TYPES = new Set([
258
+ 'string',
259
+ 'integer',
260
+ 'float',
261
+ 'boolean',
262
+ 'json',
263
+ 'bytes',
264
+ 'blob_ref',
265
+ 'crdt',
266
+ ]);
267
+ /** Parse `-- param :name <type>` header lines. They must precede the SQL. */
268
+ function parseCommentParams(file, raw) {
269
+ const out = [];
270
+ for (const line of raw.split('\n')) {
271
+ const trimmed = line.trim();
272
+ if (trimmed.length === 0)
273
+ continue;
274
+ if (!trimmed.startsWith('--'))
275
+ break; // first non-comment line ends the header
276
+ const match = PARAM_COMMENT_RE.exec(line);
277
+ if (match === null)
278
+ continue; // an ordinary comment, ignore
279
+ const name = match[1];
280
+ const type = match[2].toLowerCase();
281
+ if (!PARAM_TYPES.has(type)) {
282
+ throw new TypegenError(file, `-- param :${name}: unknown type ${JSON.stringify(match[2])} (use one of ${[...PARAM_TYPES].join(', ')})`);
283
+ }
284
+ out.push({ name, type: type });
285
+ }
286
+ return out;
287
+ }
288
+ /** Strip `--` and block comments so identifier scans don't hit commented SQL,
289
+ * and string literals so `':x'` inside a string isn't read as a param. */
290
+ function stripCommentsAndStrings(sql) {
291
+ let out = '';
292
+ let i = 0;
293
+ while (i < sql.length) {
294
+ const ch = sql[i];
295
+ const next = sql[i + 1];
296
+ if (ch === '-' && next === '-') {
297
+ const end = sql.indexOf('\n', i);
298
+ i = end === -1 ? sql.length : end;
299
+ }
300
+ else if (ch === '/' && next === '*') {
301
+ const end = sql.indexOf('*/', i + 2);
302
+ i = end === -1 ? sql.length : end + 2;
303
+ out += ' ';
304
+ }
305
+ else if (ch === "'") {
306
+ const end = sql.indexOf("'", i + 1);
307
+ i = end === -1 ? sql.length : end + 1;
308
+ out += ' ';
309
+ }
310
+ else {
311
+ out += ch;
312
+ i += 1;
313
+ }
314
+ }
315
+ return out;
316
+ }
317
+ /** Distinct `:name` placeholders in first-occurrence order (dedup by name,
318
+ * matching SQLite's paramsCount which counts distinct names). */
319
+ function scanParamNames(sql) {
320
+ const cleaned = stripCommentsAndStrings(sql);
321
+ // `:name` but not `::` (Postgres cast) — our subset never has `::`.
322
+ const re = /:([A-Za-z_][A-Za-z0-9_]*)/g;
323
+ const seen = [];
324
+ for (const m of cleaned.matchAll(re)) {
325
+ const name = m[1];
326
+ if (!seen.includes(name))
327
+ seen.push(name);
328
+ }
329
+ return seen;
330
+ }
331
+ /** Rewrite `:name` → positional `?` (repeats included) AND strip comments +
332
+ * collapse whitespace, producing a clean single-line SQL string suitable for
333
+ * embedding in a generated string literal. String literals are preserved
334
+ * verbatim (their inner whitespace is not collapsed). */
335
+ function toPositionalSql(sql) {
336
+ const tokens = [];
337
+ let i = 0;
338
+ while (i < sql.length) {
339
+ const ch = sql[i];
340
+ const next = sql[i + 1];
341
+ if (ch === '-' && next === '-') {
342
+ const end = sql.indexOf('\n', i);
343
+ i = end === -1 ? sql.length : end + 1;
344
+ tokens.push(' ');
345
+ }
346
+ else if (ch === '/' && next === '*') {
347
+ const end = sql.indexOf('*/', i + 2);
348
+ i = end === -1 ? sql.length : end + 2;
349
+ tokens.push(' ');
350
+ }
351
+ else if (ch === "'") {
352
+ // Preserve string literals verbatim (including any '' escapes).
353
+ let j = i + 1;
354
+ let lit = "'";
355
+ for (;;) {
356
+ if (j >= sql.length)
357
+ break;
358
+ if (sql[j] === "'") {
359
+ if (sql[j + 1] === "'") {
360
+ lit += "''";
361
+ j += 2;
362
+ }
363
+ else {
364
+ lit += "'";
365
+ j += 1;
366
+ break;
367
+ }
368
+ }
369
+ else {
370
+ lit += sql[j];
371
+ j += 1;
372
+ }
373
+ }
374
+ tokens.push(lit);
375
+ i = j;
376
+ }
377
+ else if (ch === ':' && next !== undefined && /[A-Za-z_]/.test(next)) {
378
+ let end = i + 1;
379
+ while (end < sql.length && /[A-Za-z0-9_]/.test(sql[end])) {
380
+ end += 1;
381
+ }
382
+ tokens.push('?');
383
+ i = end;
384
+ }
385
+ else if (/\s/.test(ch)) {
386
+ tokens.push(' ');
387
+ i += 1;
388
+ }
389
+ else {
390
+ tokens.push(ch);
391
+ i += 1;
392
+ }
393
+ }
394
+ // Collapse runs of the whitespace placeholders; trim.
395
+ return tokens.join('').replace(/\s+/g, ' ').trim();
396
+ }
397
+ const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
398
+ // `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
399
+ // tables that exist in the IR; SQLite has already proven every reference.
400
+ const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`, 'gi');
401
+ const RESERVED_ALIAS = new Set([
402
+ 'on',
403
+ 'where',
404
+ 'group',
405
+ 'order',
406
+ 'inner',
407
+ 'left',
408
+ 'right',
409
+ 'outer',
410
+ 'join',
411
+ 'cross',
412
+ 'using',
413
+ 'limit',
414
+ 'having',
415
+ ]);
416
+ function scanTableRefs(sql, ir) {
417
+ const cleaned = stripCommentsAndStrings(sql);
418
+ const known = new Map(ir.tables.map((t) => [t.name, t]));
419
+ const refs = [];
420
+ for (const m of cleaned.matchAll(TABLE_REF_RE)) {
421
+ const table = m[1];
422
+ if (!known.has(table))
423
+ continue; // e.g. FROM (subquery) — table is `(`; skip
424
+ let alias = m[2];
425
+ if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
426
+ alias = undefined;
427
+ }
428
+ refs.push({ table, alias: alias ?? table });
429
+ }
430
+ return refs;
431
+ }
432
+ /** Split a SELECT list on top-level commas (parens-aware). Returns null when
433
+ * the query isn't a plain `SELECT … FROM …` we can split (e.g. `SELECT *`). */
434
+ function splitSelectList(sql) {
435
+ const cleaned = stripCommentsAndStrings(sql).replace(/\s+/g, ' ').trim();
436
+ const m = /^SELECT\s+(?:DISTINCT\s+)?(.*?)\sFROM\s/i.exec(cleaned);
437
+ if (m === null || m[1] === undefined)
438
+ return null;
439
+ const list = m[1];
440
+ if (list.trim() === '*' || /(^|,|\.)\s*\*/.test(list))
441
+ return null; // SELECT * / t.*
442
+ const items = [];
443
+ let depth = 0;
444
+ let start = 0;
445
+ for (let i = 0; i <= list.length; i++) {
446
+ const ch = list[i];
447
+ if (ch === '(')
448
+ depth += 1;
449
+ else if (ch === ')')
450
+ depth -= 1;
451
+ if ((ch === ',' && depth === 0) || i === list.length) {
452
+ const raw = list.slice(start, i).trim();
453
+ start = i + 1;
454
+ if (raw.length === 0)
455
+ continue;
456
+ // `expr AS alias` or `expr alias` (trailing bare identifier). Only treat
457
+ // a trailing word as an alias when the expr has more than that word.
458
+ const asMatch = /^(.*?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i.exec(raw);
459
+ if (asMatch !== null) {
460
+ items.push({ expr: asMatch[1].trim(), alias: asMatch[2] });
461
+ }
462
+ else {
463
+ items.push({ expr: raw, alias: undefined });
464
+ }
465
+ }
466
+ }
467
+ return items;
468
+ }
469
+ const PLAIN_REF_RE = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})$`);
470
+ /** Resolve a SELECT item's expression to an IR column, if it is a plain ref. */
471
+ function resolveSource(item, refs, ir) {
472
+ const m = PLAIN_REF_RE.exec(item.expr);
473
+ if (m === null)
474
+ return null;
475
+ const qualifier = m[1];
476
+ const columnName = m[2];
477
+ const byName = new Map(ir.tables.map((t) => [t.name, t]));
478
+ if (qualifier !== undefined) {
479
+ const ref = refs.find((r) => r.alias === qualifier);
480
+ if (ref === undefined)
481
+ return null;
482
+ const table = byName.get(ref.table);
483
+ const col = table?.columns.find((c) => c.name === columnName);
484
+ return col === undefined ? null : { column: col };
485
+ }
486
+ // Unqualified: search every FROM/JOIN table (SQLite already resolved
487
+ // ambiguity; a single-table query is the common case).
488
+ for (const ref of refs) {
489
+ const table = byName.get(ref.table);
490
+ const col = table?.columns.find((c) => c.name === columnName);
491
+ if (col !== undefined)
492
+ return { column: col };
493
+ }
494
+ return null;
495
+ }
496
+ // -- param type inference -----------------------------------------------------
497
+ //
498
+ // Infer a param's type from `col <op> :name` or `col IN (:name, …)` where `col`
499
+ // is a plain ref to an IR column. Equality/comparison/IN only — anything else
500
+ // needs the `-- param` comment (kept deliberately simple and honest).
501
+ function inferParamType(paramName, sql, refs, ir) {
502
+ const cleaned = stripCommentsAndStrings(sql);
503
+ const byName = new Map(ir.tables.map((t) => [t.name, t]));
504
+ const lookup = (qualifier, column) => {
505
+ if (qualifier !== undefined) {
506
+ const ref = refs.find((r) => r.alias === qualifier);
507
+ const table = ref && byName.get(ref.table);
508
+ return table?.columns.find((c) => c.name === column)?.type ?? null;
509
+ }
510
+ for (const ref of refs) {
511
+ const table = byName.get(ref.table);
512
+ const col = table?.columns.find((c) => c.name === column);
513
+ if (col !== undefined)
514
+ return col.type;
515
+ }
516
+ return null;
517
+ };
518
+ // `col = :name`, `col >= :name`, `col <> :name`, `col LIKE :name`, etc.
519
+ const cmp = new RegExp(`(?:(${IDENT})\\.)?(${IDENT})\\s*(?:=|==|!=|<>|<=|>=|<|>|\\bLIKE\\b|\\bIS\\b)\\s*:${paramName}\\b`, 'i');
520
+ const cmpM = cmp.exec(cleaned);
521
+ if (cmpM !== null) {
522
+ const t = lookup(cmpM[1], cmpM[2]);
523
+ if (t !== null)
524
+ return t;
525
+ }
526
+ // reversed: `:name = col`
527
+ const rev = new RegExp(`:${paramName}\\b\\s*(?:=|==|!=|<>|<=|>=|<|>)\\s*(?:(${IDENT})\\.)?(${IDENT})`, 'i');
528
+ const revM = rev.exec(cleaned);
529
+ if (revM !== null) {
530
+ const t = lookup(revM[1], revM[2]);
531
+ if (t !== null)
532
+ return t;
533
+ }
534
+ // `col IN (…, :name, …)` — find the nearest `col IN (` before the param.
535
+ const inRe = new RegExp(`(?:(${IDENT})\\.)?(${IDENT})\\s+IN\\s*\\(([^)]*:${paramName}\\b[^)]*)\\)`, 'i');
536
+ const inM = inRe.exec(cleaned);
537
+ if (inM !== null) {
538
+ const t = lookup(inM[1], inM[2]);
539
+ if (t !== null)
540
+ return t;
541
+ }
542
+ return null;
543
+ }
544
+ // -- fallback column typing (computed expressions) ----------------------------
545
+ const AGG_RE = /\b(count|sum|total|avg|min|max)\s*\(/i;
546
+ /** decltype-null column: the documented honest fallback. Aggregates and
547
+ * arithmetic → nullable number; everything else → nullable string. */
548
+ function fallbackColumnType(expr) {
549
+ if (AGG_RE.test(expr) || /[+\-*/]/.test(expr))
550
+ return 'float';
551
+ return 'string';
552
+ }
553
+ /** Synthesize the schema DDL from the IR (the reverse of the migration
554
+ * parser): a `CREATE TABLE` per IR table with each column's declared SQLite
555
+ * type + NOT NULL, so `prepare()` validates references and decltype resolves.
556
+ * This is exported so the generator can build one in-memory DB per run. */
557
+ export function synthesizeDdl(ir) {
558
+ const SQL_TYPE = {
559
+ string: 'TEXT',
560
+ integer: 'INTEGER',
561
+ float: 'REAL',
562
+ boolean: 'BOOLEAN',
563
+ json: 'JSON',
564
+ bytes: 'BLOB',
565
+ // blob_ref rides as TEXT; keep its distinct decltype so a plain ref types
566
+ // back to blob_ref (DECLTYPE_MAP knows BLOB_REF).
567
+ blob_ref: 'BLOB_REF',
568
+ crdt: 'CRDT',
569
+ };
570
+ const lines = [];
571
+ for (const table of ir.tables) {
572
+ const cols = table.columns.map((c) => {
573
+ const notNull = c.nullable ? '' : ' NOT NULL';
574
+ const pk = c.name === table.primaryKey ? ' PRIMARY KEY' : '';
575
+ return ` ${c.name} ${SQL_TYPE[c.type]}${pk}${notNull}`;
576
+ });
577
+ lines.push(`CREATE TABLE ${table.name} (\n${cols.join(',\n')}\n);`);
578
+ // Create the declared indexes too — harmless for prepare()/decltype, and
579
+ // keeps the type-check DB's shape honest with what the client materializes.
580
+ for (const index of table.indexes) {
581
+ const unique = index.unique ? 'UNIQUE ' : '';
582
+ lines.push(`CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`);
583
+ }
584
+ }
585
+ return lines.join('\n');
586
+ }
587
+ /**
588
+ * Analyze ONE already-split statement against the IR + a prepared-DDL DB. The
589
+ * name is pre-resolved (path-derived or a `-- name:` override) by the caller;
590
+ * `statementText` is a single statement's leading comment block + SQL (no
591
+ * top-level `;`). This is the per-statement core; {@link analyzeQueryFile}
592
+ * splits a file and drives it.
593
+ */
594
+ export function analyzeStatement(name, location, statementText, ir, db) {
595
+ const file = location;
596
+ const commentParams = parseCommentParams(file, statementText);
597
+ const sql = statementText.trim();
598
+ if (sql.length === 0) {
599
+ throw new TypegenError(file, 'query file is empty');
600
+ }
601
+ // SELECT-only (the read tier). Reject the first keyword loudly otherwise.
602
+ const firstKeyword = /^\s*([A-Za-z]+)/.exec(stripCommentsAndStrings(statementText).trimStart())?.[1];
603
+ if (firstKeyword === undefined || firstKeyword.toUpperCase() !== 'SELECT') {
604
+ throw new TypegenError(file, `named queries are SELECT-only (the read tier); found ${JSON.stringify(firstKeyword ?? '')}. Writes go through mutate() (SPEC §7.1).`);
605
+ }
606
+ // Let SQLite validate + describe the query. Any bad reference throws here
607
+ // with SQLite's own message (which names the offending table/column).
608
+ let described;
609
+ try {
610
+ described = db.analyze(sql);
611
+ }
612
+ catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ throw new TypegenError(file, `SQL rejected by SQLite: ${message}`);
615
+ }
616
+ const refs = scanTableRefs(sql, ir);
617
+ const tableSet = new Set(refs.map((r) => r.table));
618
+ const tables = [...tableSet].sort();
619
+ if (tables.length === 0) {
620
+ throw new TypegenError(file, 'query reads no synced table — every named query must read at least one IR table (for exact invalidation)');
621
+ }
622
+ // Columns: pair bun's column names + decltypes with our source resolution.
623
+ const items = splitSelectList(sql);
624
+ const columns = described.columnNames.map((colName, index) => {
625
+ const decl = described.declaredTypes[index];
626
+ const item = items?.[index];
627
+ const source = item !== undefined ? resolveSource(item, refs, ir) : null;
628
+ if (source !== null) {
629
+ // Exact: IR column type + IR nullability. (decltype agrees; we prefer
630
+ // the IR type so blob_ref/crdt/json semantic types survive.)
631
+ return {
632
+ name: colName,
633
+ type: source.column.type,
634
+ nullable: source.column.nullable,
635
+ fidelity: 'exact',
636
+ };
637
+ }
638
+ // Fallback: decltype affinity if present, else the expr-shape fallback.
639
+ const mapped = decl !== null && decl !== undefined
640
+ ? DECLTYPE_MAP[decl.toUpperCase()]
641
+ : undefined;
642
+ const type = mapped ?? (item !== undefined ? fallbackColumnType(item.expr) : 'string');
643
+ return { name: colName, type, nullable: true, fidelity: 'fallback' };
644
+ });
645
+ // Params: names from the text (positional order), types from inference or
646
+ // the `-- param` comment; every param must resolve.
647
+ const paramNames = scanParamNames(sql);
648
+ if (paramNames.length !== described.paramsCount) {
649
+ // Defensive: our scan and SQLite disagree (shouldn't happen for the subset).
650
+ throw new TypegenError(file, `internal: scanned ${paramNames.length} params (${paramNames.join(', ')}) but SQLite reports ${described.paramsCount}`);
651
+ }
652
+ const commentByName = new Map(commentParams.map((p) => [p.name, p.type]));
653
+ const params = paramNames.map((paramName) => {
654
+ const commented = commentByName.get(paramName);
655
+ if (commented !== undefined) {
656
+ return { name: paramName, type: commented, source: 'comment' };
657
+ }
658
+ const inferred = inferParamType(paramName, sql, refs, ir);
659
+ if (inferred !== null) {
660
+ return { name: paramName, type: inferred, source: 'inferred' };
661
+ }
662
+ throw new TypegenError(file, `cannot infer a type for param :${paramName} (it is not compared to a plain column). Add a header comment: \`-- param :${paramName} <type>\` (one of ${[...PARAM_TYPES].join(', ')}).`);
663
+ });
664
+ // A `-- param` comment naming a param the query does not use is a mistake.
665
+ for (const cp of commentParams) {
666
+ if (!paramNames.includes(cp.name)) {
667
+ throw new TypegenError(file, `-- param :${cp.name} names a parameter the query does not use`);
668
+ }
669
+ }
670
+ return {
671
+ name,
672
+ file,
673
+ sql,
674
+ positionalSql: toPositionalSql(sql),
675
+ params,
676
+ columns,
677
+ tables,
678
+ };
679
+ }
680
+ /**
681
+ * Analyze a whole query FILE: split into statements, resolve each statement's
682
+ * name (path-derived default, or a `-- name:` override), enforce the
683
+ * multi-statement contract, and analyze each. `relPath` is the file's path
684
+ * relative to the queries root (drives the default name + error locations).
685
+ *
686
+ * Rules (agreed 2026-07-04):
687
+ * - A file with ONE statement may omit `-- name:` → the path-derived default.
688
+ * - A file with MULTIPLE statements requires `-- name:` on EVERY statement; a
689
+ * missing one errors, naming the file + the statement's position/first line.
690
+ */
691
+ export function analyzeQueryFile(relPath, content, ir, db) {
692
+ const statements = splitStatements(content);
693
+ if (statements.length === 0) {
694
+ throw new TypegenError(relPath, 'query file is empty');
695
+ }
696
+ const multi = statements.length > 1;
697
+ const defaultName = queryNameFromPath(relPath);
698
+ return statements.map((stmt, index) => {
699
+ const location = multi ? `${relPath}#${index + 1}` : relPath;
700
+ const override = parseStatementName(stmt.text, location);
701
+ if (multi && override === null) {
702
+ const firstLine = stripCommentsAndStrings(stmt.text).trim().split('\n')[0]?.trim() ?? '';
703
+ throw new TypegenError(location, `${relPath} holds ${statements.length} statements, so each requires a \`-- name: <camelCase>\` marker; statement #${index + 1} (line ${stmt.startLine}: ${JSON.stringify(firstLine.slice(0, 60))}) has none`);
704
+ }
705
+ const name = override ?? defaultName;
706
+ return analyzeStatement(name, location, stmt.text, ir, db);
707
+ });
708
+ }
709
+ /** Back-compat single-statement analyzer: resolve the name from the file path
710
+ * (or a `-- name:` override) and analyze. Rejects a `;`-separated file loudly
711
+ * — use {@link analyzeQueryFile} for the multi-statement contract. */
712
+ export function analyzeQuery(file, raw, ir, db) {
713
+ const statements = splitStatements(raw);
714
+ if (statements.length > 1) {
715
+ throw new TypegenError(file, 'a query file holds exactly one SELECT statement here (found a `;` separating statements) — use analyzeQueryFile for multi-statement files');
716
+ }
717
+ const results = analyzeQueryFile(file, raw, ir, db);
718
+ return results[0];
719
+ }