@syncular/typegen 0.13.0 → 0.15.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
@@ -185,6 +185,7 @@ duplicate ordinals are errors). The parser accepts exactly:
185
185
  - `ALTER TABLE name ADD [COLUMN] column-def`
186
186
  - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table ( col [, col…] )`
187
187
  - `DROP TABLE [IF EXISTS] name`
188
+ - `CREATE VIRTUAL TABLE [IF NOT EXISTS] name USING fts5( text-col [, text-col…], content = table [, tokenize = 'allowlisted tokenizer'] )`
188
189
  - column-def: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL] [DEFAULT literal]`
189
190
  - `--` line comments and `/* … */` block comments
190
191
 
@@ -226,6 +227,22 @@ reference relational server drops the retired current-row table and its live
226
227
  scope index. Append-only commit history follows the configured retention
227
228
  policy—`DROP TABLE` is schema retirement, not a compliance erasure API.
228
229
 
230
+ **Local FTS5 projections** (`CREATE VIRTUAL TABLE … USING fts5`). The authored
231
+ `content = table` option declares which already-created synced table owns the
232
+ projection; it is not passed through as SQLite external-content mode. Typegen
233
+ attaches `{ name, columns, tokenize }` to that table's `ftsIndexes` array and
234
+ emits it in TypeScript, Swift, Kotlin, and Dart schema values. The TypeScript
235
+ and Rust clients create a contentful FTS table with a private stable source-ID
236
+ column and deterministic maintenance triggers. FTS is local-only: it is not a
237
+ wire/server table, cannot be subscribed or mutated, and maps back to its owner
238
+ for named-query invalidation. Columns must precede options, be distinct
239
+ non-encrypted strings, and number 1–32. `content` is required. Supported
240
+ tokenizers are `unicode61` (default), its `remove_diacritics 0|1|2` variants,
241
+ `porter unicode61`, and `trigram`. Other modules/options/tokenizers, encrypted
242
+ columns, or schema-object name collisions fail generation. There is no
243
+ automatic `LIKE` fallback when FTS5 is unavailable; local schema creation
244
+ fails loudly.
245
+
229
246
  **Hard errors** (each names the construct and source file): any other
230
247
  statement (`CREATE TRIGGER/VIEW`, `DROP INDEX`, DML, `ALTER … RENAME`, …); unknown
231
248
  or parameterized types (`VARCHAR(36)`); quoted identifiers
@@ -234,7 +251,9 @@ or parameterized types (`VARCHAR(36)`); quoted identifiers
234
251
  (`REFERENCES`, `CHECK`, `COLLATE`, …); `DEFAULT (expression)`; composite
235
252
  or missing primary keys; `PRIMARY KEY` on `ADD COLUMN`; duplicate
236
253
  tables/columns; ASC/DESC, expression, or partial (`WHERE`) index columns; a
237
- duplicate or unknown-column index; trailing clauses (`STRICT`).
254
+ duplicate or unknown-column index; unsupported virtual-table modules or FTS5
255
+ options; an FTS projection without an owner or with invalid/encrypted columns;
256
+ trailing clauses (`STRICT`).
238
257
 
239
258
  **`DEFAULT` literals are accepted and ignored**: typegen extracts the
240
259
  schema *shape*; executing migrations (where defaults matter) is the
@@ -447,6 +466,13 @@ a bad table/column reference or a syntax error throws with SQLite's own
447
466
  message. The prepared statement then yields the result column names +
448
467
  `declaredTypes` (SQLite's `sqlite3_column_decltype`).
449
468
 
469
+ For FTS-backed named queries the synthesized type-check database creates the
470
+ same declared columns plus `_syncular_source_id UNINDEXED`. `MATCH :query`
471
+ infers a string parameter, and the FTS table reference is folded into its
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.
475
+
450
476
  **Typing fidelity** (what `bun:sqlite` actually exposes, and its honest
451
477
  boundary):
452
478
 
package/dist/emit-dart.js CHANGED
@@ -64,6 +64,14 @@ function emitSchemaValue(ir, indent) {
64
64
  lines.push(`${i} {'pattern': ${quote(scope.pattern)}, 'column': ${quote(scope.column)}},`);
65
65
  }
66
66
  lines.push(`${i} ],`);
67
+ if (table.ftsIndexes.length > 0) {
68
+ lines.push(`${i} 'ftsIndexes': [`);
69
+ for (const index of table.ftsIndexes) {
70
+ const columns = index.columns.map((column) => quote(column)).join(', ');
71
+ lines.push(`${i} {'name': ${quote(index.name)}, 'columns': [${columns}], 'tokenize': ${quote(index.tokenize)}},`);
72
+ }
73
+ lines.push(`${i} ],`);
74
+ }
67
75
  lines.push(`${i} },`);
68
76
  }
69
77
  lines.push(`${i} ],`);
@@ -64,6 +64,16 @@ function emitSchemaValue(ir, indent) {
64
64
  lines.push(`${i} JsonValue.obj("pattern" to JsonValue.of(${quote(scope.pattern)}), "column" to JsonValue.of(${quote(scope.column)})),`);
65
65
  }
66
66
  lines.push(`${i} )),`);
67
+ if (table.ftsIndexes.length > 0) {
68
+ lines.push(`${i} "ftsIndexes" to JsonValue.arr(listOf(`);
69
+ for (const index of table.ftsIndexes) {
70
+ const columns = index.columns
71
+ .map((column) => `JsonValue.of(${quote(column)})`)
72
+ .join(', ');
73
+ lines.push(`${i} JsonValue.obj("name" to JsonValue.of(${quote(index.name)}), "columns" to JsonValue.arr(listOf(${columns})), "tokenize" to JsonValue.of(${quote(index.tokenize)})),`);
74
+ }
75
+ lines.push(`${i} )),`);
76
+ }
67
77
  lines.push(`${i} ),`);
68
78
  }
69
79
  lines.push(`${i} )),`);
@@ -68,6 +68,16 @@ function emitSchemaValue(ir, indent) {
68
68
  lines.push(`${i} .object(["pattern": .string(${quote(scope.pattern)}), "column": .string(${quote(scope.column)})]),`);
69
69
  }
