@syncular/typegen 0.15.1 → 0.15.3

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
@@ -473,7 +473,10 @@ owning synced table for dependency and scope-coverage metadata. `bm25`,
473
473
  `highlight`, and `snippet` are accepted deterministic FTS5 auxiliary
474
474
  functions only when the query references a schema-declared FTS projection;
475
475
  they use the normal computed-expression fidelity rules. Arbitrary extension
476
- functions remain outside the portable profile.
476
+ functions remain outside the portable profile. A bounded FTS join must project
477
+ the FTS table's `_syncular_source_id` as well as every joined table's primary
478
+ key and end its total order in those identity fields. Typegen then proves the
479
+ composite row key instead of weakening bounded-query stability rules.
477
480
 
478
481
  **Typing fidelity** (what `bun:sqlite` actually exposes, and its honest
479
482
  boundary):
package/dist/query.js CHANGED
@@ -86,6 +86,8 @@ const DECLTYPE_MAP = {
86
86
  };
87
87
  /** Local client protocol column; query-only and never part of schema IR. */
88
88
  const SYNC_VERSION_COLUMN = '_sync_version';
89
+ /** Stable private identity shared by every declared local FTS5 projection. */
90
+ const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
89
91
  const DEFAULT_NAMING = { naming: 'camel', targets: ['ts'] };
90
92
  // -- path → name --------------------------------------------------------------
91
93
  /** A single kebab-case path segment (folder) or filename stem. */
@@ -513,6 +515,14 @@ function splitSelectList(sql) {
513
515
  return items;
514
516
  }
