@syncular/client 0.30.9 → 0.30.11

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/src/schema.ts CHANGED
@@ -98,6 +98,7 @@ export function compileClientSchema(
98
98
  const tables = new Map<string, CompiledClientTable>();
99
99
  const schemaObjectNames = new Set<string>();
100
100
  for (const table of schema.tables) {
101
+ const tableLabel = `table ${table.name}`;
101
102
  if (tables.has(table.name)) {
102
103
  throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
103
104
  }
@@ -111,7 +112,7 @@ export function compileClientSchema(
111
112
  table.columns.forEach((column, index) => {
112
113
  if (columnIndex.has(column.name)) {
113
114
  throw new Error(
114
- `table ${table.name}: duplicate column ${JSON.stringify(column.name)}`,
115
+ `${tableLabel}: duplicate column ${JSON.stringify(column.name)}`,
115
116
  );
116
117
  }
117
118
  columnIndex.set(column.name, index);
@@ -119,7 +120,7 @@ export function compileClientSchema(
119
120
  const primaryKeyIndex = columnIndex.get(table.primaryKey);
120
121
  if (primaryKeyIndex === undefined) {
121
122
  throw new Error(
122
- `table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
123
+ `${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
123
124
  );
124
125
  }
125
126
  // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
@@ -134,12 +135,12 @@ export function compileClientSchema(
134
135
  primaryKeyColumn.type !== 'json'
135
136
  ) {
136
137
  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`,
138
+ `${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
138
139
  );
139
140
  }
140
141
  if (table.scopes.length === 0) {
141
142
  throw new Error(
142
- `table ${table.name}: every synced table declares at least one scope pattern (§3.1)`,
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>();
@@ -149,7 +150,7 @@ export function compileClientSchema(
149
150
  const match = PATTERN_RE.exec(pattern);
150
151
  if (match === null || match[1] === undefined || match[2] === undefined) {
151
152
  throw new Error(
152
- `table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
153
+ `${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
153
154
  );
154
155
  }
155
156
  const prefix = match[1];
@@ -157,13 +158,13 @@ export function compileClientSchema(
157
158
  const column = typeof spec === 'string' ? variable : spec.column;
158
159
  if (!columnIndex.has(column)) {
159
160
  throw new Error(
160
- `table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
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
166
  throw new Error(
166
- `table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
167
+ `${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
167
168
  );
168
169
  }
169
170
  scopeColumnByVariable.set(variable, column);
@@ -186,36 +187,35 @@ export function compileClientSchema(
186
187
  for (const index of indexes) {
187
188
  if (schemaObjectNames.has(index.name)) {
188
189
  throw new Error(
189
- `table ${table.name}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
190
+ `${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
190
191
  );
191
192
  }
192
193
  schemaObjectNames.add(index.name);
193
194
  for (const column of index.columns) {
194
195
  if (!columnIndex.has(column)) {
195
196
  throw new Error(
196
- `table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
197
+ `${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
197
198
  );
198
199
  }
199
200
  }
200
201
  }
201
202
  const ftsIndexes = table.ftsIndexes ?? [];
202
203
  for (const index of ftsIndexes) {
204
+ const projectionLabel = `${tableLabel}: FTS projection ${JSON.stringify(index.name)}`;
203
205
  if (schemaObjectNames.has(index.name)) {
204
206
  throw new Error(
205
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} conflicts with another schema object`,
207
+ `${projectionLabel} conflicts with another schema object`,
206
208
  );
207
209
  }
208
210
  schemaObjectNames.add(index.name);
209
211
  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
- );
212
+ throw new Error(`${projectionLabel} needs between 1 and 32 columns`);
213
213
  }
214
214
  const seenColumns = new Set<string>();
215
215
  for (const columnName of index.columns) {
216
216
  if (seenColumns.has(columnName)) {
217
217
  throw new Error(
218
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} repeats column ${JSON.stringify(columnName)}`,
218
+ `${projectionLabel} repeats column ${JSON.stringify(columnName)}`,
219
219
  );
220
220
  }
221
221
  seenColumns.add(columnName);
@@ -224,18 +224,18 @@ export function compileClientSchema(
224
224
  );
225
225
  if (column === undefined) {
226
226
  throw new Error(
227
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(columnName)}`,
227
+ `${projectionLabel} names unknown column ${JSON.stringify(columnName)}`,
228
228
  );
229
229
  }
230
230
  if (localColumnType(column) !== 'string') {
231
231
  throw new Error(
232
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`,
232
+ `${projectionLabel} column ${JSON.stringify(columnName)} must have string type`,
233
233
  );
234
234
  }
235
235
  }
236
236
  if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
237
237
  throw new Error(
238
- `table ${table.name}: FTS projection ${JSON.stringify(index.name)} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
238
+ `${projectionLabel} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
239
239
  );
240
240
  }
241
241
  }
@@ -344,7 +344,7 @@ function createSyncedTable(
344
344
  });
345
345
  columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
346
346
  db.exec(
347
- `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`,
347
+ `CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`,
348
348
  );
349
349
  // Local secondary indexes (CREATE INDEX subset). Created here so both the
350
350
  // initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
@@ -354,7 +354,7 @@ function createSyncedTable(
354
354
  const unique = index.unique ? 'UNIQUE ' : '';
355
355
  const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
356
356
  db.exec(
357
- `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${cols})`,
357
+ `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`,
358
358
  );
359
359
  }
360
360
  }
@@ -390,7 +390,7 @@ function createFtsProjection(
390
390
  const indexedColumns = index.columns.map(quoteIdent);
391
391
  const tokenizer = index.tokenize.replaceAll("'", "''");
392
392
  db.exec(
393
- `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`,
393
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`,
394
394
  );
395
395
 
396
396
  const mapping = quoteIdent(`_syncular_fts_${index.name}`);
@@ -399,7 +399,7 @@ function createFtsProjection(
399
399
  `_syncular_fts_${index.name}`,
400
400
  ]).length > 0;
401
401
  db.exec(
402
- `CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`,
402
+ `CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`,
403
403
  );
404
404
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
405
405
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -413,10 +413,10 @@ function createFtsProjection(
413
413
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
414
414
  ].join(', ');
415
415
  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}`;
416
+ `DELETE FROM ${quoteIdent(index.name)} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
417
417
  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})`;
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})`;
420
420
 
421
421
  // A clean insert cannot already have a projection row because the source
422
422
  // primary key is unique. Keep clean inserts linear by moving replacement
@@ -454,7 +454,7 @@ function createFtsProjection(
454
454
  const match = spec.columns
455
455
  .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
456
456
  .join(' AND ');
457
- return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
457
+ return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} !=new.${quoteIdent(table.primaryKey)}`;
458
458
  });
459
459
  const insertGuardCondition = [
460
460
  replacementExists,
@@ -465,17 +465,22 @@ function createFtsProjection(
465
465
  ...displacedByUnique.map(deleteDisplaced),
466
466
  ].join('; ');
467
467
 
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`,
468
+ const createTrigger = (
469
+ suffix: string,
470
+ timing: string,
471
+ body: string,
472
+ when = '',
473
+ ) =>
474
+ db.exec(
475
+ `CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${quoteIdent(table.name)}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`,
476
+ );
477
+ createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
478
+ createTrigger('ai', 'AFTER INSERT', insertNew);
479
+ createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
480
+ createTrigger(
481
+ 'au',
482
+ 'AFTER UPDATE',
483
+ `${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`,
479
484
  );
480
485
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
481
486
  // different-PK row about to be displaced through a secondary unique index by
@@ -486,20 +491,18 @@ function createFtsProjection(
486
491
  .map((select) => `EXISTS (${select})`)
487
492
  .join(' OR ');
488
493
  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
- );
494
+ createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
492
495
  }
493
496
 
494
497
  if (!existed) {
495
498
  db.exec(
496
- `INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
499
+ `INSERT INTO ${quoteIdent(index.name)}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
497
500
  );
498
501
  }
499
502
  if (!mapped || !existed) {
500
503
  db.exec(`DELETE FROM ${mapping}`);
501
504
  db.exec(
502
- `INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
505
+ `INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
503
506
  );
504
507
  }
505
508
  }
@@ -534,39 +537,31 @@ export function ensureLocalSyncedSchema(
534
537
  export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
535
538
  db.transaction(() => {
536
539
  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))`);
540
+ const createTable = (name: string, columns: string) =>
541
+ db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
542
+ createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
543
+ createTable(
544
+ '_syncular_blob_commit_refs',
545
+ 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
546
+ );
541
547
  db.exec(
542
548
  'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
543
549
  );
544
550
  db.exec(
545
- `INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`,
551
+ "INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')",
552
+ );
553
+ createTable(
554
+ '_syncular_outbox',
555
+ 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
556
+ );
557
+ createTable(
558
+ '_syncular_outbox_before_images',
559
+ '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)',
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))}`);
@@ -810,3 +795,13 @@ export function jsonToRowValue(value: JsonRowValue): RowValue {
810
795
  }
811
796
  return value;
812
797
  }
798
+
799
+ /** Convert each column value without changing the row's presence set. */
800
+ export function mapRowValues<T, U>(
801
+ values: Readonly<Record<string, T>>,
802
+ convert: (value: T) => U,
803
+ ): Record<string, U> {
804
+ return Object.fromEntries(
805
+ Object.entries(values).map(([key, value]) => [key, convert(value)]),
806
+ );
807
+ }
package/src/state.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * exactly for that purpose.
6
6
  */
7
7
  import type { ScopeMap } from '@syncular/core';
8
+ import type { CompiledClientSchema } from './schema';
8
9
  import type { ClientDatabase } from './database';
9
10
  import type { LocalRevision } from './invalidation';
10
11
 
@@ -65,7 +66,7 @@ export function getSubscription(
65
66
  db: ClientDatabase,
66
67
  id: string,
67
68
  ): SubscriptionRecord | undefined {
68
- const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id = ?', [
69
+ const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
69
70
  id,
70
71
  ])[0];
71
72
  return row === undefined ? undefined : rowToRecord(row);
@@ -76,10 +77,7 @@ export function saveSubscription(
76
77
  record: SubscriptionRecord,
77
78
  ): void {
78
79
  db.exec(
79
- `INSERT OR REPLACE INTO _syncular_subscriptions(
80
- id, tbl, requested_scopes, params, cursor, bootstrap_state,
81
- effective_scopes, status, reason_code)
82
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
80
+ 'INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)',
83
81
  [
84
82
  record.id,
85
83
  record.table,
@@ -97,11 +95,11 @@ export function saveSubscription(
97
95
  }
98
96
 
99
97
  export function deleteSubscription(db: ClientDatabase, id: string): void {
100
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
98
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
101
99
  }
102
100
 
103
101
  /**
104
- * §7.4.3 reset: keep every subscription REGISTRATION (id, table,
102
+ * §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
105
103
  * requested scopes, params — the app's declared intent) but discard all
106
104
  * synced state (cursor → -1, no resume token, no effective-scope map,
107
105
  * status → active), so the next round fresh-bootstraps exactly the
@@ -109,56 +107,115 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
109
107
  */
110
108
  export function resetSubscriptionsForBump(db: ClientDatabase): void {
111
109
  db.exec(
112
- `UPDATE _syncular_subscriptions
113
- SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
114
- status = 'active', reason_code = NULL`,
110
+ "UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL",
115
111
  );
116
112
  }
117
113
 
118
- /**
119
- * Remove registrations whose table no longer exists in the running schema.
120
- * Keeping one would make every subsequent pull fail with
121
- * `sync.unknown_table`. Window bookkeeping belongs to the registration and
122
- * is removed with it. The caller supplies its startup subscription snapshot
123
- * so pruning and startup-work detection stay a single read.
124
- */
114
+ type ScopeDeclarations = Record<
115
+ string,
116
+ Record<string, readonly [string, string]>
117
+ >;
118
+
119
+ /** Persist declarations separately from the previous-version row descriptor. */
120
+ export function saveSubscriptionScopeSchema(
121
+ db: ClientDatabase,
122
+ schema: CompiledClientSchema,
123
+ ): void {
124
+ const declarations: ScopeDeclarations = {};
125
+ for (const table of schema.tables.values()) {
126
+ declarations[table.name] = Object.fromEntries(
127
+ [...table.scopeColumnByVariable].map(([variable, column]) => [
128
+ variable,
129
+ [table.scopePrefixByVariable.get(variable)!, column],
130
+ ]),
131
+ );
132
+ }
133
+ setMeta(db, 'subscriptionScopeSchema', JSON.stringify(declarations));
134
+ }
135
+
136
+ /** Drop incompatible registrations and their windows; never translate scopes. */
125
137
  export function pruneUnknownSubscriptions(
126
138
  db: ClientDatabase,
127
139
  subscriptions: readonly SubscriptionRecord[],
128
- tableNames: ReadonlySet<string>,
140
+ schema: CompiledClientSchema,
141
+ bump = false,
129
142
  ): SubscriptionRecord[] {
130
- const retained = subscriptions.filter((record) =>
131
- tableNames.has(record.table),
143
+ const stored = bump ? getMeta(db, 'subscriptionScopeSchema') : undefined;
144
+ const previous =
145
+ stored === undefined
146
+ ? undefined
147
+ : (JSON.parse(stored) as ScopeDeclarations);
148
+ const compatible = (
149
+ record: Pick<SubscriptionRecord, 'table' | 'scopes'>,
150
+ ): boolean => {
151
+ const table = schema.tables.get(record.table);
152
+ if (table === undefined) return false;
153
+ const variables = Object.keys(record.scopes);
154
+ if (
155
+ !variables.every((variable) => table.scopeColumnByVariable.has(variable))
156
+ )
157
+ return false;
158
+ if (!bump) return true;
159
+ // An old binary stored no declaration evidence. Re-registration is required
160
+ // at a bump: matching names cannot prove unchanged column/prefix meaning.
161
+ const old = previous?.[record.table];
162
+ if (old === undefined) return false;
163
+ const compared =
164
+ variables.length === 0
165
+ ? [
166
+ ...new Set([
167
+ ...Object.keys(old),
168
+ ...table.scopeColumnByVariable.keys(),
169
+ ]),
170
+ ]
171
+ : variables;
172
+ return compared.every(
173
+ (variable) =>
174
+ old[variable]?.[0] === table.scopePrefixByVariable.get(variable) &&
175
+ old[variable]?.[1] === table.scopeColumnByVariable.get(variable),
176
+ );
177
+ };
178
+ const retained = subscriptions.filter(compatible);
179
+ const staleIds = new Set(
180
+ subscriptions
181
+ .filter((record) => !compatible(record))
182
+ .map((record) => record.id),
132
183
  );
133
- const staleIds = subscriptions
134
- .filter((record) => !tableNames.has(record.table))
135
- .map((record) => record.id);
136
- // A redundant subscription scan plus an empty write transaction cut the
137
- // fresh-client bootstrap lane by more than half on bun:sqlite. The supplied
138
- // snapshot and this branch are synchronous, so no application work can
139
- // interleave before a real pruning transaction begins.
140
- if (staleIds.length === 0) return retained;
184
+ // Shrinking a pinned window already removed its registration. Its deferred
185
+ // eviction still carries the old scope declaration and needs the same fence.
186
+ for (const row of db.query(
187
+ 'SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict',
188
+ )) {
189
+ if (
190
+ !compatible({
191
+ table: String(row.tbl),
192
+ scopes: JSON.parse(String(row.effective_scopes)) as ScopeMap,
193
+ })
194
+ )
195
+ staleIds.add(String(row.sub_id));
196
+ }
197
+ if (staleIds.size === 0) return retained;
141
198
  db.transaction(() => {
142
199
  for (const id of staleIds) {
143
- db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
144
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
200
+ db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
201
+ db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
145
202
  id,
146
203
  ]);
147
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
204
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
148
205
  }
149
206
  });
150
207
  return retained;
151
208
  }
152
209
 
153
210
  export function getMeta(db: ClientDatabase, key: string): string | undefined {
154
- const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
211
+ const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
155
212
  key,
156
213
  ])[0];
157
214
  return row === undefined ? undefined : (row.value as string);
158
215
  }
159
216
 
160
217
  export function setMeta(db: ClientDatabase, key: string, value: string): void {
161
- db.exec('INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?, ?)', [
218
+ db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
162
219
  key,
163
220
  value,
164
221
  ]);