70
70
  lines.push(`${i} ]),`);
71
+ if (table.ftsIndexes.length > 0) {
72
+ lines.push(`${i} "ftsIndexes": .array([`);
73
+ for (const index of table.ftsIndexes) {
74
+ const columns = index.columns
75
+ .map((column) => `.string(${quote(column)})`)
76
+ .join(', ');
77
+ lines.push(`${i} .object(["name": .string(${quote(index.name)}), "columns": .array([${columns}]), "tokenize": .string(${quote(index.tokenize)})]),`);
78
+ }
79
+ lines.push(`${i} ]),`);
80
+ }
71
81
  lines.push(`${i} ]),`);
72
82
  }
73
83
  lines.push(`${i} ]),`);
package/dist/emit.js CHANGED
@@ -77,6 +77,14 @@ function emitSchema(ir) {
77
77
  }
78
78
  lines.push(' ],');
79
79
  }
80
+ if (table.ftsIndexes.length > 0) {
81
+ lines.push(' ftsIndexes: [');
82
+ for (const index of table.ftsIndexes) {
83
+ const cols = index.columns.map((column) => quote(column)).join(', ');
84
+ lines.push(` { name: ${quote(index.name)}, columns: [${cols}], tokenize: ${quote(index.tokenize)} },`);
85
+ }
86
+ lines.push(' ],');
87
+ }
80
88
  lines.push(' },');
81
89
  }
82
90
  lines.push(' ],');
package/dist/generate.js CHANGED
@@ -50,6 +50,14 @@ function buildTable(manifestTable, parsed) {
50
50
  }
51
51
  }
52
52
  const columns = applyEncryption(parsed, scopes, manifestTable.encryptedColumns);
