@syncular/client 0.30.10 → 0.30.12

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.
@@ -453,12 +453,12 @@ export function readPreviousVersionContainer(
453
453
  db: ClientDatabase,
454
454
  ): PreviousVersionRecord | undefined {
455
455
  const meta = db.query(
456
- "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?",
456
+ "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
457
457
  [CONTAINER_META_TABLE],
458
458
  );
459
459
  if (meta.length === 0) return undefined;
460
460
  const raw = db.query(
461
- `SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id = 1`,
461
+ `SELECT record FROM ${quoteIdent(CONTAINER_META_TABLE)} WHERE id=1`,
462
462
  )[0]?.record;
463
463
  if (raw === undefined) return undefined;
464
464
  try {
@@ -504,13 +504,11 @@ function decodeRecord(value: string): PreviousVersionRecord {
504
504
 
505
505
  function writeRecord(db: ClientDatabase, record: PreviousVersionRecord): void {
506
506
  db.exec(
507
- `CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)} (
508
- id INTEGER PRIMARY KEY CHECK (id = 1),
509
- record TEXT NOT NULL)`,
507
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(CONTAINER_META_TABLE)}(id INTEGER PRIMARY KEY CHECK(id=1),record TEXT NOT NULL)`,
510
508
  );
511
509
  db.exec(`DELETE FROM ${quoteIdent(CONTAINER_META_TABLE)}`);
512
510
  db.exec(
513
- `INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id, record) VALUES (1, ?)`,
511
+ `INSERT INTO ${quoteIdent(CONTAINER_META_TABLE)}(id,record)VALUES(1,?)`,
514
512
  [JSON.stringify(record)],
515
513
  );
516
514
  }
@@ -541,9 +539,7 @@ export function capturePreviousVersion(
541
539
  const discovered = new Set(
542
540
  replica
543
541
  .query(
544
- `SELECT name FROM sqlite_master WHERE type = 'table'
545
- AND name NOT LIKE '_syncular_%'
546
- AND name NOT LIKE 'sqlite_%'`,
542
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
547
543
  )
548
544
  .map((row) => String(row.name)),
549
545
  );
@@ -567,7 +563,7 @@ export function capturePreviousVersion(
567
563
  for (const table of tables) {
568
564
  const probeRows = Number(
569
565
  replica.query(
570
- `SELECT COUNT(*) AS count FROM (SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`,
566
+ `SELECT COUNT(*)AS count FROM(SELECT 1 FROM ${quoteIdent(table.name)} LIMIT ?)`,
571
567
  [config.maxRows + 1],
572
568
  )[0]?.count ?? 0,
573
569
  );
@@ -584,15 +580,12 @@ export function capturePreviousVersion(
584
580
  .join(' + ');
585
581
  if (table.columns.length === 0) continue;
586
582
  const oversized = replica.query(
587
- `SELECT 1 AS hit FROM ${quoteIdent(table.name)}
588
- WHERE (${byteSum}) > ? LIMIT 1`,
583
+ `SELECT 1 AS hit FROM ${quoteIdent(table.name)} WHERE(${byteSum})>? LIMIT 1`,
589
584
  [config.maxRowBytes],
590
585
  );
591
586
  if (oversized.length > 0) return over(measuredRows, measuredBytes);
592
587
  const total = replica.query(
593
- `SELECT COALESCE(SUM(bytes), 0) AS total FROM (
594
- SELECT (${byteSum}) AS bytes FROM ${quoteIdent(table.name)} LIMIT ?
595
- )`,
588
+ `SELECT COALESCE(SUM(bytes),0)AS total FROM(SELECT(${byteSum})AS bytes FROM ${quoteIdent(table.name)} LIMIT ?)`,
596
589
  [perTableRows[index] ?? 0],
597
590
  )[0]?.total;
598
591
  measuredBytes += Number(total ?? 0);
@@ -604,11 +597,7 @@ export function capturePreviousVersion(
604
597
  container.transaction(() => {
605
598
  dropPreviousVersionContainer(container);
606
599
  container.exec(
607
- `CREATE TABLE ${quoteIdent(CONTAINER_TABLE)} (
608
- tbl TEXT NOT NULL,
609
- row_id TEXT NOT NULL,
610
- payload TEXT NOT NULL,
611
- PRIMARY KEY (tbl, row_id))`,
600
+ `CREATE TABLE ${quoteIdent(CONTAINER_TABLE)}(tbl TEXT NOT NULL,row_id TEXT NOT NULL,payload TEXT NOT NULL,PRIMARY KEY(tbl,row_id))`,
612
601
  );
613
602
  for (const table of tables) {
614
603
  const selectColumns = table.columns
@@ -617,8 +606,7 @@ export function capturePreviousVersion(
617
606
  let lastRowId = -1;
618
607
  for (;;) {
619
608
  const rows = replica.query(
620
- `SELECT rowid AS _rid, ${selectColumns} FROM ${quoteIdent(table.name)}
621
- WHERE rowid > ? ORDER BY rowid ASC LIMIT ?`,
609
+ `SELECT rowid AS _rid,${selectColumns} FROM ${quoteIdent(table.name)} WHERE rowid>? ORDER BY rowid ASC LIMIT ?`,
622
610
  [lastRowId, COPY_BATCH_ROWS],
623
611
  );
624
612
  if (rows.length === 0) break;
@@ -632,8 +620,7 @@ export function capturePreviousVersion(
632
620
  payload[column.name] = rowValueToJson(value);
633
621
  }
634
622
  container.exec(
635
- `INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl, row_id, payload)
636
- VALUES (?, ?, ?)`,
623
+ `INSERT INTO ${quoteIdent(CONTAINER_TABLE)}(tbl,row_id,payload)VALUES(?,?,?)`,
637
624
  [table.name, rowIdText(table, row), JSON.stringify(payload)],
638
625
  );
639
626
  }
@@ -703,8 +690,7 @@ export function readPreviousVersionRows(
703
690
  ? ''
704
691
  : ` AND row_id IN (${rowIds.map(() => '?').join(', ')})`;
705
692
  const rows = db.query(
706
- `SELECT row_id, payload FROM ${quoteIdent(CONTAINER_TABLE)}
707
- WHERE tbl = ?${where} ORDER BY row_id ASC LIMIT ?`,
693
+ `SELECT row_id,payload FROM ${quoteIdent(CONTAINER_TABLE)} WHERE tbl=?${where} ORDER BY row_id ASC LIMIT ?`,
708
694
  [table.name, ...rowIds, limit + 1],
709
695
  );
710
696
  const truncated = rows.length > limit;
@@ -738,7 +724,7 @@ export function writePreviousVersionRefusal(
738
724
 
739
725
  /** Delete the durable refusal. Part of the orphan sweep and of a successful capture. */
740
726
  export function clearPreviousVersionRefusal(db: ClientDatabase): void {
741
- db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
727
+ db.exec('DELETE FROM _syncular_meta WHERE key=?', [
742
728
  PREVIOUS_VERSION_CONTEXT_KEY,
743
729
  ]);
744
730
  }
@@ -906,7 +892,7 @@ export function writePreviousVersionAudit(
906
892
 
907
893
  /** Delete the advisory audit. Part of a discard, never of a normal bump. */
908
894
  export function clearPreviousVersionAudit(db: ClientDatabase): void {
909
- db.exec('DELETE FROM _syncular_meta WHERE key = ?', [
895
+ db.exec('DELETE FROM _syncular_meta WHERE key=?', [
910
896
  PREVIOUS_VERSION_AUDIT_KEY,
911
897
  ]);
912
898
  }
package/src/schema.ts CHANGED
@@ -92,17 +92,22 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
92
92
  'trigram',
93
93
  ]);
94
94
 
95
+ function invalidSchema(message: string): never {
96
+ throw new Error(message);
97
+ }
98
+
95
99
  export function compileClientSchema(
96
100
  schema: ClientSchema,
97
101
  ): CompiledClientSchema {
98
102
  const tables = new Map<string, CompiledClientTable>();
99
103
  const schemaObjectNames = new Set<string>();
100
104
  for (const table of schema.tables) {
105
+ const tableLabel = `table ${table.name}`;
101
106
  if (tables.has(table.name)) {
102
- throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
107
+ invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
103
108
  }
104
109
  if (schemaObjectNames.has(table.name)) {
105
- throw new Error(
110
+ invalidSchema(
106
111
  `table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`,
107
112
  );
108
113
  }
@@ -110,36 +115,32 @@ export function compileClientSchema(
110
115
  const columnIndex = new Map<string, number>();
111
116
  table.columns.forEach((column, index) => {
112
117
  if (columnIndex.has(column.name)) {
113
- throw new Error(
114
- `table ${table.name}: duplicate column ${JSON.stringify(column.name)}`,
118
+ invalidSchema(
119
+ `${tableLabel}: duplicate column ${JSON.stringify(column.name)}`,
115
120
  );
116
121
  }
117
122
  columnIndex.set(column.name, index);
118
123
  });
119
124
  const primaryKeyIndex = columnIndex.get(table.primaryKey);
120
125
  if (primaryKeyIndex === undefined) {
121
- throw new Error(
122
- `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
126
+ invalidSchema(
127
+ `${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
123
128
  );
124
129
  }
125
130
  // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
126
131
  // string every implementation produces identically and local storage
127
132
  // resolves without deferring to a SQLite build.
128
- const primaryKeyColumn = table.columns[primaryKeyIndex];
133
+ const primaryKeyColumn = table.columns[primaryKeyIndex] as RowColumn;
129
134
  if (
130
- primaryKeyColumn !== undefined &&
131
- primaryKeyColumn.type !== 'string' &&
132
- primaryKeyColumn.type !== 'integer' &&
133
- primaryKeyColumn.type !== 'boolean' &&
134
- primaryKeyColumn.type !== 'json'
135
+ !['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)
135
136
  ) {
136
- throw new Error(
137
- `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
137
+ invalidSchema(
138
+ `${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`,
138
139
  );
139
140
  }
140
141
  if (table.scopes.length === 0) {
141
- throw new Error(
142
- `table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
142
+ invalidSchema(
143
+ `${tableLabel}: every synced table declares at least one scope pattern (§3.1)`,
143
144
  );
144
145
  }
145
146
  const scopeColumnByVariable = new Map<string, string>();
@@ -148,22 +149,22 @@ export function compileClientSchema(
148
149
  const pattern = typeof spec === 'string' ? spec : spec.pattern;
149
150
  const match = PATTERN_RE.exec(pattern);
150
151
  if (match === null || match[1] === undefined || match[2] === undefined) {
151
- throw new Error(
152
- `table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
152
+ invalidSchema(
153
+ `${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
153
154
  );
154
155
  }
155
156
  const prefix = match[1];
156
157
  const variable = match[2];
157
158
  const column = typeof spec === 'string' ? variable : spec.column;
158
159
  if (!columnIndex.has(column)) {
159
- throw new Error(
160
- `table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
160
+ invalidSchema(
161
+ `${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
161
162
  );
162
163
  }
163
164
  const existing = scopeColumnByVariable.get(variable);
164
165
  if (existing !== undefined && existing !== column) {
165
- throw new Error(
166
- `table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
166
+ invalidSchema(
167
+ `${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
167
168
  );
168
169
  }
169
170
  scopeColumnByVariable.set(variable, column);
@@ -183,61 +184,34 @@ export function compileClientSchema(
183
184
  });
184
185
  for (const alias of ambiguous) columnIndexByCamel.delete(alias);
185
186
  const indexes = table.indexes ?? [];
186
- for (const index of indexes) {
187
- if (schemaObjectNames.has(index.name)) {
188
- throw new Error(
189
- `table ${table.name}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
190
- );
191
- }
192
- schemaObjectNames.add(index.name);
193
- for (const column of index.columns) {
194
- if (!columnIndex.has(column)) {
195
- throw new Error(
196
- `table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
197
- );
198
- }
199
- }
200
- }
201
187
  const ftsIndexes = table.ftsIndexes ?? [];
202
- for (const index of ftsIndexes) {
203
- if (schemaObjectNames.has(index.name)) {
204
- throw new Error(
205
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} conflicts with another schema object`,
206
- );
207
- }
188
+ for (const index of [...indexes, ...ftsIndexes]) {
189
+ const fts = 'tokenize' in index;
190
+ const label = `${tableLabel}: ${fts ? 'FTS projection' : 'index'} ${JSON.stringify(index.name)}`;
191
+ if (schemaObjectNames.has(index.name))
192
+ invalidSchema(`${label} conflicts with another schema object`);
208
193
  schemaObjectNames.add(index.name);
209
- if (index.columns.length === 0 || index.columns.length > 32) {
210
- throw new Error(
211
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} needs between 1 and 32 columns`,
212
- );
213
- }
214
- const seenColumns = new Set<string>();
215
- for (const columnName of index.columns) {
216
- if (seenColumns.has(columnName)) {
217
- throw new Error(
218
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} repeats column ${JSON.stringify(columnName)}`,
219
- );
220
- }
221
- seenColumns.add(columnName);
222
- const column = table.columns.find(
223
- (candidate) => candidate.name === columnName,
224
- );
225
- if (column === undefined) {
226
- throw new Error(
227
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(columnName)}`,
194
+ if (fts && (!index.columns.length || index.columns.length > 32))
195
+ invalidSchema(`${label} needs between 1 and 32 columns`);
196
+ const seen = new Set<string>();
197
+ for (const name of index.columns) {
198
+ if (fts && seen.has(name))
199
+ invalidSchema(`${label} repeats column ${JSON.stringify(name)}`);
200
+ seen.add(name);
201
+ const column = table.columns[columnIndex.get(name) ?? -1];
202
+ if (!column)
203
+ invalidSchema(
204
+ `${label} names unknown column ${JSON.stringify(name)}`,
228
205
  );
229
- }
230
- if (localColumnType(column) !== 'string') {
231
- throw new Error(
232
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`,
206
+ if (fts && localColumnType(column) !== 'string')
207
+ invalidSchema(
208
+ `${label} column ${JSON.stringify(name)} must have string type`,
233
209
  );
234
- }
235
210
  }
236
- if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
237
- throw new Error(
238
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
211
+ if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
212
+ invalidSchema(
213
+ `${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
239
214
  );
240
- }
241
215
  }
242
216
  tables.set(table.name, {
243
217
  name: table.name,
@@ -291,6 +265,21 @@ export function stripSyncColumns(rows: SqlRow[]): SqlRow[] {
291
265
  });
292
266
  }
293
267
 
268
+ export function matchesLocalScopes(
269
+ table: CompiledClientTable,
270
+ scopes: Readonly<Record<string, readonly string[]>>,
271
+ values: Readonly<Record<string, JsonRowValue>>,
272
+ ): boolean {
273
+ const entries = Object.entries(scopes);
274
+ return (
275
+ entries.length > 0 &&
276
+ entries.every(([variable, allowed]) => {
277
+ const column = table.scopeColumnByVariable.get(variable);
278
+ return column !== undefined && allowed.includes(String(values[column]));
279
+ })
280
+ );
281
+ }
282
+
294
283
  export function quoteIdent(name: string): string {
295
284
  return `"${name.replaceAll('"', '""')}"`;
296
285
  }
@@ -344,7 +333,7 @@ function createSyncedTable(
344
333
  });
345
334
  columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
346
335
  db.exec(
347
- `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`,
336
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`,
348
337
  );
349
338
  // Local secondary indexes (CREATE INDEX subset). Created here so both the
350
339
  // initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
@@ -354,7 +343,7 @@ function createSyncedTable(
354
343
  const unique = index.unique ? 'UNIQUE ' : '';
355
344
  const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
356
345
  db.exec(
357
- `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${cols})`,
346
+ `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`,
358
347
  );
359
348
  }
360
349
  }
@@ -382,6 +371,9 @@ function createFtsProjection(
382
371
  table: CompiledClientTable,
383
372
  index: ClientFtsIndexSpec,
384
373
  ): void {
374
+ const projection = quoteIdent(index.name);
375
+ const sourceTable = quoteIdent(table.name);
376
+ const primaryKey = quoteIdent(table.primaryKey);
385
377
  const existed =
386
378
  db.query(
387
379
  "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?",
@@ -390,7 +382,7 @@ function createFtsProjection(
390
382
  const indexedColumns = index.columns.map(quoteIdent);
391
383
  const tokenizer = index.tokenize.replaceAll("'", "''");
392
384
  db.exec(
393
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
385
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`,
394
386
  );
395
387
 
396
388
  const mapping = quoteIdent(`_syncular_fts_${index.name}`);
@@ -399,11 +391,11 @@ function createFtsProjection(
399
391
  `_syncular_fts_${index.name}`,
400
392
  ]).length > 0;
401
393
  db.exec(
402
- `CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
394
+ `CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`,
403
395
  );
404
- const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
405
- const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
406
- const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
396
+ const sourceId = `CAST(${primaryKey} AS TEXT)`;
397
+ const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
398
+ const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
407
399
  const projectionColumns = [
408
400
  quoteIdent(FTS_SOURCE_ID_COLUMN),
409
401
  ...indexedColumns,
@@ -413,10 +405,10 @@ function createFtsProjection(
413
405
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
414
406
  ].join(', ');
415
407
  const deleteFor = (value: string) =>
416
- `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
408
+ `DELETE FROM ${projection} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
417
409
  const deleteDisplaced = (select: string) =>
418
- `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select})); DELETE FROM ${mapping} WHERE source_id IN (${select})`;
419
- const insertNew = `INSERT INTO ${mapping}(source_id) VALUES (${newSourceId}); INSERT INTO ${quoteIdent(index.name)} (rowid, ${projectionColumns}) VALUES ((SELECT id FROM ${mapping} WHERE source_id = ${newSourceId}), ${newValues})`;
410
+ `DELETE FROM ${projection} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));DELETE FROM ${mapping} WHERE source_id IN (${select})`;
411
+ const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${projection}(rowid,${projectionColumns})VALUES((SELECT id FROM ${mapping} WHERE source_id=${newSourceId}),${newValues})`;
420
412
 
