@syncular/typegen 0.4.0 → 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.
@@ -71,7 +71,27 @@ function bindExpr(
71
71
  return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
72
72
  }
73
73
 
74
- function emitQuery(query: AnalyzedQuery): string {
74
+ function reactiveParam(query: AnalyzedQuery, name: string): string {
75
+ const param = query.params.find((candidate) => candidate.name === name);
76
+ if (param === undefined) throw new Error(`unknown reactive param ${name}`);
77
+ return `String(params.${propertyKey(param.langName)})`;
78
+ }
79
+
80
+ function scopeKeyExpression(
81
+ query: AnalyzedQuery,
82
+ pattern: string,
83
+ variable: string,
84
+ param: string,
85
+ ): string {
86
+ const marker = `{${variable}}`;
87
+ const index = pattern.indexOf(marker);
88
+ if (index < 0) throw new Error(`scope pattern ${pattern} lacks ${marker}`);
89
+ const prefix = pattern.slice(0, index);
90
+ const suffix = pattern.slice(index + marker.length);
91
+ return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
92
+ }
93
+
94
+ function emitQuery(query: AnalyzedQuery, hash: string): string {
75
95
  const Row = `${pascalCase(query.name)}Row`;
76
96
  const Params = `${pascalCase(query.name)}Params`;
77
97
  const lines: string[] = [];
@@ -130,7 +150,7 @@ function emitQuery(query: AnalyzedQuery): string {
130
150
 
131
151
  // Tables dependency set (for useRawSql {tables} / useQuery).
132
152
  lines.push(
133
- `/** Tables ${quote(query.name)} reads the exact useRawSql \`{tables}\` set. */`,
153
+ `/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
134
154
  );
135
155
  lines.push(
136
156
  `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
@@ -253,12 +273,14 @@ function emitQuery(query: AnalyzedQuery): string {
253
273
  // set, and a `bind(params)` → positional array (always paired with the
254
274
  // sqlFor selection). Typed by the query's own Row/Params.
255
275
  lines.push(
256
- `/** Descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\` — sql + tables + row type. */`,
276
+ `/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
257
277
  );
258
278
  const paramsTypeArg = hasParams ? Params : 'undefined';
259
279
  lines.push(
260
280
  `export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`,
261
281
  );
282
+ lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
283
+ lines.push(` hasParams: ${hasParams},`);
262
284
  lines.push(` sql: ${sqlConst},`);
263
285
  if (query.variants !== undefined) {
264
286
  lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
@@ -266,6 +288,40 @@ function emitQuery(query: AnalyzedQuery): string {
266
288
  lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
267
289
  }
268
290
  lines.push(` tables: ${query.name}Tables,`);
291
+ const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
292
+ dependency.scopes.some((scope) => scope.params.length > 0),
293
+ );
294
+ lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
295
+ for (const dependency of query.reactive.dependencies) {
296
+ const keys = dependency.scopes.flatMap((scope) =>
297
+ scope.params.map((param) =>
298
+ scopeKeyExpression(query, scope.pattern, scope.variable, param),
299
+ ),
300
+ );
301
+ lines.push(
302
+ ` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`,
303
+ );
304
+ }
305
+ lines.push(' ],');
306
+ const coverageUsesParams = query.reactive.coverage.some(
307
+ (coverage) =>
308
+ coverage.units.length > 0 ||
309
+ coverage.fixedScopes.some((scope) => scope.params.length > 0),
310
+ );
311
+ lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
312
+ for (const coverage of query.reactive.coverage) {
313
+ const fixed = coverage.fixedScopes
314
+ .map(
315
+ (scope) =>
316
+ `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`,
317
+ )
318
+ .join(', ');
319
+ const fixedPart = fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : '';
320
+ lines.push(
321
+ ` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`,
322
+ );
323
+ }
324
+ lines.push(' ],');
269
325
  if (query.variants !== undefined) {
270
326
  lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
271
327
  } else if (hasParams) {
@@ -273,6 +329,11 @@ function emitQuery(query: AnalyzedQuery): string {
273
329
  } else {
274
330
  lines.push(' bind: () => [],');
275
331
  }
332
+ if (query.reactive.rowKey !== undefined) {
333
+ lines.push(
334
+ ` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`,
335
+ );
336
+ }
276
337
  lines.push('};');
277
338
  return lines.join('\n');
278
339
  }
@@ -311,24 +372,44 @@ export function emitQueriesModule(
311
372
  ' ): unknown[] | Promise<unknown[]>;',
312
373
  '}',
313
374
  '',
314
- '/** A named-query descriptor — sql + its exact table dependency set + a',
375
+ '/** A named-query descriptor — checked SQL plus revisioned reactive metadata and a',
315
376
  ' * `bind(params)` → positional args. Consumed by',
316
377
  " * `@syncular/react`'s `useQuery`. `Row` is the projection row",
317
378
  ' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
318
379
  ' * (present only with an orderBy knob) composes the statement from a',
319
380
  ' * generate-time-checked column allowlist. */',
320
381
  'export interface NamedQuery<Row, Params = undefined> {',
382
+ ' readonly id: string;',
383
+ ' readonly hasParams: boolean;',
321
384
  ' readonly sql: string;',
322
385
  ' readonly tables: readonly string[];',
323
386
  ' readonly bind: (params: Params) => readonly QueryValue[];',
324
387
  ' readonly sqlFor?: (params: Params) => string;',
388
+ ' readonly dependencies: (params: Params) => readonly QueryDependency[];',
389
+ ' readonly coverage: (params: Params) => readonly WindowCoverage[];',
390
+ ' readonly rowKey?: (row: Row) => readonly QueryValue[];',
325
391
  ' /** Phantom — carries the Row type for `useQuery` inference. */',
326
392
  ' readonly __row?: Row;',
327
393
  '}',
394
+ '',
395
+ 'export interface QueryDependency {',
396
+ ' readonly table: string;',
397
+ ' readonly scopeKeys?: readonly string[];',
398
+ '}',
399
+ '',
400
+ 'export interface WindowCoverage {',
401
+ ' readonly base: {',
402
+ ' readonly table: string;',
403
+ ' readonly variable: string;',
404
+ ' readonly fixedScopes?: Readonly<Record<string, readonly string[]>>;',
405
+ ' readonly params?: string;',
406
+ ' };',
407
+ ' readonly units: readonly string[];',
408
+ '}',
328
409
  ].join('\n'),
329
410
  );
330
411
  for (const query of queries) {
331
- parts.push(emitQuery(query));
412
+ parts.push(emitQuery(query, hash));
332
413
  }
333
414
  return `${parts.join('\n\n')}\n`;
334
415
  }
package/src/emit.ts CHANGED
@@ -163,6 +163,26 @@ function emitRowInterfaces(table: IrTable, naming: NamingMode): string {
163
163
  return lines.join('\n');
164
164
  }
165
165
 
166
+ function emitTableDescriptor(table: IrTable, naming: NamingMode): string {
167
+ const type = pascalCase(table.name);
168
+ const primary = table.columns.find(
169
+ (column) => column.name === table.primaryKey,
170
+ );
171
+ if (primary === undefined)
172
+ throw new Error(`table ${table.name} lacks its primary key`);
173
+ const field =
174
+ naming === 'camel' ? snakeToCamel(table.primaryKey) : table.primaryKey;
175
+ const constant = `${naming === 'camel' ? snakeToCamel(table.name) : table.name}Table`;
176
+ return [
177
+ `/** Typed mutation/resource descriptor for ${quote(table.name)}. */`,
178
+ `export const ${constant}: SyncTable<${type}Row, ${type}Insert, ${type}Update, ${appTsType(primary)}> = {`,
179
+ ` name: ${quote(table.name)},`,
180
+ ` primaryKey: ${quote(field)},`,
181
+ ` physicalPrimaryKey: ${quote(table.primaryKey)},`,
182
+ '};',
183
+ ].join('\n');
184
+ }
185
+
166
186
  function emitSubscription(sub: IrSubscription): string {
167
187
  const params: string[] = [];
168
188
  for (const scope of sub.scopes) {
@@ -218,9 +238,27 @@ export function emitModule(
218
238
  `// irHash: ${hash}`,
219
239
  ].join('\n'),
220
240
  );
241
+ parts.push(
242
+ [
243
+ '/** Structural descriptor consumed by renderer bindings; phantom type',
244
+ ' * fields make row/insert/update/id inference available without imports. */',
245
+ 'export interface SyncTable<Row, Insert, Update, Id> {',
246
+ ' readonly name: string;',
247
+ ' /** Language-facing key used in generated row and mutation types. */',
248
+ ' readonly primaryKey: keyof Row & string;',
249
+ ' /** Physical SQLite/wire primary-key column. */',
250
+ ' readonly physicalPrimaryKey: string;',
251
+ ' readonly __row?: Row;',
252
+ ' readonly __insert?: Insert;',
253
+ ' readonly __update?: Update;',
254
+ ' readonly __id?: Id;',
255
+ '}',
256
+ ].join('\n'),
257
+ );
221
258
  parts.push(emitSchema(ir));
222
259
  for (const table of ir.tables) {
223
260
  parts.push(emitRowInterfaces(table, naming));
261
+ parts.push(emitTableDescriptor(table, naming));
224
262
  }
225
263
  for (const sub of ir.subscriptions) {
226
264
  parts.push(emitSubscription(sub));
package/src/generate.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  type QueryNamingOptions,
49
49
  synthesizeDdl,
50
50
  } from './query';
51
+ import { serializeQueryIr } from './query-ir';
51
52
  import { applyMigrationSql, type ParsedTable } from './sql';
52
53
  import { analyzeSyqlFile } from './syql';
53
54
 
@@ -463,6 +464,10 @@ export interface GenerateResult {
463
464
  /** Serialized IR document (the exact bytes of the `.ir.json` output). */
464
465
  readonly irJson: string;
465
466
  readonly hash: string;
467
+ /** Serialized QueryIR and its content hash. Query descriptors use this hash
468
+ * so a SQL-only edit cannot reuse a stale reactive cache entry. */
469
+ readonly queryIrJson: string;
470
+ readonly queryHash: string;
466
471
  /** Generated TS module source (the exact bytes of the `.ts` output). */
467
472
  readonly module: string;
468
473
  readonly irPath: string;
@@ -525,12 +530,19 @@ export function generate(manifestDir: string): GenerateResult {
525
530
  { path: modulePath, content: module },
526
531
  ];
527
532
  // Opt-in native emitters — each present only when the manifest requests it.
528
- const { queries: tsQueriesPath, swift, kotlin, dart } = manifest.output;
533
+ const {
534
+ queryIr: queryIrPath,
535
+ queries: tsQueriesPath,
536
+ swift,
537
+ kotlin,
538
+ dart,
539
+ } = manifest.output;
529
540
 
530
541
  // Named queries: analyzed once (SELECT-only, typed against the IR via
531
542
  // SQLite), then emitted per-language into its OWN file so schema-only
532
543
  // consumers never churn. Only analyzed when SOME query output is requested.
533
544
  const wantsQueries =
545
+ queryIrPath !== undefined ||
534
546
  tsQueriesPath !== undefined ||
535
547
  swift?.queriesPath !== undefined ||
536
548
  kotlin?.queriesPath !== undefined ||
@@ -549,10 +561,19 @@ export function generate(manifestDir: string): GenerateResult {
549
561
  );
550
562
  }
551
563
 
564
+ const queryIrJson = serializeQueryIr(analyzedQueries);
565
+ const queryHash = irHash(queryIrJson);
566
+ if (queryIrPath !== undefined) {
567
+ outputs.push({
568
+ path: resolve(manifestDir, queryIrPath),
569
+ content: queryIrJson,
570
+ });
571
+ }
572
+
552
573
  if (tsQueriesPath !== undefined) {
553
574
  outputs.push({
554
575
  path: resolve(manifestDir, tsQueriesPath),
555
- content: emitQueriesModule(analyzedQueries, hash, ir.irVersion),
576
+ content: emitQueriesModule(analyzedQueries, queryHash, ir.irVersion),
556
577
  });
557
578
  }
558
579
  if (swift !== undefined) {
@@ -565,7 +586,7 @@ export function generate(manifestDir: string): GenerateResult {
565
586
  path: resolve(manifestDir, swift.queriesPath),
566
587
  content: emitQueriesSwiftModule(
567
588
  analyzedQueries,
568
- hash,
589
+ queryHash,
569
590
  ir.irVersion,
570
591
  swift.enumName,
571
592
  ),
@@ -582,7 +603,7 @@ export function generate(manifestDir: string): GenerateResult {
582
603
  path: resolve(manifestDir, kotlin.queriesPath),
583
604
  content: emitQueriesKotlinModule(
584
605
  analyzedQueries,
585
- hash,
606
+ queryHash,
586
607
  ir.irVersion,
587
608
  kotlin.package,
588
609
  kotlin.objectName,
@@ -598,7 +619,11 @@ export function generate(manifestDir: string): GenerateResult {
598
619
  if (dart.queriesPath !== undefined) {
599
620
  outputs.push({
600
621
  path: resolve(manifestDir, dart.queriesPath),
601
- content: emitQueriesDartModule(analyzedQueries, hash, ir.irVersion),
622
+ content: emitQueriesDartModule(
623
+ analyzedQueries,
624
+ queryHash,
625
+ ir.irVersion,
626
+ ),
602
627
  });
603
628
  }
604
629
  }
@@ -606,6 +631,8 @@ export function generate(manifestDir: string): GenerateResult {
606
631
  ir,
607
632
  irJson,
608
633
  hash,
634
+ queryIrJson,
635
+ queryHash,
609
636
  module,
610
637
  irPath,
611
638
  modulePath,
package/src/index.ts CHANGED
@@ -23,3 +23,8 @@ export * from './query';
23
23
  export * from './query-ir';
24
24
  export * from './sql';
25
25
  export * from './syql';
26
+ export * from './syql-ast';
27
+ export * from './syql-lexer';
28
+ export * from './syql-modules';
29
+ export * from './syql-parser';
30
+ export * from './syql-template-parser';
package/src/manifest.ts 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
@@ -105,6 +106,9 @@ export interface DartOutput {
105
106
  export interface ManifestOutput {
106
107
  readonly ir: string;
107
108
  readonly module: string;
109
+ /** Opt-in deterministic QueryIR JSON. This is also the source of the
110
+ * generated query-descriptor hash, so SQL-only changes rotate cache IDs. */
111
+ readonly queryIr?: string;
108
112
  /** Opt-in TS named-queries output path (a sibling `.ts` file). Undefined →
109
113
  * named queries are not generated for TS. */
110
114
  readonly queries?: string;
@@ -400,6 +404,7 @@ export function parseManifest(raw: unknown): Manifest {
400
404
  }
401
405
  let ir = './syncular.ir.json';
402
406
  let module = './syncular.generated.ts';
407
+ let queryIr: string | undefined;
403
408
  let queriesOut: string | undefined;
404
409
  let swift: SwiftOutput | undefined;
405
410
  let kotlin: KotlinOutput | undefined;
@@ -408,13 +413,16 @@ export function parseManifest(raw: unknown): Manifest {
408
413
  const output = asObject(obj.output, 'output');
409
414
  rejectUnknownKeys(
410
415
  output,
411
- ['ir', 'module', 'queries', 'swift', 'kotlin', 'dart'],
416
+ ['ir', 'module', 'queryIr', 'queries', 'swift', 'kotlin', 'dart'],
412
417
  'output',
413
418
  );
414
419
  if (output.ir !== undefined) ir = asString(output.ir, 'output.ir');
415
420
  if (output.module !== undefined) {
416
421
  module = asString(output.module, 'output.module');
417
422
  }
423
+ if (output.queryIr !== undefined) {
424
+ queryIr = asString(output.queryIr, 'output.queryIr');
425
+ }
418
426
  if (output.queries !== undefined) {
419
427
  queriesOut = asString(output.queries, 'output.queries');
420
428
  }
@@ -427,6 +435,7 @@ export function parseManifest(raw: unknown): Manifest {
427
435
  const outputSpec: ManifestOutput = {
428
436
  ir,
429
437
  module,
438
+ ...(queryIr !== undefined ? { queryIr } : {}),
430
439
  ...(queriesOut !== undefined ? { queries: queriesOut } : {}),
431
440
  ...(swift !== undefined ? { swift } : {}),
432
441
  ...(kotlin !== undefined ? { kotlin } : {}),
package/src/query-ir.ts CHANGED
@@ -14,7 +14,7 @@ import type { AnalyzedQuery } from './query';
14
14
  /** Serialize analyzed queries as the deterministic QueryIR JSON document. */
15
15
  export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
16
16
  const doc = {
17
- queryIrVersion: 1,
17
+ queryIrVersion: 2,
18
18
  queries: queries.map((query) => ({
19
19
  name: query.name,
20
20
  file: query.file,
@@ -37,8 +37,31 @@ export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
37
37
  type: column.type,
38
38
  nullable: column.nullable,
39
39
  fidelity: column.fidelity,
40
+ ...(column.origin !== undefined ? { origin: column.origin } : {}),
40
41
  })),
41
42
  tables: query.tables,
43
+ reactive: {
44
+ dependencies: query.reactive.dependencies.map((dependency) => ({
45
+ table: dependency.table,
46
+ scopes: dependency.scopes.map((scope) => ({
47
+ variable: scope.variable,
48
+ pattern: scope.pattern,
49
+ params: scope.params,
50
+ })),
51
+ })),
52
+ coverage: query.reactive.coverage.map((coverage) => ({
53
+ table: coverage.table,
54
+ variable: coverage.variable,
55
+ units: coverage.units,
56
+ fixedScopes: coverage.fixedScopes.map((scope) => ({
57
+ variable: scope.variable,
58
+ params: scope.params,
59
+ })),
60
+ })),
61
+ ...(query.reactive.rowKey !== undefined
62
+ ? { rowKey: query.reactive.rowKey }
63
+ : {}),
64
+ },
42
65
  // §6 knob metadata — emitted only when declared.
43
66
  ...(query.orderBy !== undefined
44
67
  ? {