53
+ for (const ftsIndex of parsed.ftsIndexes) {
54
+ for (const columnName of ftsIndex.columns) {
55
+ const column = columns.find((candidate) => candidate.name === columnName);
56
+ if (column?.encrypted === true) {
57
+ throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: FTS index ${JSON.stringify(ftsIndex.name)} cannot index encrypted column ${JSON.stringify(columnName)}`);
58
+ }
59
+ }
60
+ }
53
61
  return {
54
62
  name: parsed.name,
55
63
  primaryKey: parsed.primaryKey,
@@ -58,6 +66,7 @@ function buildTable(manifestTable, parsed) {
58
66
  // Indexes flow through from the migration parser (already validated:
59
67
  // columns exist, names unique per schema) in declaration order.
60
68
  indexes: parsed.indexes,
69
+ ftsIndexes: parsed.ftsIndexes,
61
70
  extensions: canonicalizeExtensions(manifestTable.extensions),
62
71
  };
63
72
  }
@@ -256,10 +265,16 @@ export function makeQueryDb(ir) {
256
265
  try {
257
266
  const columnNames = stmt.columnNames;
258
267
  // Execute once against the empty DB to populate decltype; result is [].
259
- stmt.all();
260
- const declaredTypes = stmt.declaredTypes;
261
268
  const paramsCount = stmt
262
269
  .paramsCount;
270
+ // An unbound FTS5 MATCH parameter is treated as the empty query and
271
+ // fails parsing. A numeric probe is valid for MATCH and LIMIT alike,
272
+ // while the empty database still guarantees no result rows.
273
+ const probe = /\bMATCH\b/i.test(sql)
274
+ ? Array.from({ length: paramsCount }, () => 1)
275
+ : [];
276
+ stmt.all(...probe);
277
+ const declaredTypes = stmt.declaredTypes;
263
278
  return { columnNames, declaredTypes, paramsCount };
264
279
  }
265
280
  finally {
package/dist/ir.d.ts CHANGED
@@ -37,6 +37,14 @@ export interface IrIndex {
37
37
  readonly columns: readonly string[];
38
38
  readonly unique: boolean;
39
39
  }
40
+ /** One client-local contentful FTS5 projection. It is owned by the
41
+ * synced table in whose `ftsIndexes` array it appears and never enters the
42
+ * row codec, server schema, scopes, or mutation surface. */
43
+ export interface IrFtsIndex {
44
+ readonly name: string;
45
+ readonly columns: readonly string[];
46
+ readonly tokenize: string;
47
+ }
40
48
  export interface IrTable {
41
49
  readonly name: string;
42
50
  readonly primaryKey: string;
@@ -46,6 +54,8 @@ export interface IrTable {
46
54
  /** Local secondary indexes (§migration subset), in declaration order.
47
55
  * Additive under irVersion 1: absent/empty for tables that declare none. */
48
56
  readonly indexes: readonly IrIndex[];
57
+ /** Client-local FTS5 projections, in migration declaration order. */
58
+ readonly ftsIndexes: readonly IrFtsIndex[];
49
59
  /** Reserved per-table hook slot (WP-49); empty object for now. */
50
60
  readonly extensions: Readonly<Record<string, unknown>>;
51
61
  }
package/dist/ir.js CHANGED
@@ -74,6 +74,15 @@ export function serializeIr(ir) {
74
74
  })),
75
75
  }
76
76
  : {}),
77
+ ...(table.ftsIndexes.length > 0
78
+ ? {
79
+ ftsIndexes: table.ftsIndexes.map((index) => ({
80
+ name: index.name,
81
+ columns: index.columns,
82
+ tokenize: index.tokenize,
83
+ })),
84
+ }
85
+ : {}),
77
86
  extensions: canonicalizeExtensions(table.extensions),
78
87
  })),
79
88
  subscriptions: ir.subscriptions.map((sub) => ({
package/dist/query.js CHANGED
@@ -439,7 +439,6 @@ export function toPositionalSql(sql) {
439
439
  const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
440
440
  // `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
441
441
  // tables that exist in the IR; SQLite has already proven every reference.
442
- const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`, 'gi');
443
442
  const RESERVED_ALIAS = new Set([
444
443
  'on',
445
444
  'where',
@@ -455,9 +454,14 @@ const RESERVED_ALIAS = new Set([
455
454
  'limit',
456
455
  'having',
457
456
  ]);
457
+ const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
458
+ const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`, 'gi');
458
459
  export function scanTableRefs(sql, ir) {
459
460
  const cleaned = stripCommentsAndStrings(sql);
460
- const known = new Map(ir.tables.map((t) => [t.name, t]));
461
+ const known = new Set(ir.tables.flatMap((table) => [
462
+ table.name,
463
+ ...table.ftsIndexes.map((index) => index.name),
464
+ ]));
461
465
  const refs = [];
462
466
  for (const m of cleaned.matchAll(TABLE_REF_RE)) {
463
467
  const table = m[1];
@@ -614,6 +618,9 @@ function inferParamType(paramName, sql, refs, ir) {
614
618
  const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'i');
615
619
  if (like.test(cleaned))
616
620
  return 'string';
621
+ const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'i');
622
+ if (match.test(cleaned))
623
+ return 'string';
617
624
  return null;
618
625
  }
619
626
  /**
@@ -665,6 +672,9 @@ export function inferParamTypeEvidence(paramName, sql, refs, ir) {
665
672
  const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'gi');
666
673
  if (like.test(cleaned))
667
674
  evidence.push('string');
675
+ const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'gi');
676
+ if (match.test(cleaned))
677
+ evidence.push('string');
668
678
  return evidence;
669
679
  }
670
680
  // -- fallback column typing (computed expressions) ----------------------------
@@ -711,7 +721,10 @@ function inferReactiveMetadata(sql, refs, ir, params, columns) {
711
721
  const byParam = new Map(params.map((param) => [param.name, param]));
712
722
  const dependencies = [];
713
723
  const coverage = [];
714
- for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
724
+ const ftsOwner = new Map(ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
725
+ for (const tableName of [
726
+ ...new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table)),
727
+ ].sort()) {
715
728
  const table = ir.tables.find((candidate) => candidate.name === tableName);
716
729
  const tableRefs = refs.filter((ref) => ref.table === tableName);
717
730
  const scopes = [];
@@ -821,6 +834,10 @@ export function synthesizeDdl(ir) {
821
834
  const unique = index.unique ? 'UNIQUE ' : '';
822
835
  lines.push(`CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`);
823
836
  }
837
+ for (const index of table.ftsIndexes) {
838
+ const tokenize = index.tokenize.replaceAll("'", "''");
839
+ lines.push(`CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`);
840
+ }
824
841
  }
825
842
  return lines.join('\n');
826
843
  }
@@ -864,7 +881,8 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
864
881
  };
865
882
  let described = analyze(sourceSql);
866
883
  const refs = scanTableRefs(sourceSql, ir);
867
- const tableSet = new Set(refs.map((r) => r.table));
884
+ const ftsOwner = new Map(ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
885
+ const tableSet = new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table));
868
886
  const tables = [...tableSet].sort();
869
887
  if (tables.length === 0) {
870
888
  throw new TypegenError(file, 'query reads no synced table — every named query must read at least one IR table (for exact invalidation)');
package/dist/sql.d.ts CHANGED
@@ -1,10 +1,12 @@
1
- import type { IrColumn, IrIndex } from './ir.js';
1
+ import type { IrColumn, IrFtsIndex, IrIndex } from './ir.js';
2
2
  export interface ParsedTable {
3
3
  readonly name: string;
4
4
  primaryKey: string;
5
5
  readonly columns: IrColumn[];
6
6
  /** Local secondary indexes, in declaration order (CREATE INDEX subset). */
7
7
  readonly indexes: IrIndex[];
8
+ /** Client-local contentful FTS5 projections. */
9
+ readonly ftsIndexes: IrFtsIndex[];
8
10
  }
9
11
  /**
10
12
  * Apply one migration file's SQL to the accumulated table map. Columns
package/dist/sql.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * - `ALTER TABLE name ADD [COLUMN] coldef`
9
9
  * - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`
10
10
  * - `DROP TABLE [IF EXISTS] name`
11
+ * - `CREATE VIRTUAL TABLE name USING fts5(cols…, content=table,
12
+ * [tokenize='allowlisted tokenizer'])` (RFC 0005 local projection)
11
13
  * - column defs: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL]
12
14
  * [DEFAULT literal]`
13
15
  * - `--` and C-style comments
@@ -115,7 +117,7 @@ function tokenizeStatements(sql, source) {
115
117
  current.push({ kind: 'number', text: sql.slice(i, end) });
116
118
  i = end;
117
119
  }
118
- else if (ch === '(' || ch === ')' || ch === ',') {
120
+ else if (ch === '(' || ch === ')' || ch === ',' || ch === '=') {
119
121
  current.push({ kind: 'punct', text: ch });
120
122
  i += 1;
121
123
  }
@@ -248,6 +250,10 @@ function parseColumnDef(cursor, allowPrimaryKey) {
248
250
  * `CREATE UNIQUE INDEX`. Anything else is a hard error naming the construct.
249
251
  */
250
252
  function parseCreate(cursor, tables, droppedTables, source) {
253
+ if (cursor.eatWord('VIRTUAL')) {
254
+ parseCreateVirtualTable(cursor, tables);
255
+ return;
256
+ }
251
257
  if (cursor.eatWord('TABLE')) {
252
258
  parseCreateTable(cursor, tables, droppedTables, source);
253
259
  return;
@@ -262,7 +268,109 @@ function parseCreate(cursor, tables, droppedTables, source) {
262
268
  return;
263
269
  }
264
270
  const token = cursor.peek();
265
- cursor.fail(`unsupported CREATE statement (only CREATE TABLE and CREATE [UNIQUE] INDEX), found ${JSON.stringify(token?.text ?? '')}`);
271
+ cursor.fail(`unsupported CREATE statement (only CREATE TABLE, CREATE [UNIQUE] INDEX, and CREATE VIRTUAL TABLE … USING fts5), found ${JSON.stringify(token?.text ?? '')}`);
272
+ }
273
+ const ALLOWED_FTS_TOKENIZERS = new Set([
274
+ 'unicode61',
275
+ 'unicode61 remove_diacritics 0',
276
+ 'unicode61 remove_diacritics 1',
277
+ 'unicode61 remove_diacritics 2',
278
+ 'porter unicode61',
279
+ 'trigram',
280
+ ]);
281
+ /** Parse the deliberately narrow migration-subset v2 FTS5 form documented in
282
+ * RFC 0005. The virtual table is attached to its owning synced
283
+ * table and is not itself a synced table. */
284
+ function parseCreateVirtualTable(cursor, tables) {
285
+ cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
286
+ if (cursor.eatWord('IF')) {
287
+ cursor.expectWord('NOT', 'after IF');
288
+ cursor.expectWord('EXISTS', 'after IF NOT');
289
+ }
290
+ const name = cursor.identifier('an FTS virtual-table name');
291
+ if (tables.has(name)) {
292
+ cursor.fail(`FTS virtual table ${name} conflicts with a synced table`);
293
+ }
294
+ for (const table of tables.values()) {
295
+ if (table.indexes.some((index) => index.name === name) ||
296
+ table.ftsIndexes.some((index) => index.name === name)) {
297
+ cursor.fail(`FTS virtual table ${name} is created twice or conflicts with an index`);
298
+ }
299
+ }
300
+ cursor.expectWord('USING', `after CREATE VIRTUAL TABLE ${name}`);
301
+ cursor.expectWord('FTS5', `as the module for ${name}`);
302
+ cursor.expectPunct('(', `after USING fts5 for ${name}`);
303
+ const columns = [];
304
+ let content;
305
+ let tokenize = 'unicode61';
306
+ let tokenizeDeclared = false;
307
+ let sawOption = false;
308
+ for (;;) {
309
+ const field = cursor.identifier(`an FTS5 column or option for ${name}`);
310
+ if (cursor.peek()?.text === '=') {
311
+ sawOption = true;
312
+ cursor.next();
313
+ const value = cursor.next();
314
+ if (value.kind !== 'word' && value.kind !== 'string') {
315
+ cursor.fail(`FTS virtual table ${name}: ${field} needs a literal value`);
316
+ }
317
+ if (field.toUpperCase() === 'CONTENT') {
318
+ if (content !== undefined) {
319
+ cursor.fail(`FTS virtual table ${name}: content is declared twice`);
320
+ }
321
+ content = value.text;
322
+ }
323
+ else if (field.toUpperCase() === 'TOKENIZE') {
324
+ if (tokenizeDeclared) {
325
+ cursor.fail(`FTS virtual table ${name}: tokenize is declared twice`);
326
+ }
327
+ if (!ALLOWED_FTS_TOKENIZERS.has(value.text)) {
328
+ cursor.fail(`FTS virtual table ${name}: tokenizer ${JSON.stringify(value.text)} is not allowlisted`);
329
+ }
330
+ tokenize = value.text;
331
+ tokenizeDeclared = true;
332
+ }
333
+ else {
334
+ cursor.fail(`FTS virtual table ${name}: unsupported FTS5 option ${JSON.stringify(field)}`);
335
+ }
336
+ }
337
+ else {
338
+ if (sawOption) {
339
+ cursor.fail(`FTS virtual table ${name}: indexed columns must precede FTS5 options`);
340
+ }
341
+ if (columns.includes(field)) {
342
+ cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(field)} appears twice`);
343
+ }
344
+ columns.push(field);
345
+ }
346
+ const separator = cursor.next();
347
+ if (separator.kind === 'punct' && separator.text === ',')
348
+ continue;
349
+ if (separator.kind === 'punct' && separator.text === ')')
350
+ break;
351
+ cursor.fail(`FTS virtual table ${name}: expected "," or ")", found ${JSON.stringify(separator.text)}`);
352
+ }
353
+ cursor.expectEnd();
354
+ if (content === undefined) {
355
+ cursor.fail(`FTS virtual table ${name}: content = synced_table is required`);
356
+ }
357
+ const table = tables.get(content);
358
+ if (table === undefined) {
359
+ cursor.fail(`FTS virtual table ${name}: content table ${JSON.stringify(content)} does not exist at this point`);
360
+ }
361
+ if (columns.length === 0 || columns.length > 32) {
362
+ cursor.fail(`FTS virtual table ${name}: between 1 and 32 columns are required`);
363
+ }
364
+ for (const columnName of columns) {
365
+ const column = table.columns.find((candidate) => candidate.name === columnName);
366
+ if (column === undefined) {
367
+ cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(columnName)} does not exist on table ${content}`);
368
+ }
369
+ if (column.type !== 'string') {
370
+ cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(columnName)} must have TEXT/string type`);
371
+ }
372
+ }
373
+ table.ftsIndexes.push({ name, columns, tokenize });
266
374
  }
267
375
  function parseCreateTable(cursor, tables, droppedTables, source) {
268
376
  if (cursor.eatWord('IF')) {
@@ -276,6 +384,12 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
276
384
  if (tables.has(name)) {
277
385
  cursor.fail(`table ${name} is created twice`);
278
386
  }
387
+ for (const table of tables.values()) {
388
+ if (table.indexes.some((index) => index.name === name) ||
389
+ table.ftsIndexes.some((index) => index.name === name)) {
390
+ cursor.fail(`table ${name} conflicts with an index or FTS virtual table`);
391
+ }
392
+ }
279
393
  cursor.expectPunct('(', `after CREATE TABLE ${name}`);
280
394
  const columns = [];
281
395
  const primaryKeys = [];
@@ -335,7 +449,13 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
335
449
  throw new TypegenError(source, `table ${name}: primary key ${JSON.stringify(primaryKey)} is not a column`);
336
450
  }
337
451
  const finalColumns = columns.map((c) => c.name === primaryKey ? { ...c, nullable: false } : c);
338
- tables.set(name, { name, primaryKey, columns: finalColumns, indexes: [] });
452
+ tables.set(name, {
453
+ name,
454
+ primaryKey,
455
+ columns: finalColumns,
456
+ indexes: [],
457
+ ftsIndexes: [],
458
+ });
339
459
  }
340
460
  /**
341
461
  * `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`.
@@ -352,9 +472,13 @@ function parseCreateIndex(cursor, tables, unique) {
352
472
  cursor.expectWord('EXISTS', 'after IF NOT');
353
473
  }
354
474
  const name = cursor.identifier('an index name');
475
+ if (tables.has(name)) {
476
+ cursor.fail(`index ${name} conflicts with a synced table`);
477
+ }
355
478
  for (const table of tables.values()) {
356
- if (table.indexes.some((i) => i.name === name)) {
357
- cursor.fail(`index ${name} is created twice`);
479
+ if (table.indexes.some((i) => i.name === name) ||
480
+ table.ftsIndexes.some((i) => i.name === name)) {
481
+ cursor.fail(`index ${name} is created twice or conflicts with an FTS virtual table`);
358
482
  }
359
483
  }
360
484
  cursor.expectWord('ON', `after CREATE INDEX ${name}`);
@@ -459,7 +583,7 @@ export function applyMigrationSql(tables, sql, source, droppedTables = new Set()
459
583
  parseDropTable(cursor, tables, droppedTables);
460
584
  }
461
585
  else {
462
- throw new TypegenError(source, `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, ALTER TABLE … ADD COLUMN, and DROP TABLE)`);
586
+ throw new TypegenError(source, `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, CREATE VIRTUAL TABLE … USING fts5, ALTER TABLE … ADD COLUMN, and DROP TABLE)`);
463
587
  }
464
588
  }
465
589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
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.13.0",
52
- "@syncular/server": "0.13.0"
51
+ "@syncular/core": "0.15.0",
52
+ "@syncular/server": "0.15.0"
53
53
  }
54
54
  }
package/src/emit-dart.ts CHANGED
@@ -91,6 +91,16 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
91
91
  );
92
92
  }
93
93
  lines.push(`${i} ],`);
94
+ if (table.ftsIndexes.length > 0) {
95
+ lines.push(`${i} 'ftsIndexes': [`);
96
+ for (const index of table.ftsIndexes) {
97
+ const columns = index.columns.map((column) => quote(column)).join(', ');
98
+ lines.push(
99
+ `${i} {'name': ${quote(index.name)}, 'columns': [${columns}], 'tokenize': ${quote(index.tokenize)}},`,
100
+ );
101
+ }
102
+ lines.push(`${i} ],`);
103
+ }
94
104
  lines.push(`${i} },`);
95
105
  }
96
106
  lines.push(`${i} ],`);
@@ -92,6 +92,18 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
92
92
  );
93
93
  }
94
94
  lines.push(`${i} )),`);
95
+ if (table.ftsIndexes.length > 0) {
96
+ lines.push(`${i} "ftsIndexes" to JsonValue.arr(listOf(`);
97
+ for (const index of table.ftsIndexes) {
98
+ const columns = index.columns
99
+ .map((column) => `JsonValue.of(${quote(column)})`)
100
+ .join(', ');
101
+ lines.push(
102
+ `${i} JsonValue.obj("name" to JsonValue.of(${quote(index.name)}), "columns" to JsonValue.arr(listOf(${columns})), "tokenize" to JsonValue.of(${quote(index.tokenize)})),`,
103
+ );
104
+ }
105
+ lines.push(`${i} )),`);
106
+ }
95
107
  lines.push(`${i} ),`);
96
108
  }
97
109
  lines.push(`${i} )),`);
package/src/emit-swift.ts CHANGED
@@ -96,6 +96,18 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
96
96
  );
97
97
  }
98
98
  lines.push(`${i} ]),`);
99
+ if (table.ftsIndexes.length > 0) {
100
+ lines.push(`${i} "ftsIndexes": .array([`);
101
+ for (const index of table.ftsIndexes) {
102
+ const columns = index.columns
103
+ .map((column) => `.string(${quote(column)})`)
104
+ .join(', ');
105
+ lines.push(
106
+ `${i} .object(["name": .string(${quote(index.name)}), "columns": .array([${columns}]), "tokenize": .string(${quote(index.tokenize)})]),`,
107
+ );
108
+ }
109
+ lines.push(`${i} ]),`);
110
+ }
99
111
  lines.push(`${i} ]),`);
100
112
  }
101
113
  lines.push(`${i} ]),`);
package/src/emit.ts CHANGED
@@ -108,6 +108,16 @@ function emitSchema(ir: IrDocument): string {
108
108
  }
109
109
  lines.push(' ],');
110
110
  }
111
+ if (table.ftsIndexes.length > 0) {
112
+ lines.push(' ftsIndexes: [');
113
+ for (const index of table.ftsIndexes) {
114
+ const cols = index.columns.map((column) => quote(column)).join(', ');
115
+ lines.push(
116
+ ` { name: ${quote(index.name)}, columns: [${cols}], tokenize: ${quote(index.tokenize)} },`,
117
+ );
118
+ }
119
+ lines.push(' ],');
120
+ }
111
121
  lines.push(' },');
112
122
  }
113
123
  lines.push(' ],');
package/src/generate.ts CHANGED
@@ -113,6 +113,17 @@ function buildTable(
113
113
  scopes,
114
114
  manifestTable.encryptedColumns,
115
115
  );
116
+ for (const ftsIndex of parsed.ftsIndexes) {
117
+ for (const columnName of ftsIndex.columns) {
118
+ const column = columns.find((candidate) => candidate.name === columnName);
119
+ if (column?.encrypted === true) {
120
+ throw new TypegenError(
121
+ MANIFEST_FILENAME,
122
+ `table ${parsed.name}: FTS index ${JSON.stringify(ftsIndex.name)} cannot index encrypted column ${JSON.stringify(columnName)}`,
123
+ );
124
+ }
125
+ }
126
+ }
116
127
  return {
117
128
  name: parsed.name,
118
129
  primaryKey: parsed.primaryKey,
@@ -121,6 +132,7 @@ function buildTable(
121
132
  // Indexes flow through from the migration parser (already validated:
122
133
  // columns exist, names unique per schema) in declaration order.
123
134
  indexes: parsed.indexes,
135
+ ftsIndexes: parsed.ftsIndexes,
124
136
  extensions: canonicalizeExtensions(manifestTable.extensions) as Record<
125
137
  string,
126
138
  unknown
@@ -410,12 +422,22 @@ export function makeQueryDb(ir: IrDocument): {
410
422
  try {
411
423
  const columnNames = stmt.columnNames;
412
424
  // Execute once against the empty DB to populate decltype; result is [].
413
- (stmt as unknown as { all: () => unknown[] }).all();
425
+ const paramsCount = (stmt as unknown as { paramsCount: number })
426
+ .paramsCount;
427
+ // An unbound FTS5 MATCH parameter is treated as the empty query and
428
+ // fails parsing. A numeric probe is valid for MATCH and LIMIT alike,
429
+ // while the empty database still guarantees no result rows.
430
+ const probe = /\bMATCH\b/i.test(sql)
431
+ ? Array.from({ length: paramsCount }, () => 1)
432
+ : [];
433
+ (
434
+ stmt as unknown as {
435
+ all: (...params: readonly number[]) => unknown[];
436
+ }
437
+ ).all(...probe);
414
438
  const declaredTypes = (
415
439
  stmt as unknown as { declaredTypes: (string | null)[] }
416
440
  ).declaredTypes;
417
- const paramsCount = (stmt as unknown as { paramsCount: number })
418
- .paramsCount;
419
441
  return { columnNames, declaredTypes, paramsCount };
420
442
  } finally {
421
443
  stmt.finalize();
package/src/ir.ts CHANGED
@@ -64,6 +64,15 @@ export interface IrIndex {
64
64
  readonly unique: boolean;
65
65
  }
66
66
 
67
+ /** One client-local contentful FTS5 projection. It is owned by the
68
+ * synced table in whose `ftsIndexes` array it appears and never enters the
69
+ * row codec, server schema, scopes, or mutation surface. */
70
+ export interface IrFtsIndex {
71
+ readonly name: string;
72
+ readonly columns: readonly string[];
73
+ readonly tokenize: string;
74
+ }
75
+
67
76
  export interface IrTable {
68
77
  readonly name: string;
69
78
  readonly primaryKey: string;
@@ -73,6 +82,8 @@ export interface IrTable {
73
82
  /** Local secondary indexes (§migration subset), in declaration order.
74
83
  * Additive under irVersion 1: absent/empty for tables that declare none. */
75
84
  readonly indexes: readonly IrIndex[];
85
+ /** Client-local FTS5 projections, in migration declaration order. */
86
+ readonly ftsIndexes: readonly IrFtsIndex[];
76
87
  /** Reserved per-table hook slot (WP-49); empty object for now. */
77
88
  readonly extensions: Readonly<Record<string, unknown>>;
78
89
  }
@@ -176,6 +187,15 @@ export function serializeIr(ir: IrDocument): string {
176
187
  })),
177
188
  }
178
189
  : {}),
190
+ ...(table.ftsIndexes.length > 0
191
+ ? {
192
+ ftsIndexes: table.ftsIndexes.map((index) => ({
193
+ name: index.name,
194
+ columns: index.columns,
195
+ tokenize: index.tokenize,
196
+ })),
197
+ }
198
+ : {}),
179
199
  extensions: canonicalizeExtensions(table.extensions),
180
200
  })),
181
201
  subscriptions: ir.subscriptions.map((sub) => ({
package/src/query.ts CHANGED
@@ -696,11 +696,6 @@ export interface TableRef {
696
696
  const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
697
697
  // `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