421
413
  // A clean insert cannot already have a projection row because the source
422
414
  // primary key is unique. Keep clean inserts linear by moving replacement
@@ -445,7 +437,7 @@ function createFtsProjection(
445
437
  for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
446
438
  db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
447
439
  }
448
- const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
440
+ const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
449
441
  // Per secondary UNIQUE index: the different-PK rows about to be displaced
450
442
  // via that unique key. `=` makes NULL unique values match nothing, which is
451
443
  // exactly SQLite's unique-index semantics (NULLs never conflict).
@@ -454,7 +446,7 @@ function createFtsProjection(
454
446
  const match = spec.columns
455
447
  .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
456
448
  .join(' AND ');
457
- return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
449
+ return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
458
450
  });
459
451
  const insertGuardCondition = [
460
452
  replacementExists,
@@ -465,17 +457,22 @@ function createFtsProjection(
465
457
  ...displacedByUnique.map(deleteDisplaced),
466
458
  ].join('; ');
467
459
 
468
- db.exec(
469
- `CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
470
- );
471
- db.exec(
472
- `CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`,
473
- );
474
- db.exec(
475
- `CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`,
476
- );
477
- db.exec(
478
- `CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`,
460
+ const createTrigger = (
461
+ suffix: string,
462
+ timing: string,
463
+ body: string,
464
+ when = '',
465
+ ) =>
466
+ db.exec(
467
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`,
468
+ );
469
+ createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
470
+ createTrigger('ai', 'AFTER INSERT', insertNew);
471
+ createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
472
+ createTrigger(
473
+ 'au',
474
+ 'AFTER UPDATE',
475
+ `${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`,
479
476
  );
480
477
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
481
478
  // different-PK row about to be displaced through a secondary unique index by
@@ -486,20 +483,18 @@ function createFtsProjection(
486
483
  .map((select) => `EXISTS (${select})`)
487
484
  .join(' OR ');
488
485
  const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
489
- db.exec(
490
- `CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
491
- );
486
+ createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
492
487
  }
493
488
 
494
489
  if (!existed) {
495
490
  db.exec(
496
- `INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
491
+ `INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`,
497
492
  );
498
493
  }
499
494
  if (!mapped || !existed) {
500
495
  db.exec(`DELETE FROM ${mapping}`);
501
496
  db.exec(
502
- `INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
497
+ `INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`,
503
498
  );
504
499
  }
505
500
  }
@@ -534,39 +529,39 @@ export function ensureLocalSyncedSchema(
534
529
  export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
535
530
  db.transaction(() => {
536
531
  ensureFailedOverlaySchema(db);
537
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
538
- key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
539
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
540
- commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
532
+ const createTable = (name: string, columns: string) =>
533
+ db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
534
+ createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
535
+ createTable(
536
+ '_syncular_blob_commit_refs',
537
+ 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
538
+ );
541
539
  db.exec(
542
540
  'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
543
541
  );
544
542
  db.exec(
545
- `INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`,
543
+ "INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')",
544
+ );
545
+ createTable(
546
+ '_syncular_outbox',
547
+ 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
548
+ );
549
+ createTable(
550
+ '_syncular_outbox_before_images',
551
+ 'client_commit_id TEXT NOT NULL,op_index INTEGER NOT NULL,existed INTEGER NOT NULL CHECK(existed IN (0,1)),sync_version INTEGER,values_json TEXT,PRIMARY KEY(client_commit_id,op_index)',
552
+ );
553
+ if (
554
+ !db
555
+ .query('PRAGMA table_info(_syncular_outbox_before_images)')
556
+ .some((row) => row.name === 'delivery_seq')
557
+ )
558
+ db.exec(
559
+ 'ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER',
560
+ );
561
+ createTable(
562
+ '_syncular_commit_outcomes',
563
+ "seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,status TEXT NOT NULL CHECK(status IN ('applied','cached','conflict','rejected')),recorded_at_ms INTEGER NOT NULL,results TEXT NOT NULL,operations TEXT,resolution TEXT NOT NULL DEFAULT 'active' CHECK(resolution IN ('active','resolved_keep_server','superseded','dismissed')),resolved_at_ms INTEGER,replacement_client_commit_id TEXT",
546
564
  );
547
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
548
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
549
- client_commit_id TEXT NOT NULL UNIQUE,
550
- created_at_ms INTEGER NOT NULL,
551
- operations TEXT NOT NULL)`);
552
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
553
- client_commit_id TEXT NOT NULL,
554
- op_index INTEGER NOT NULL,
555
- existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
556
- sync_version INTEGER,
557
- values_json TEXT,
558
- PRIMARY KEY(client_commit_id, op_index))`);
559
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
560
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
561
- client_commit_id TEXT NOT NULL UNIQUE,
562
- status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
563
- recorded_at_ms INTEGER NOT NULL,
564
- results TEXT NOT NULL,
565
- operations TEXT,
566
- resolution TEXT NOT NULL DEFAULT 'active'
567
- CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
568
- resolved_at_ms INTEGER,
569
- replacement_client_commit_id TEXT)`);
570
565
  // Outcomes created before durable aggregate recovery retain no commit