515
517
  const PLAIN_REF_RE = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})$`);
518
+ function declaredFtsProjection(ir, tableName) {
519
+ return ir.tables.some((table) => table.ftsIndexes.some((index) => index.name === tableName));
520
+ }
521
+ const FTS_SOURCE_ID_IR_COLUMN = {
522
+ name: FTS_SOURCE_ID_COLUMN,
523
+ type: 'string',
524
+ nullable: false,
525
+ };
516
526
  /** Resolve a SELECT item's expression to an IR column, if it is a plain ref. */
517
527
  function resolveSource(item, refs, ir) {
518
528
  const m = PLAIN_REF_RE.exec(item.expr);
@@ -527,7 +537,13 @@ function resolveSource(item, refs, ir) {
527
537
  return null;
528
538
  const table = byName.get(ref.table);
529
539
  const col = table?.columns.find((c) => c.name === columnName);
530
- return col === undefined ? null : { table: ref.table, column: col };
540
+ if (col !== undefined)
541
+ return { table: ref.table, column: col };
542
+ if (columnName === FTS_SOURCE_ID_COLUMN &&
543
+ declaredFtsProjection(ir, ref.table)) {
544
+ return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
545
+ }
546
+ return null;
531
547
  }
532
548
  // Unqualified: search every FROM/JOIN table (SQLite already resolved
533
549
  // ambiguity; a single-table query is the common case).
@@ -536,6 +552,10 @@ function resolveSource(item, refs, ir) {
536
552
  const col = table?.columns.find((c) => c.name === columnName);
537
553
  if (col !== undefined)
538
554
  return { table: ref.table, column: col };
555
+ if (columnName === FTS_SOURCE_ID_COLUMN &&
556
+ declaredFtsProjection(ir, ref.table)) {
557
+ return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
558
+ }
539
559
  }
540
560
  return null;
541
561
  }
@@ -836,7 +856,7 @@ export function synthesizeDdl(ir) {
836
856
  }
837
857
  for (const index of table.ftsIndexes) {
838
858
  const tokenize = index.tokenize.replaceAll("'", "''");
839
- lines.push(`CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`);
859
+ lines.push(`CREATE VIRTUAL TABLE ${index.name} USING fts5(${FTS_SOURCE_ID_COLUMN} UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`);
840
860
  }
841
861
  }
842
862
  return lines.join('\n');
@@ -762,7 +762,10 @@ class Validator {
762
762
  }
763
763
  candidates.push({ ref, scopes: inferred });
764
764
  }
765
- const dependencies = [...new Set(refs.map((ref) => ref.table))]
765
+ const ftsOwners = new Map(this.#ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
766
+ const dependencies = [
767
+ ...new Set(refs.map((ref) => ftsOwners.get(ref.table) ?? ref.table)),
768
+ ]
766
769
  .sort()
767
770
  .map((table) => {
768
771
  const instances = candidates.filter((item) => item.ref.table === table);
@@ -907,9 +910,13 @@ class Validator {
907
910
  const inferred = [];
908
911
  for (const ref of refs) {
909
912
  const table = this.#ir.tables.find((item) => item.name === ref.table);
910
- const column = analysis.columns.find((candidate) => table !== undefined &&
911
- candidate.origin?.table === table.name &&
912
- candidate.origin.column === table.primaryKey &&
913
+ const primaryKey = table?.primaryKey ??
914
+ (this.#ir.tables.some((item) => item.ftsIndexes.some((index) => index.name === ref.table))
915
+ ? '_syncular_source_id'
916
+ : undefined);
917
+ const column = analysis.columns.find((candidate) => primaryKey !== undefined &&
918
+ candidate.origin?.table === ref.table &&
919
+ candidate.origin.column === primaryKey &&
913
920
  !candidate.nullable);
914
921
  if (column === undefined)
915
922
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,7 +48,7 @@
48
48
  "!dist/**/*.test.d.ts"
49
49
  ],
50
50
  "devDependencies": {
51
- "@syncular/core": "0.15.1",
52
- "@syncular/server": "0.15.1"
51
+ "@syncular/core": "0.15.3",
52
+ "@syncular/server": "0.15.3"
53
53
  }
54
54
  }
package/src/query.ts CHANGED
@@ -89,6 +89,8 @@ const DECLTYPE_MAP: Readonly<Record<string, IrColumnType>> = {
89
89
 
90
90
  /** Local client protocol column; query-only and never part of schema IR. */
91
91
  const SYNC_VERSION_COLUMN = '_sync_version';
92
+ /** Stable private identity shared by every declared local FTS5 projection. */
93
+ const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
92
94
 
93
95
  /** A param type is one of the §2.4 types (the columns params compare to). */
94
96
  export type QueryParamType = IrColumnType;
@@ -793,6 +795,18 @@ interface ResolvedSource {
793
795
  readonly column: IrTable['columns'][number];
794
796
  }
795
797
 
798
+ function declaredFtsProjection(ir: IrDocument, tableName: string): boolean {
799
+ return ir.tables.some((table) =>
800
+ table.ftsIndexes.some((index) => index.name === tableName),
801
+ );
802
+ }
803
+
804
+ const FTS_SOURCE_ID_IR_COLUMN: IrTable['columns'][number] = {
805
+ name: FTS_SOURCE_ID_COLUMN,
806
+ type: 'string',
807
+ nullable: false,
808
+ };
809
+
796
810
  /** Resolve a SELECT item's expression to an IR column, if it is a plain ref. */
797
811
  function resolveSource(
798
812
  item: SelectItem,
@@ -809,7 +823,14 @@ function resolveSource(
809
823
  if (ref === undefined) return null;
810
824
  const table = byName.get(ref.table);
811
825
  const col = table?.columns.find((c) => c.name === columnName);
812
- return col === undefined ? null : { table: ref.table, column: col };
826
+ if (col !== undefined) return { table: ref.table, column: col };
827
+ if (
828
+ columnName === FTS_SOURCE_ID_COLUMN &&
829
+ declaredFtsProjection(ir, ref.table)
830
+ ) {
831
+ return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
832
+ }
833
+ return null;
813
834
  }
814
835
  // Unqualified: search every FROM/JOIN table (SQLite already resolved
815
836
  // ambiguity; a single-table query is the common case).
@@ -817,6 +838,12 @@ function resolveSource(
817
838
  const table = byName.get(ref.table);
818
839
  const col = table?.columns.find((c) => c.name === columnName);
819
840
  if (col !== undefined) return { table: ref.table, column: col };
841
+ if (
842
+ columnName === FTS_SOURCE_ID_COLUMN &&
843
+ declaredFtsProjection(ir, ref.table)
844
+ ) {
845
+ return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
846
+ }
820
847
  }
821
848
  return null;
822
849
  }
@@ -1211,7 +1238,7 @@ export function synthesizeDdl(ir: IrDocument): string {
1211
1238
  for (const index of table.ftsIndexes) {
1212
1239
  const tokenize = index.tokenize.replaceAll("'", "''");
1213
1240
  lines.push(
1214
- `CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`,
1241
+ `CREATE VIRTUAL TABLE ${index.name} USING fts5(${FTS_SOURCE_ID_COLUMN} UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`,
1215
1242
  );
1216
1243
  }
1217
1244
  }
@@ -1149,7 +1149,14 @@ class Validator {
1149
1149
  candidates.push({ ref, scopes: inferred });
1150
1150
  }
1151
1151
 
1152
- const dependencies = [...new Set(refs.map((ref) => ref.table))]
1152
+ const ftsOwners = new Map(
1153
+ this.#ir.tables.flatMap((table) =>
1154
+ table.ftsIndexes.map((index) => [index.name, table.name] as const),
1155
+ ),
1156
+ );
1157
+ const dependencies = [
1158
+ ...new Set(refs.map((ref) => ftsOwners.get(ref.table) ?? ref.table)),
1159
+ ]
1153
1160
  .sort()
1154
1161
  .map((table) => {
1155
1162
  const instances = candidates.filter((item) => item.ref.table === table);
@@ -1364,11 +1371,18 @@ class Validator {
1364
1371
  const inferred: string[] = [];
1365
1372
  for (const ref of refs) {
1366
1373
  const table = this.#ir.tables.find((item) => item.name === ref.table);
1374
+ const primaryKey =
1375
+ table?.primaryKey ??
1376
+ (this.#ir.tables.some((item) =>
1377
+ item.ftsIndexes.some((index) => index.name === ref.table),
1378
+ )
1379
+ ? '_syncular_source_id'
1380
+ : undefined);
1367
1381
  const column = analysis.columns.find(
1368
1382
  (candidate) =>
1369
- table !== undefined &&
1370
- candidate.origin?.table === table.name &&
1371
- candidate.origin.column === table.primaryKey &&
1383
+ primaryKey !== undefined &&
1384
+ candidate.origin?.table === ref.table &&
1385
+ candidate.origin.column === primaryKey &&
1372
1386
  !candidate.nullable,
1373
1387
  );
1374
1388
  if (column === undefined) return undefined;