698
698
  // tables that exist in the IR; SQLite has already proven every reference.
699
- const TABLE_REF_RE = new RegExp(
700
- `\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`,
701
- 'gi',
702
- );
703
-
704
699
  const RESERVED_ALIAS = new Set([
705
700
  'on',
706
701
  'where',
@@ -716,10 +711,20 @@ const RESERVED_ALIAS = new Set([
716
711
  'limit',
717
712
  'having',
718
713
  ]);
714
+ const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
715
+ const TABLE_REF_RE = new RegExp(
716
+ `\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`,
717
+ 'gi',
718
+ );
719
719
 
720
720
  export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
721
721
  const cleaned = stripCommentsAndStrings(sql);
722
- const known = new Map(ir.tables.map((t) => [t.name, t] as const));
722
+ const known = new Set(
723
+ ir.tables.flatMap((table) => [
724
+ table.name,
725
+ ...table.ftsIndexes.map((index) => index.name),
726
+ ]),
727
+ );
723
728
  const refs: TableRef[] = [];
724
729
  for (const m of cleaned.matchAll(TABLE_REF_RE)) {
725
730
  const table = m[1] as string;
@@ -915,6 +920,8 @@ function inferParamType(
915
920
  // shape).
916
921
  const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'i');
917
922
  if (like.test(cleaned)) return 'string';
923
+ const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'i');
924
+ if (match.test(cleaned)) return 'string';
918
925
  return null;
919
926
  }
920
927
 
@@ -987,6 +994,8 @@ export function inferParamTypeEvidence(
987
994
  }
988
995
  const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'gi');