571
566
  // envelope. New failed outcomes store it in the protected client DB.
572
567
  try {
@@ -576,32 +571,26 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
576
571
  } catch {
577
572
  // column already exists — the CREATE above included it
578
573
  }
579
- db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
580
- ON _syncular_commit_outcomes(resolution, seq)`);
581
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
582
- id TEXT PRIMARY KEY,
583
- tbl TEXT NOT NULL,
584
- requested_scopes TEXT NOT NULL,
585
- params TEXT,
586
- cursor INTEGER NOT NULL DEFAULT -1,
587
- bootstrap_state TEXT,
588
- effective_scopes TEXT,
589
- status TEXT NOT NULL DEFAULT 'active',
590
- reason_code TEXT)`);
574
+ db.exec(
575
+ 'CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)',
576
+ );
577
+ createTable(
578
+ '_syncular_subscriptions',
579
+ "id TEXT PRIMARY KEY,tbl TEXT NOT NULL,requested_scopes TEXT NOT NULL,params TEXT,cursor INTEGER NOT NULL DEFAULT -1,bootstrap_state TEXT,effective_scopes TEXT,status TEXT NOT NULL DEFAULT 'active',reason_code TEXT",
580
+ );
591
581
  // §4.8 window registry: which units (scope values) of a window base are
