@syncular/typegen 0.4.1 → 0.5.0

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 CHANGED
@@ -72,6 +72,7 @@ schema guide and suggests `syncular init`.
72
72
  | `queryBackend` | no (default `"neutralize"`) | `.syql` conditional lowering: `"neutralize"` (one guarded statement), `"variants"` (enumerate per provided-combination whenever eligible), `"auto"` (enumerate at ≤ 2 optional groups). The per-query `variants` knob always forces. |
73
73
  | `output.ir` | no (default `./syncular.ir.json`) | IR output path, relative to the manifest. |
74
74
  | `output.module` | no (default `./syncular.generated.ts`) | Generated TS module path, relative to the manifest. |
75
+ | `output.queryIr` | no | Deterministic analyzed QueryIR JSON. Its hash keys generated reactive query descriptors, so SQL-only changes invalidate caches. |
75
76
  | `output.queries` | no | Opt-in TS named-queries output path (see §6). A sibling `.ts` file. |
76
77
  | `output.swift` | no | Opt-in Swift emitter (see §5). A path string, or `{ "path", "enumName", "queriesPath" }` (default `enumName` `SyncularSchema`; `queriesPath` opts into §6 Swift named queries). |
77
78
  | `output.kotlin` | no | Opt-in Kotlin emitter (see §5). A path string, or `{ "path", "package", "objectName", "queriesPath" }` (defaults `syncular.generated` / `SyncularSchema`). |
@@ -440,17 +441,45 @@ so the emitted SQL rewrites `:name` → `?` (repeats included) and each runner
440
441
  reorders the named params object into the positional array. Comments are
441
442
  stripped and whitespace collapsed to a clean one-line string.
442
443
 