989
996
  if (like.test(cleaned)) evidence.push('string');
997
+ const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'gi');
998
+ if (match.test(cleaned)) evidence.push('string');
990
999
  return evidence;
991
1000
  }
992
1001
 
@@ -1054,8 +1063,15 @@ function inferReactiveMetadata(
1054
1063
  scopes: QueryScopeBinding[];
1055
1064
  }> = [];
1056
1065
  const coverage: QueryCoverageBinding[] = [];
1066
+ const ftsOwner = new Map(
1067
+ ir.tables.flatMap((table) =>
1068
+ table.ftsIndexes.map((index) => [index.name, table.name] as const),
1069
+ ),
1070
+ );
1057
1071
 
1058
- for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
1072
+ for (const tableName of [
1073
+ ...new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table)),
1074
+ ].sort()) {
1059
1075
  const table = ir.tables.find((candidate) => candidate.name === tableName);
1060
1076
  const tableRefs = refs.filter((ref) => ref.table === tableName);
1061
1077
  const scopes: QueryScopeBinding[] = [];
@@ -1192,6 +1208,12 @@ export function synthesizeDdl(ir: IrDocument): string {
1192
1208
  `CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`,
1193
1209
  );
1194
1210
  }
1211
+ for (const index of table.ftsIndexes) {
1212
+ const tokenize = index.tokenize.replaceAll("'", "''");
1213
+ lines.push(
1214
+ `CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`,
1215
+ );
1216
+ }
1195
1217
  }