592
582
  // live locally — the completeness oracle (I3) and the shrink driver
593
583
  // (a unit's omission from the next pull unregisters it, §4.1).
594
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_windows(
595
- base TEXT NOT NULL,
596
- unit TEXT NOT NULL,
597
- sub_id TEXT NOT NULL,
598
- PRIMARY KEY (base, unit))`);
584
+ createTable(
585
+ '_syncular_windows',
586
+ 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)',
587
+ );
599
588
  // §4.8 E1: units that left the window but still had outbox-pinned rows.
600
589
  // Retried when the outbox drains; cancelled if the unit re-enters.
601
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict(
602
- sub_id TEXT PRIMARY KEY,
603
- tbl TEXT NOT NULL,
604
- effective_scopes TEXT NOT NULL)`);
590
+ createTable(
591
+ '_syncular_window_pending_evict',
592
+ 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL',
593
+ );
605
594
  });
606
595
  }
607
596
 
@@ -634,9 +623,7 @@ export function dropAndRecreateSyncedTables(
634
623
  // Drop virtual tables first. SQLite then removes their shadow tables as one
635
624
  // unit, so the generic discovery below never tears an FTS5 projection apart.
636
625
  const virtualTables = db.query(
637
- `SELECT name FROM sqlite_master WHERE type = 'table'
638
- AND sql LIKE 'CREATE VIRTUAL TABLE%'
639
- AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
626
+ `SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
640
627
  );
641
628
  for (const row of virtualTables) {
642
629
  db.exec(
@@ -645,9 +632,7 @@ export function dropAndRecreateSyncedTables(
645
632
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
646
633
  }
647
634
  const existing = db.query(
648
- `SELECT name FROM sqlite_master WHERE type = 'table'
649
- AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
650
- AND name NOT LIKE 'sqlite_%'`,
635
+ `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%' AND name NOT LIKE 'sqlite_%'`,
651
636
  );
652
637
  for (const row of existing) {
653
638
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
@@ -680,15 +665,8 @@ export function fromSqlValue(column: RowColumn, value: SqlValue): RowValue {
680
665
  return value !== 0 && value !== false;
681
666
  case 'integer':
682
667
  return typeof value === 'bigint' ? Number(value) : (value as number);
683
- case 'float':
684
- return value as number;
685
- case 'bytes':
686
- case 'crdt':
687
- return value as Uint8Array;
688
- case 'string':
689
- case 'json':
690
- case 'blob_ref':
691
- return value as string;
668
+ default:
669
+ return value as RowValue;
692
670
  }
693
671
  }
694
672
 
@@ -710,17 +688,17 @@ export function normalizeRecordKeys(
710
688
  if (index === undefined) {
711
689
  if (key.startsWith('_sync_')) {
712
690
  throw invalidRequest(
713
- `table ${table.name}: ${JSON.stringify(key)} is an internal sync column and cannot appear in mutation values — did you build this record from a raw SELECT * row? (client.query() strips _sync_* columns; rows read via client.database keep them)`,
691
+ `table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`,
714
692
  );
715
693
  }
716
694
  throw invalidRequest(
717
- `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`,
695
+ `table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`,
718
696
  );
719
697
  }
720
698
  const sqlName = (table.columns[index] as RowColumn).name;
721
699
  if (normalized.has(sqlName)) {
722
700
  throw invalidRequest(
723
- `table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`,
701
+ `table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`,
724
702
  );
725
703
  }
726
704
  normalized.set(sqlName, value);
@@ -810,3 +788,13 @@ export function jsonToRowValue(value: JsonRowValue): RowValue {
810
788
  }
811
789
  return value;
812
790
  }
791
+
792
+ /** Convert each column value without changing the row's presence set. */
793
+ export function mapRowValues<T, U>(
794
+ values: Readonly<Record<string, T>>,
795
+ convert: (value: T) => U,
796
+ ): Record<string, U> {
797
+ return Object.fromEntries(
798
+ Object.entries(values).map(([key, value]) => [key, convert(value)]),
799
+ );
800
+ }