443
- **The tables dependency set** (for React's exact invalidation). Each query
444
- emits a `tables` set — the FROM/JOIN tables it reads, resolved against the IR
445
- and *validated by the same SQLite `prepare()`* (an unknown table would have
446
- thrown). This feeds `useRawSql`'s `{tables}` option so a named query
447
- re-runs exactly when a depended-on table invalidates. **Boundary**:
444
+ **Reactive metadata.** Each query emits a QueryIR-derived cache id, the
445
+ FROM/JOIN table set, table-associated scope dependencies, provable window
446
+ coverage, and a safe row identity when the primary key is projected by an
447
+ unambiguous single-table query. React uses these facts to share one query per
448
+ revision, route exact change batches, claim window coverage, and retain
449
+ unchanged row objects. The cache id is derived from the query IR rather than
450
+ the schema IR, so a SQL-only change cannot reuse old state. **Boundary**:
448
451
  `bun:sqlite` exposes no authorizer and no statement table-list, and EXPLAIN
449
452
  opcodes are fragile — so the set is derived by scanning every `FROM`/`JOIN`
450
453
  identifier and keeping those that name an IR table. This captures subquery /
451
454
  `WHERE EXISTS (…)` tables (they still appear under `FROM`/`JOIN`), and the
452
455
  prepare() still guarantees the SQL itself is correct.
453
456
 
457
+ Inference is deliberately conservative around `OR`, joins, grouping, and
458
+ computed identity. A `.syql` query can state the missing fact explicitly:
459
+
460
+ ```syql
461
+ query compareLists(left, right)
462
+ depends tasks on project_id = left | right
463
+ window tasks by project_id = left | right
464
+ key by id
465
+ {
466
+ select id, title from tasks
467
+ where project_id = :left or project_id = :right
468
+ }
469
+ ```
470
+
471
+ The declaration grammar is:
472
+
473
+ - `depends <table> on <scope> = <param> | <param>`;
474
+ - `window <table> by <scope> = <param> | <param>` with optional
475
+ `fixed <other_scope> = <param>, ...` for every other scope on that table;
476
+ - `key by <result-column> | <result-column>`.
477
+
478
+ These escape hatches are checked against the prepared query, schema, inferred
479
+ param types, and result projection. They reject unread tables, unknown scopes
480
+ or params, optional/incompatible coverage params, incomplete fixed scopes,
481
+ duplicate declarations, and unprojected key fields.
482
+
454
483
  **Emitted shape, per language** (one query, five outputs — abbreviated):
455
484
 
456
485
  | | Output |
@@ -467,11 +496,11 @@ the schema structs. `--check` gates every queries file byte-exactly, like the
467
496
  schema files; each carries the DO-NOT-EDIT header + IR hash.
468
497
 
469
498
  **React helper.** `@syncular/react` exports `useQuery(query, params?)`,
470
- which takes the TS `NamedQuery` descriptor and reuses `useRawSql`'s
471
- invalidation machinery with the descriptor's exact `tables` set:
499
+ which takes the TS `NamedQuery` descriptor and observes the client-scoped
500
+ revisioned store:
472
501
 
473
502
  ```ts
474
503
  import { listTodosQuery } from './syncular.queries';
475
- const { rows } = useQuery(listTodosQuery, { listId });
476
- // ^ ListTodosRow[] typed by the query's own projection
504
+ const todos = useQuery(listTodosQuery, { listId });
505
+ // todos.rows: ListTodosRow[]; todos.phase: loading | partial | ready | error
477
506
  ```
@@ -33,7 +33,22 @@ function bindExpr(query, param, access) {
33
33
  const key = propertyKey(param.langName);
34
34
  return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
35
35
  }
36
- function emitQuery(query) {
36
+ function reactiveParam(query, name) {
37
+ const param = query.params.find((candidate) => candidate.name === name);
38
+ if (param === undefined)
39
+ throw new Error(`unknown reactive param ${name}`);
40
+ return `String(params.${propertyKey(param.langName)})`;
41
+ }
42
+ function scopeKeyExpression(query, pattern, variable, param) {
43
+ const marker = `{${variable}}`;
44
+ const index = pattern.indexOf(marker);
45
+ if (index < 0)
46
+ throw new Error(`scope pattern ${pattern} lacks ${marker}`);
47
+ const prefix = pattern.slice(0, index);
48
+ const suffix = pattern.slice(index + marker.length);
49
+ return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
50
+ }
51
+ function emitQuery(query, hash) {
37
52
  const Row = `${pascalCase(query.name)}Row`;
38
53
  const Params = `${pascalCase(query.name)}Params`;
39
54
  const lines = [];
@@ -75,7 +90,7 @@ function emitQuery(query) {
75
90
  lines.push('');
76
91
  }
77
92
  // Tables dependency set (for useRawSql {tables} / useQuery).
78
- lines.push(`/** Tables ${quote(query.name)} reads the exact useRawSql \`{tables}\` set. */`);
93
+ lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`);
79
94
  lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
80
95
  lines.push('');
81
96
  // SQL constants: the static default statement, plus (orderBy knob) the
@@ -166,9 +181,11 @@ function emitQuery(query) {
166
181
  // the baked allowlist, or variant-selected), the exact table dependency
167
182
  // set, and a `bind(params)` → positional array (always paired with the
168
183
  // sqlFor selection). Typed by the query's own Row/Params.
169
- lines.push(`/** Descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`);
184
+ lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`);
170
185
  const paramsTypeArg = hasParams ? Params : 'undefined';
171
186
  lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
187
+ lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
188
+ lines.push(` hasParams: ${hasParams},`);
172
189
  lines.push(` sql: ${sqlConst},`);
173
190
  if (query.variants !== undefined) {
174
191
  lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
@@ -177,6 +194,24 @@ function emitQuery(query) {
177
194
  lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
178
195
  }
179
196
  lines.push(` tables: ${query.name}Tables,`);
197
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
198
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
199
+ for (const dependency of query.reactive.dependencies) {
200
+ const keys = dependency.scopes.flatMap((scope) => scope.params.map((param) => scopeKeyExpression(query, scope.pattern, scope.variable, param)));
201
+ lines.push(` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`);
202
+ }
203
+ lines.push(' ],');
204
+ const coverageUsesParams = query.reactive.coverage.some((coverage) => coverage.units.length > 0 ||
205
+ coverage.fixedScopes.some((scope) => scope.params.length > 0));
206
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
207
+ for (const coverage of query.reactive.coverage) {
208
+ const fixed = coverage.fixedScopes
209
+ .map((scope) => `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`)
210
+ .join(', ');
211
+ const fixedPart = fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : '';
212
+ lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
213
+ }
214
+ lines.push(' ],');
180
215
  if (query.variants !== undefined) {
181
216
  lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
182
217
  }
@@ -186,6 +221,9 @@ function emitQuery(query) {
186
221
  else {
187
222
  lines.push(' bind: () => [],');
188
223
  }
224
+ if (query.reactive.rowKey !== undefined) {
225
+ lines.push(` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`);
226
+ }
189
227
  lines.push('};');
190
228
  return lines.join('\n');
191
229
  }
@@ -216,23 +254,43 @@ export function emitQueriesModule(queries, hash, irVersion) {
216
254
  ' ): unknown[] | Promise<unknown[]>;',
217
255
  '}',
218
256
  '',
219
- '/** A named-query descriptor — sql + its exact table dependency set + a',
257
+ '/** A named-query descriptor — checked SQL plus revisioned reactive metadata and a',
220
258
  ' * `bind(params)` → positional args. Consumed by',
221
259
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
222
260
  ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
223
261
  ' * (present only with an orderBy knob) composes the statement from a',
224
262
  ' * generate-time-checked column allowlist. */',
225
263
  'export interface NamedQuery<Row, Params = undefined> {',
264
+ ' readonly id: string;',
265
+ ' readonly hasParams: boolean;',
226
266
  ' readonly sql: string;',
227
267
  ' readonly tables: readonly string[];',
228
268
  ' readonly bind: (params: Params) => readonly QueryValue[];',
229
269
  ' readonly sqlFor?: (params: Params) => string;',
270
+ ' readonly dependencies: (params: Params) => readonly QueryDependency[];',
271
+ ' readonly coverage: (params: Params) => readonly WindowCoverage[];',
272
+ ' readonly rowKey?: (row: Row) => readonly QueryValue[];',
230
273
  ' /** Phantom — carries the Row type for `useQuery` inference. */',
231
274
  ' readonly __row?: Row;',
232
275
  '}',
276
+ '',
277
+ 'export interface QueryDependency {',
278
+ ' readonly table: string;',
279
+ ' readonly scopeKeys?: readonly string[];',
280
+ '}',
281
+ '',
282
+ 'export interface WindowCoverage {',
283
+ ' readonly base: {',
284
+ ' readonly table: string;',
285
+ ' readonly variable: string;',
286
+ ' readonly fixedScopes?: Readonly<Record<string, readonly string[]>>;',
287
+ ' readonly params?: string;',
288
+ ' };',
289
+ ' readonly units: readonly string[];',
290
+ '}',
233
291
  ].join('\n'));
234
292
  for (const query of queries) {
235
- parts.push(emitQuery(query));
293
+ parts.push(emitQuery(query, hash));
236
294
  }
237
295
  return `${parts.join('\n\n')}\n`;
238
296
  }