1196
1218
  return lines.join('\n');
1197
1219
  }
@@ -1252,7 +1274,14 @@ export function analyzeStatement(
1252
1274
  let described = analyze(sourceSql);
1253
1275
 
1254
1276
  const refs = scanTableRefs(sourceSql, ir);
1255
- const tableSet = new Set(refs.map((r) => r.table));
1277
+ const ftsOwner = new Map(
1278
+ ir.tables.flatMap((table) =>
1279
+ table.ftsIndexes.map((index) => [index.name, table.name] as const),
1280
+ ),
1281
+ );
1282
+ const tableSet = new Set(
1283
+ refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table),
1284
+ );
1256
1285
  const tables = [...tableSet].sort();
1257
1286
  if (tables.length === 0) {
1258
1287
  throw new TypegenError(
package/src/sql.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  * - `ALTER TABLE name ADD [COLUMN] coldef`
9
9
  * - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`
10
10
  * - `DROP TABLE [IF EXISTS] name`
11
+ * - `CREATE VIRTUAL TABLE name USING fts5(cols…, content=table,
12
+ * [tokenize='allowlisted tokenizer'])` (RFC 0005 local projection)
11
13
  * - column defs: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL]
12
14
  * [DEFAULT literal]`
13
15
  * - `--` and C-style comments
@@ -18,7 +20,7 @@
18
20
  * indexes — is a hard error naming the unsupported construct.
19
21
  */
20
22
  import { TypegenError } from './errors';
21
- import type { IrColumn, IrColumnType, IrIndex } from './ir';
23
+ import type { IrColumn, IrColumnType, IrFtsIndex, IrIndex } from './ir';
22
24
 
23
25
  /** SQL type keyword → the §2.4 column types. Case-insensitive. */
24
26
  const TYPE_MAP: Readonly<Record<string, IrColumnType>> = {
@@ -56,6 +58,8 @@ export interface ParsedTable {
56
58
  readonly columns: IrColumn[];
57
59
  /** Local secondary indexes, in declaration order (CREATE INDEX subset). */
58
60
  readonly indexes: IrIndex[];
61
+ /** Client-local contentful FTS5 projections. */
62
+ readonly ftsIndexes: IrFtsIndex[];
59
63
  }
60
64
 
61
65
  interface Token {
@@ -122,7 +126,7 @@ function tokenizeStatements(sql: string, source: string): Token[][] {
122
126
  while (end < sql.length && /[0-9.]/.test(sql[end] as string)) end += 1;
123
127
  current.push({ kind: 'number', text: sql.slice(i, end) });
124
128
  i = end;
125
- } else if (ch === '(' || ch === ')' || ch === ',') {
129
+ } else if (ch === '(' || ch === ')' || ch === ',' || ch === '=') {
126
130
  current.push({ kind: 'punct', text: ch });
127
131
  i += 1;
128
132
  } else {
@@ -294,6 +298,10 @@ function parseCreate(
294
298
  droppedTables: ReadonlySet<string>,
295
299
  source: string,
296
300
  ): void {
301
+ if (cursor.eatWord('VIRTUAL')) {
302
+ parseCreateVirtualTable(cursor, tables);
303
+ return;
304
+ }
297
305
  if (cursor.eatWord('TABLE')) {
298
306
  parseCreateTable(cursor, tables, droppedTables, source);
299
307
  return;
@@ -309,10 +317,140 @@ function parseCreate(
309
317
  }
310
318
  const token = cursor.peek();
311
319
  cursor.fail(
312
- `unsupported CREATE statement (only CREATE TABLE and CREATE [UNIQUE] INDEX), found ${JSON.stringify(token?.text ?? '')}`,
320
+ `unsupported CREATE statement (only CREATE TABLE, CREATE [UNIQUE] INDEX, and CREATE VIRTUAL TABLE … USING fts5), found ${JSON.stringify(token?.text ?? '')}`,
313
321
  );
314
322
  }
315
323
 
324
+ const ALLOWED_FTS_TOKENIZERS = new Set([
325
+ 'unicode61',
326
+ 'unicode61 remove_diacritics 0',
327
+ 'unicode61 remove_diacritics 1',
328
+ 'unicode61 remove_diacritics 2',
329
+ 'porter unicode61',
330
+ 'trigram',
331
+ ]);
332
+
333
+ /** Parse the deliberately narrow migration-subset v2 FTS5 form documented in
334
+ * RFC 0005. The virtual table is attached to its owning synced
335
+ * table and is not itself a synced table. */
336
+ function parseCreateVirtualTable(
337
+ cursor: Cursor,
338
+ tables: Map<string, ParsedTable>,
339
+ ): void {
340
+ cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
341
+ if (cursor.eatWord('IF')) {
342
+ cursor.expectWord('NOT', 'after IF');
343
+ cursor.expectWord('EXISTS', 'after IF NOT');
344
+ }
345
+ const name = cursor.identifier('an FTS virtual-table name');
346
+ if (tables.has(name)) {
347
+ cursor.fail(`FTS virtual table ${name} conflicts with a synced table`);
348
+ }
349
+ for (const table of tables.values()) {
350
+ if (
351
+ table.indexes.some((index) => index.name === name) ||
352
+ table.ftsIndexes.some((index) => index.name === name)
353
+ ) {
354
+ cursor.fail(
355
+ `FTS virtual table ${name} is created twice or conflicts with an index`,
356
+ );
357
+ }
358
+ }
359
+ cursor.expectWord('USING', `after CREATE VIRTUAL TABLE ${name}`);
360
+ cursor.expectWord('FTS5', `as the module for ${name}`);
361
+ cursor.expectPunct('(', `after USING fts5 for ${name}`);
362
+ const columns: string[] = [];
363
+ let content: string | undefined;
364
+ let tokenize = 'unicode61';
365
+ let tokenizeDeclared = false;
366
+ let sawOption = false;
367
+ for (;;) {
368
+ const field = cursor.identifier(`an FTS5 column or option for ${name}`);
369
+ if (cursor.peek()?.text === '=') {
370
+ sawOption = true;
371
+ cursor.next();
372
+ const value = cursor.next();
373
+ if (value.kind !== 'word' && value.kind !== 'string') {
374
+ cursor.fail(
375
+ `FTS virtual table ${name}: ${field} needs a literal value`,
376
+ );
377
+ }
378
+ if (field.toUpperCase() === 'CONTENT') {
379
+ if (content !== undefined) {
380
+ cursor.fail(`FTS virtual table ${name}: content is declared twice`);
381
+ }
382
+ content = value.text;
383
+ } else if (field.toUpperCase() === 'TOKENIZE') {
384
+ if (tokenizeDeclared) {
385
+ cursor.fail(`FTS virtual table ${name}: tokenize is declared twice`);
386
+ }
387
+ if (!ALLOWED_FTS_TOKENIZERS.has(value.text)) {
388
+ cursor.fail(
389
+ `FTS virtual table ${name}: tokenizer ${JSON.stringify(value.text)} is not allowlisted`,
390
+ );
391
+ }
392
+ tokenize = value.text;
393
+ tokenizeDeclared = true;
394
+ } else {
395
+ cursor.fail(
396
+ `FTS virtual table ${name}: unsupported FTS5 option ${JSON.stringify(field)}`,
397
+ );
398
+ }
399
+ } else {
400
+ if (sawOption) {
401
+ cursor.fail(
402
+ `FTS virtual table ${name}: indexed columns must precede FTS5 options`,
403
+ );
404
+ }
405
+ if (columns.includes(field)) {
406
+ cursor.fail(
407
+ `FTS virtual table ${name}: column ${JSON.stringify(field)} appears twice`,
408
+ );
409
+ }
410
+ columns.push(field);
411
+ }
412
+ const separator = cursor.next();
413
+ if (separator.kind === 'punct' && separator.text === ',') continue;
414
+ if (separator.kind === 'punct' && separator.text === ')') break;
415
+ cursor.fail(
416
+ `FTS virtual table ${name}: expected "," or ")", found ${JSON.stringify(separator.text)}`,
417
+ );
418
+ }
419
+ cursor.expectEnd();
420
+ if (content === undefined) {
421
+ cursor.fail(
422
+ `FTS virtual table ${name}: content = synced_table is required`,
423
+ );
424
+ }
425
+ const table = tables.get(content);
426
+ if (table === undefined) {
427
+ cursor.fail(
428
+ `FTS virtual table ${name}: content table ${JSON.stringify(content)} does not exist at this point`,
429
+ );
430
+ }
431
+ if (columns.length === 0 || columns.length > 32) {
432
+ cursor.fail(
433
+ `FTS virtual table ${name}: between 1 and 32 columns are required`,
434
+ );
435
+ }
436
+ for (const columnName of columns) {
437
+ const column = table.columns.find(
438
+ (candidate) => candidate.name === columnName,
439
+ );
440
+ if (column === undefined) {
441
+ cursor.fail(
442
+ `FTS virtual table ${name}: column ${JSON.stringify(columnName)} does not exist on table ${content}`,
443
+ );
444
+ }
445
+ if (column.type !== 'string') {
446
+ cursor.fail(
447
+ `FTS virtual table ${name}: column ${JSON.stringify(columnName)} must have TEXT/string type`,
448
+ );
449
+ }
450
+ }
451
+ table.ftsIndexes.push({ name, columns, tokenize });
452
+ }
453
+
316
454
  function parseCreateTable(
317
455
  cursor: Cursor,
318
456
  tables: Map<string, ParsedTable>,
@@ -332,6 +470,14 @@ function parseCreateTable(
332
470
  if (tables.has(name)) {
333
471
  cursor.fail(`table ${name} is created twice`);
334
472
  }
473
+ for (const table of tables.values()) {
474
+ if (
475
+ table.indexes.some((index) => index.name === name) ||
476
+ table.ftsIndexes.some((index) => index.name === name)
477
+ ) {
478
+ cursor.fail(`table ${name} conflicts with an index or FTS virtual table`);
479
+ }
480
+ }
335
481
  cursor.expectPunct('(', `after CREATE TABLE ${name}`);
336
482
  const columns: IrColumn[] = [];
337
483
  const primaryKeys: string[] = [];
@@ -401,7 +547,13 @@ function parseCreateTable(
401
547
  const finalColumns = columns.map((c) =>
402
548
  c.name === primaryKey ? { ...c, nullable: false } : c,
403
549
  );
404
- tables.set(name, { name, primaryKey, columns: finalColumns, indexes: [] });
550
+ tables.set(name, {
551
+ name,
552
+ primaryKey,
553
+ columns: finalColumns,
554
+ indexes: [],
555
+ ftsIndexes: [],
556
+ });
405
557
  }
406
558
 
407
559
  /**
@@ -423,9 +575,17 @@ function parseCreateIndex(
423
575
  cursor.expectWord('EXISTS', 'after IF NOT');
424
576
  }
425
577
  const name = cursor.identifier('an index name');
578
+ if (tables.has(name)) {
579
+ cursor.fail(`index ${name} conflicts with a synced table`);
580
+ }
426
581
  for (const table of tables.values()) {
427
- if (table.indexes.some((i) => i.name === name)) {
428
- cursor.fail(`index ${name} is created twice`);
582
+ if (
583
+ table.indexes.some((i) => i.name === name) ||
584
+ table.ftsIndexes.some((i) => i.name === name)
585
+ ) {
586
+ cursor.fail(
587
+ `index ${name} is created twice or conflicts with an FTS virtual table`,
588
+ );
429
589
  }
430
590
  }
431
591
  cursor.expectWord('ON', `after CREATE INDEX ${name}`);
@@ -559,7 +719,7 @@ export function applyMigrationSql(
559
719
  } else {
560
720
  throw new TypegenError(
561
721
  source,
562
- `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, ALTER TABLE … ADD COLUMN, and DROP TABLE)`,
722
+ `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, CREATE VIRTUAL TABLE … USING fts5, ALTER TABLE … ADD COLUMN, and DROP TABLE)`,
563
723
  );
564
724
  }
565
725
  }