@syncular/typegen 0.15.0 → 0.15.2

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
@@ -470,8 +470,13 @@ For FTS-backed named queries the synthesized type-check database creates the
470
470
  same declared columns plus `_syncular_source_id UNINDEXED`. `MATCH :query`
471
471
  infers a string parameter, and the FTS table reference is folded into its
472
472
  owning synced table for dependency and scope-coverage metadata. `bm25`,
473
- `highlight`, and `snippet` remain SQLite expressions and therefore use the
474
- normal computed-expression fidelity rules.
473
+ `highlight`, and `snippet` are accepted deterministic FTS5 auxiliary
474
+ functions only when the query references a schema-declared FTS projection;
475
+ they use the normal computed-expression fidelity rules. Arbitrary extension
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.
475
480
 
476
481
  **Typing fidelity** (what `bun:sqlite` actually exposes, and its honest
477
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');
@@ -121,6 +121,16 @@ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
121
121
  'jsonb_replace',
122
122
  'jsonb_set',
123
123
  ]);
124
+ /**
125
+ * Deterministic FTS5 auxiliary functions available only when the query reads
126
+ * a schema-declared FTS projection. FTS5 is an explicit Syncular client
127
+ * requirement in that case, not an arbitrary host extension.
128
+ */
129
+ const SQLITE_FTS5_AUXILIARY_FUNCTIONS = new Set([
130
+ 'bm25',
131
+ 'highlight',
132
+ 'snippet',
133
+ ]);
124
134
  const SQL_PAREN_KEYWORDS = new Set([
125
135
  'and',
126
136
  'as',
@@ -248,8 +258,8 @@ class Validator {
248
258
  const activeStructure = this.#inspect(activeSql, location);
249
259
  this.#validateStatementShape(activeSql, activeStructure, logical.declaration, location);
250
260
  this.#validateDeterminism(activeSql, logical.declaration.statement.span);
251
- this.#validatePortableProfile(activeSql, logical.declaration.statement.span);
252
261
  const refs = scanTableRefs(activeSql, this.#ir);
262
+ this.#validatePortableProfile(activeSql, logical.declaration.statement.span, refs);
253
263
  const bindSymbols = this.#bindSymbols(logical.declaration);
254
264
  const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols);
255
265
  const sort = this.#validateSort(logical.declaration, activeStructure, location);
@@ -512,8 +522,11 @@ class Validator {
512
522
  }
513
523
  }
514
524
  }
515
- #validatePortableProfile(sql, span) {
525
+ #validatePortableProfile(sql, span, refs) {
516
526
  const tokens = significant(lexSyqlSqlSource(span.file, sql));
527
+ const declaredFtsTables = new Set(this.#ir.tables.flatMap((table) => table.ftsIndexes.map((index) => index.name)));
528
+ const queryRefs = refs ?? scanTableRefs(sql, this.#ir);
529
+ const readsDeclaredFts = queryRefs.some((ref) => declaredFtsTables.has(ref.table));
517
530
  for (let index = 0; index < tokens.length; index += 1) {
518
531
  const token = tokens[index];
519
532
  const lower = tokenLower(token);
@@ -537,8 +550,10 @@ class Validator {
537
550
  tokens[closeIndex + 2]?.text === '(') {
538
551
  continue;
539
552
  }
540
- if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '')) {
541
- this.#fail('SYQL6002_INVALID_SQL', token.span, `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile`);
553
+ const allowedFtsAuxiliary = readsDeclaredFts && SQLITE_FTS5_AUXILIARY_FUNCTIONS.has(lower ?? '');
554
+ if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '') &&
555
+ !allowedFtsAuxiliary) {
556
+ this.#fail('SYQL6002_INVALID_SQL', token.span, `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile or an auxiliary function of a referenced schema-declared FTS5 projection`);
542
557
  }
543
558
  const argumentCount = functionArgumentCount(tokens, index + 1);
544
559
  if (lower === 'iif' && argumentCount !== 3) {
@@ -892,9 +907,13 @@ class Validator {
892
907
  const inferred = [];
893
908
  for (const ref of refs) {
894
909
  const table = this.#ir.tables.find((item) => item.name === ref.table);
895
- const column = analysis.columns.find((candidate) => table !== undefined &&
896
- candidate.origin?.table === table.name &&
897
- candidate.origin.column === table.primaryKey &&
910
+ const primaryKey = table?.primaryKey ??
911
+ (this.#ir.tables.some((item) => item.ftsIndexes.some((index) => index.name === ref.table))
912
+ ? '_syncular_source_id'
913
+ : undefined);
914
+ const column = analysis.columns.find((candidate) => primaryKey !== undefined &&
915
+ candidate.origin?.table === ref.table &&
916
+ candidate.origin.column === primaryKey &&
898
917
  !candidate.nullable);
899
918
  if (column === undefined)
900
919
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
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.0",
52
- "@syncular/server": "0.15.0"
51
+ "@syncular/core": "0.15.2",
52
+ "@syncular/server": "0.15.2"
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
  }
@@ -221,6 +221,16 @@ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
221
221
  'jsonb_replace',
222
222
  'jsonb_set',
223
223
  ]);
224
+ /**
225
+ * Deterministic FTS5 auxiliary functions available only when the query reads
226
+ * a schema-declared FTS projection. FTS5 is an explicit Syncular client
227
+ * requirement in that case, not an arbitrary host extension.
228
+ */
229
+ const SQLITE_FTS5_AUXILIARY_FUNCTIONS = new Set([
230
+ 'bm25',
231
+ 'highlight',
232
+ 'snippet',
233
+ ]);
224
234
  const SQL_PAREN_KEYWORDS = new Set([
225
235
  'and',
226
236
  'as',
@@ -376,12 +386,13 @@ class Validator {
376
386
  location,
377
387
  );
378
388
  this.#validateDeterminism(activeSql, logical.declaration.statement.span);
389
+ const refs = scanTableRefs(activeSql, this.#ir);
379
390
  this.#validatePortableProfile(
380
391
  activeSql,
381
392
  logical.declaration.statement.span,
393
+ refs,
382
394
  );
383
395
 
384
- const refs = scanTableRefs(activeSql, this.#ir);
385
396
  const bindSymbols = this.#bindSymbols(logical.declaration);
386
397
  const bindTypes = this.#resolveBindTypes(
387
398
  logical,
@@ -792,8 +803,21 @@ class Validator {
792
803
  }
793
804
  }
794
805
 
795
- #validatePortableProfile(sql: string, span: SyqlSourceSpan): void {
806
+ #validatePortableProfile(
807
+ sql: string,
808
+ span: SyqlSourceSpan,
809
+ refs?: readonly TableRef[],
810
+ ): void {
796
811
  const tokens = significant(lexSyqlSqlSource(span.file, sql));
812
+ const declaredFtsTables = new Set(
813
+ this.#ir.tables.flatMap((table) =>
814
+ table.ftsIndexes.map((index) => index.name),
815
+ ),
816
+ );
817
+ const queryRefs = refs ?? scanTableRefs(sql, this.#ir);
818
+ const readsDeclaredFts = queryRefs.some((ref) =>
819
+ declaredFtsTables.has(ref.table),
820
+ );
797
821
  for (let index = 0; index < tokens.length; index += 1) {
798
822
  const token = tokens[index] as SyqlToken;
799
823
  const lower = tokenLower(token);
@@ -828,11 +852,16 @@ class Validator {
828
852
  ) {
829
853
  continue;
830
854
  }
831
- if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '')) {
855
+ const allowedFtsAuxiliary =
856
+ readsDeclaredFts && SQLITE_FTS5_AUXILIARY_FUNCTIONS.has(lower ?? '');
857
+ if (
858
+ !SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '') &&
859
+ !allowedFtsAuxiliary
860
+ ) {
832
861
  this.#fail(
833
862
  'SYQL6002_INVALID_SQL',
834
863
  token.span,
835
- `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile`,
864
+ `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile or an auxiliary function of a referenced schema-declared FTS5 projection`,
836
865
  );
837
866
  }
838
867
 
@@ -1335,11 +1364,18 @@ class Validator {
1335
1364
  const inferred: string[] = [];
1336
1365
  for (const ref of refs) {
1337
1366
  const table = this.#ir.tables.find((item) => item.name === ref.table);
1367
+ const primaryKey =
1368
+ table?.primaryKey ??
1369
+ (this.#ir.tables.some((item) =>
1370
+ item.ftsIndexes.some((index) => index.name === ref.table),
1371
+ )
1372
+ ? '_syncular_source_id'
1373
+ : undefined);
1338
1374
  const column = analysis.columns.find(
1339
1375
  (candidate) =>
1340
- table !== undefined &&
1341
- candidate.origin?.table === table.name &&
1342
- candidate.origin.column === table.primaryKey &&
1376
+ primaryKey !== undefined &&
1377
+ candidate.origin?.table === ref.table &&
1378
+ candidate.origin.column === primaryKey &&
1343
1379
  !candidate.nullable,
1344
1380
  );
1345
1381
  if (column === undefined) return undefined;