package/dist/emit.js CHANGED
@@ -122,6 +122,22 @@ function emitRowInterfaces(table, naming) {
122
122
  lines.push('}');
123
123
  return lines.join('\n');
124
124
  }
125
+ function emitTableDescriptor(table, naming) {
126
+ const type = pascalCase(table.name);
127
+ const primary = table.columns.find((column) => column.name === table.primaryKey);
128
+ if (primary === undefined)
129
+ throw new Error(`table ${table.name} lacks its primary key`);
130
+ const field = naming === 'camel' ? snakeToCamel(table.primaryKey) : table.primaryKey;
131
+ const constant = `${naming === 'camel' ? snakeToCamel(table.name) : table.name}Table`;
132
+ return [
133
+ `/** Typed mutation/resource descriptor for ${quote(table.name)}. */`,
134
+ `export const ${constant}: SyncTable<${type}Row, ${type}Insert, ${type}Update, ${appTsType(primary)}> = {`,
135
+ ` name: ${quote(table.name)},`,
136
+ ` primaryKey: ${quote(field)},`,
137
+ ` physicalPrimaryKey: ${quote(table.primaryKey)},`,
138
+ '};',
139
+ ].join('\n');
140
+ }
125
141
  function emitSubscription(sub) {
126
142
  const params = [];
127
143
  for (const scope of sub.scopes) {
@@ -166,9 +182,25 @@ export function emitModule(ir, hash, naming = 'camel') {
166
182
  `// irVersion: ${ir.irVersion}`,
167
183
  `// irHash: ${hash}`,
168
184
  ].join('\n'));
185
+ parts.push([
186
+ '/** Structural descriptor consumed by renderer bindings; phantom type',
187
+ ' * fields make row/insert/update/id inference available without imports. */',
188
+ 'export interface SyncTable<Row, Insert, Update, Id> {',
189
+ ' readonly name: string;',
190
+ ' /** Language-facing key used in generated row and mutation types. */',
191
+ ' readonly primaryKey: keyof Row & string;',
192
+ ' /** Physical SQLite/wire primary-key column. */',
193
+ ' readonly physicalPrimaryKey: string;',
194
+ ' readonly __row?: Row;',
195
+ ' readonly __insert?: Insert;',
196
+ ' readonly __update?: Update;',
197
+ ' readonly __id?: Id;',
198
+ '}',
199
+ ].join('\n'));
169
200
  parts.push(emitSchema(ir));
170
201
  for (const table of ir.tables) {
171
202
  parts.push(emitRowInterfaces(table, naming));
203
+ parts.push(emitTableDescriptor(table, naming));
172
204
  }
173
205
  for (const sub of ir.subscriptions) {
174
206
  parts.push(emitSubscription(sub));
@@ -48,6 +48,10 @@ export interface GenerateResult {
48
48
  /** Serialized IR document (the exact bytes of the `.ir.json` output). */
49
49
  readonly irJson: string;
50
50
  readonly hash: string;
51
+ /** Serialized QueryIR and its content hash. Query descriptors use this hash
52
+ * so a SQL-only edit cannot reuse a stale reactive cache entry. */
53
+ readonly queryIrJson: string;
54
+ readonly queryHash: string;
51
55
  /** Generated TS module source (the exact bytes of the `.ts` output). */
52
56
  readonly module: string;
53
57
  readonly irPath: string;
package/dist/generate.js CHANGED
@@ -18,6 +18,7 @@ import { canonicalizeExtensions, IR_VERSION, irHash, serializeIr, } from './ir.j
18
18
  import { MANIFEST_FILENAME, parseManifest, } from './manifest.js';
19
19
  import { buildNamingMap } from './naming.js';
20
20
  import { analyzeQueryFile, synthesizeDdl, } from './query.js';
21
+ import { serializeQueryIr } from './query-ir.js';
21
22
  import { applyMigrationSql } from './sql.js';
22
23
  import { analyzeSyqlFile } from './syql.js';
23
24
  /** Same grammar the server compiles (§3.1): `prefix:{variable}`. */
@@ -337,11 +338,12 @@ export function generate(manifestDir) {
337
338
  { path: modulePath, content: module },
338
339
  ];
339
340
  // Opt-in native emitters — each present only when the manifest requests it.
340
- const { queries: tsQueriesPath, swift, kotlin, dart } = manifest.output;
341
+ const { queryIr: queryIrPath, queries: tsQueriesPath, swift, kotlin, dart, } = manifest.output;
341
342
  // Named queries: analyzed once (SELECT-only, typed against the IR via
342
343
  // SQLite), then emitted per-language into its OWN file so schema-only
343
344
  // consumers never churn. Only analyzed when SOME query output is requested.
344
- const wantsQueries = tsQueriesPath !== undefined ||
345
+ const wantsQueries = queryIrPath !== undefined ||
346
+ tsQueriesPath !== undefined ||
345
347
  swift?.queriesPath !== undefined ||
346
348
  kotlin?.queriesPath !== undefined ||
347
349
  dart?.queriesPath !== undefined;
@@ -351,10 +353,18 @@ export function generate(manifestDir) {
351
353
  if (wantsQueries && analyzedQueries.length === 0) {
352
354
  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`);
353
355
  }
356
+ const queryIrJson = serializeQueryIr(analyzedQueries);
357
+ const queryHash = irHash(queryIrJson);
358
+ if (queryIrPath !== undefined) {
359
+ outputs.push({
360
+ path: resolve(manifestDir, queryIrPath),
361
+ content: queryIrJson,
362
+ });
363
+ }
354
364
  if (tsQueriesPath !== undefined) {
355
365
  outputs.push({
356
366
  path: resolve(manifestDir, tsQueriesPath),
357
- content: emitQueriesModule(analyzedQueries, hash, ir.irVersion),
367
+ content: emitQueriesModule(analyzedQueries, queryHash, ir.irVersion),
358
368
  });
359
369
  }
360
370
  if (swift !== undefined) {
@@ -365,7 +375,7 @@ export function generate(manifestDir) {
365
375
  if (swift.queriesPath !== undefined) {
366
376
  outputs.push({
367
377
  path: resolve(manifestDir, swift.queriesPath),
368
- content: emitQueriesSwiftModule(analyzedQueries, hash, ir.irVersion, swift.enumName),
378
+ content: emitQueriesSwiftModule(analyzedQueries, queryHash, ir.irVersion, swift.enumName),
369
379
  });
370
380
  }
371
381
  }
@@ -377,7 +387,7 @@ export function generate(manifestDir) {
377
387
  if (kotlin.queriesPath !== undefined) {
378
388
  outputs.push({
379
389
  path: resolve(manifestDir, kotlin.queriesPath),
380
- content: emitQueriesKotlinModule(analyzedQueries, hash, ir.irVersion, kotlin.package, kotlin.objectName),
390
+ content: emitQueriesKotlinModule(analyzedQueries, queryHash, ir.irVersion, kotlin.package, kotlin.objectName),
381
391
  });
382
392
  }
383
393
  }
@@ -389,7 +399,7 @@ export function generate(manifestDir) {
389
399
  if (dart.queriesPath !== undefined) {
390
400
  outputs.push({
391
401
  path: resolve(manifestDir, dart.queriesPath),
392
- content: emitQueriesDartModule(analyzedQueries, hash, ir.irVersion),
402
+ content: emitQueriesDartModule(analyzedQueries, queryHash, ir.irVersion),
393
403
  });
394
404
  }
395
405
  }
@@ -397,6 +407,8 @@ export function generate(manifestDir) {
397
407
  ir,
398
408
  irJson,
399
409
  hash,
410
+ queryIrJson,
411
+ queryHash,
400
412
  module,
401
413
  irPath,
402
414
  modulePath,
package/dist/index.d.ts CHANGED
@@ -23,3 +23,8 @@ export * from './query.js';
23
23
  export * from './query-ir.js';
24
24
  export * from './sql.js';
25
25
  export * from './syql.js';
26
+ export * from './syql-ast.js';
27
+ export * from './syql-lexer.js';
28
+ export * from './syql-modules.js';
29
+ export * from './syql-parser.js';
30
+ export * from './syql-template-parser.js';
package/dist/index.js CHANGED
@@ -23,3 +23,8 @@ export * from './query.js';
23
23
  export * from './query-ir.js';
24
24
  export * from './sql.js';
25
25
  export * from './syql.js';
26
+ export * from './syql-ast.js';
27
+ export * from './syql-lexer.js';
28
+ export * from './syql-modules.js';
29
+ export * from './syql-parser.js';
30
+ export * from './syql-template-parser.js';
@@ -56,6 +56,9 @@ export interface DartOutput {
56
56
  export interface ManifestOutput {
57
57
  readonly ir: string;
58
58
  readonly module: string;
59
+ /** Opt-in deterministic QueryIR JSON. This is also the source of the
60
+ * generated query-descriptor hash, so SQL-only changes rotate cache IDs. */
61
+ readonly queryIr?: string;
59
62
  /** Opt-in TS named-queries output path (a sibling `.ts` file). Undefined →
60
63
  * named queries are not generated for TS. */
61
64
  readonly queries?: string;
package/dist/manifest.js CHANGED
@@ -26,7 +26,8 @@
26
26
  * final entry must cover the final migration.
27
27
  * - Subscription scope values are literals or whole-value `{param}`
28
28
  * placeholders (partial templates are unsupported).
29
- * - `output.ir` / `output.module` are the always-emitted TS defaults.
29
+ * - `output.ir` / `output.module` are the always-emitted TS defaults;
30
+ * `output.queryIr` opts into the deterministic analyzed-query document.
30
31
  * `output.swift` / `output.kotlin` / `output.dart` are OPT-IN native
31
32
  * emitters — a bare string is the output path; an object carries
32
33
  * language-appropriate options (Kotlin `package`/`objectName`, Swift
@@ -242,18 +243,22 @@ export function parseManifest(raw) {
242
243
  }
243
244
  let ir = './syncular.ir.json';
244
245
  let module = './syncular.generated.ts';
246
+ let queryIr;
245
247
  let queriesOut;
246
248
  let swift;
247
249
  let kotlin;
248
250
  let dart;
249
251
  if (obj.output !== undefined) {
250
252
  const output = asObject(obj.output, 'output');
251
- rejectUnknownKeys(output, ['ir', 'module', 'queries', 'swift', 'kotlin', 'dart'], 'output');
253
+ rejectUnknownKeys(output, ['ir', 'module', 'queryIr', 'queries', 'swift', 'kotlin', 'dart'], 'output');
252
254
  if (output.ir !== undefined)
253
255
  ir = asString(output.ir, 'output.ir');
254
256
  if (output.module !== undefined) {
255
257
  module = asString(output.module, 'output.module');
256
258
  }
259
+ if (output.queryIr !== undefined) {
260
+ queryIr = asString(output.queryIr, 'output.queryIr');
261
+ }
257
262
  if (output.queries !== undefined) {
258
263
  queriesOut = asString(output.queries, 'output.queries');
259
264
  }
@@ -269,6 +274,7 @@ export function parseManifest(raw) {
269
274
  const outputSpec = {
270
275
  ir,
271
276
  module,
277
+ ...(queryIr !== undefined ? { queryIr } : {}),
272
278
  ...(queriesOut !== undefined ? { queries: queriesOut } : {}),
273
279
  ...(swift !== undefined ? { swift } : {}),
274
280
  ...(kotlin !== undefined ? { kotlin } : {}),
package/dist/query-ir.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** Serialize analyzed queries as the deterministic QueryIR JSON document. */
2
2
  export function serializeQueryIr(queries) {
3
3
  const doc = {
4
- queryIrVersion: 1,
4
+ queryIrVersion: 2,
5
5
  queries: queries.map((query) => ({
6
6
  name: query.name,
7
7
  file: query.file,
@@ -24,8 +24,31 @@ export function serializeQueryIr(queries) {
24
24
  type: column.type,
25
25
  nullable: column.nullable,
26
26
  fidelity: column.fidelity,
27
+ ...(column.origin !== undefined ? { origin: column.origin } : {}),
27
28
  })),
28
29
  tables: query.tables,
30
+ reactive: {
31
+ dependencies: query.reactive.dependencies.map((dependency) => ({
32
+ table: dependency.table,
33
+ scopes: dependency.scopes.map((scope) => ({
34
+ variable: scope.variable,
35
+ pattern: scope.pattern,
36
+ params: scope.params,
37
+ })),
38
+ })),
39
+ coverage: query.reactive.coverage.map((coverage) => ({
40
+ table: coverage.table,
41
+ variable: coverage.variable,
42
+ units: coverage.units,
43
+ fixedScopes: coverage.fixedScopes.map((scope) => ({
44
+ variable: scope.variable,
45
+ params: scope.params,
46
+ })),
47
+ })),
48
+ ...(query.reactive.rowKey !== undefined
49
+ ? { rowKey: query.reactive.rowKey }
50
+ : {}),
51
+ },
29
52
  // §6 knob metadata — emitted only when declared.
30
53
  ...(query.orderBy !== undefined
31
54
  ? {
package/dist/query.d.ts CHANGED
@@ -47,6 +47,35 @@ export interface QueryColumn {
47
47
  /** `exact` = resolved to an IR column (plain ref); `fallback` = a computed
48
48
  * expression typed by the documented honest fallback. */
49
49
  readonly fidelity: 'exact' | 'fallback';
50
+ /** Proven physical origin for reactive row identity and diagnostics. */
51
+ readonly origin?: {
52
+ readonly table: string;
53
+ readonly column: string;
54
+ };
55
+ }
56
+ export interface QueryScopeBinding {
57
+ readonly table: string;
58
+ readonly variable: string;
59
+ readonly pattern: string;
60
+ readonly params: readonly string[];
61
+ }
62
+ export interface QueryCoverageBinding {
63
+ readonly table: string;
64
+ readonly variable: string;
65
+ readonly units: readonly string[];
66
+ readonly fixedScopes: readonly {
67
+ readonly variable: string;
68
+ readonly params: readonly string[];
69
+ }[];
70
+ }
71
+ export interface QueryReactiveMetadata {
72
+ readonly dependencies: readonly {
73
+ readonly table: string;
74
+ readonly scopes: readonly QueryScopeBinding[];
75
+ }[];
76
+ readonly coverage: readonly QueryCoverageBinding[];
77
+ /** Language-facing projection fields forming a proven unique row key. */
78
+ readonly rowKey?: readonly string[];
50
79
  }
51
80
  /** §7/§8 backend selection: neutralization (default), always-variants, or
52
81
  * the small-N heuristic (`auto`: enumerate at ≤ 2 optional groups). */
@@ -81,6 +110,7 @@ export interface AnalyzedQuery {
81
110
  readonly columns: readonly QueryColumn[];
82
111
  /** IR tables this query reads (the useRawSql `{tables}` set), sorted. */
83
112
  readonly tables: readonly string[];
113
+ readonly reactive: QueryReactiveMetadata;
84
114
  /** §6 orderBy knob (`.syql` tier). When present, `sql`/`positionalSql`
85
115
  * carry the DEFAULT order-by tail and `positionalSqlBase` is the static
86
116
  * prefix emitters compose the selected column onto. */