@syncular/client 0.30.10 → 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/dist/schema.js CHANGED
@@ -14,6 +14,7 @@ export function compileClientSchema(schema) {
14
14
  const tables = new Map();
15
15
  const schemaObjectNames = new Set();
16
16
  for (const table of schema.tables) {
17
+ const tableLabel = `table ${table.name}`;
17
18
  if (tables.has(table.name)) {
18
19
  throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
19
20
  }
@@ -24,13 +25,13 @@ export function compileClientSchema(schema) {
24
25
  const columnIndex = new Map();
25
26
  table.columns.forEach((column, index) => {
26
27
  if (columnIndex.has(column.name)) {
27
- throw new Error(`table ${table.name}: duplicate column ${JSON.stringify(column.name)}`);
28
+ throw new Error(`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`);
28
29
  }
29
30
  columnIndex.set(column.name, index);
30
31
  });
31
32
  const primaryKeyIndex = columnIndex.get(table.primaryKey);
32
33
  if (primaryKeyIndex === undefined) {
33
- throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
34
+ throw new Error(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
34
35
  }
35
36
  // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
36
37
  // string every implementation produces identically and local storage
@@ -41,10 +42,10 @@ export function compileClientSchema(schema) {
41
42
  primaryKeyColumn.type !== 'integer' &&
42
43
  primaryKeyColumn.type !== 'boolean' &&
43
44
  primaryKeyColumn.type !== 'json') {
44
- throw new Error(`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`);
45
+ throw new Error(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`);
45
46
  }
46
47
  if (table.scopes.length === 0) {
47
- throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
48
+ throw new Error(`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`);
48
49
  }
49
50
  const scopeColumnByVariable = new Map();
50
51
  const scopePrefixByVariable = new Map();
@@ -52,17 +53,17 @@ export function compileClientSchema(schema) {
52
53
  const pattern = typeof spec === 'string' ? spec : spec.pattern;
53
54
  const match = PATTERN_RE.exec(pattern);
54
55
  if (match === null || match[1] === undefined || match[2] === undefined) {
55
- throw new Error(`table ${table.name}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
56
+ throw new Error(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
56
57
  }
57
58
  const prefix = match[1];
58
59
  const variable = match[2];
59
60
  const column = typeof spec === 'string' ? variable : spec.column;
60
61
  if (!columnIndex.has(column)) {
61
- throw new Error(`table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
62
+ throw new Error(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
62
63
  }
63
64
  const existing = scopeColumnByVariable.get(variable);
64
65
  if (existing !== undefined && existing !== column) {
65
- throw new Error(`table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
66
+ throw new Error(`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
66
67
  }
67
68
  scopeColumnByVariable.set(variable, column);
68
69
  scopePrefixByVariable.set(variable, prefix);
@@ -85,40 +86,41 @@ export function compileClientSchema(schema) {
85
86
  const indexes = table.indexes ?? [];
86
87
  for (const index of indexes) {
87
88
  if (schemaObjectNames.has(index.name)) {
88
- throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} conflicts with another schema object`);
89
+ throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`);
89
90
  }
90
91
  schemaObjectNames.add(index.name);
91
92
  for (const column of index.columns) {
92
93
  if (!columnIndex.has(column)) {
93
- throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
94
+ throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
94
95
  }
95
96
  }
96
97
  }
97
98
  const ftsIndexes = table.ftsIndexes ?? [];
98
99
  for (const index of ftsIndexes) {
100
+ const projectionLabel = `${tableLabel}: FTS projection ${JSON.stringify(index.name)}`;
99
101
  if (schemaObjectNames.has(index.name)) {
100
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} conflicts with another schema object`);
102
+ throw new Error(`${projectionLabel} conflicts with another schema object`);
101
103
  }
102
104
  schemaObjectNames.add(index.name);
103
105
  if (index.columns.length === 0 || index.columns.length > 32) {
104
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} needs between 1 and 32 columns`);
106
+ throw new Error(`${projectionLabel} needs between 1 and 32 columns`);
105
107
  }
106
108
  const seenColumns = new Set();
107
109
  for (const columnName of index.columns) {
108
110
  if (seenColumns.has(columnName)) {
109
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} repeats column ${JSON.stringify(columnName)}`);
111
+ throw new Error(`${projectionLabel} repeats column ${JSON.stringify(columnName)}`);
110
112
  }
111
113
  seenColumns.add(columnName);
112
114
  const column = table.columns.find((candidate) => candidate.name === columnName);
113
115
  if (column === undefined) {
114
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(columnName)}`);
116
+ throw new Error(`${projectionLabel} names unknown column ${JSON.stringify(columnName)}`);
115
117
  }
116
118
  if (localColumnType(column) !== 'string') {
117
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} column ${JSON.stringify(columnName)} must have string type`);
119
+ throw new Error(`${projectionLabel} column ${JSON.stringify(columnName)} must have string type`);
118
120
  }
119
121
  }
120
122
  if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
121
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
123
+ throw new Error(`${projectionLabel} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
122
124
  }
123
125
  }
124
126
  tables.set(table.name, {
@@ -216,7 +218,7 @@ function createSyncedTable(db, table) {
216
218
  return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
217
219
  });
218
220
  columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
219
- db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`);
221
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`);
220
222
  // Local secondary indexes (CREATE INDEX subset). Created here so both the
221
223
  // initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
222
224
  // materialize them. IF NOT EXISTS keeps it idempotent; the DROP TABLE in the
@@ -224,7 +226,7 @@ function createSyncedTable(db, table) {
224
226
  for (const index of table.indexes) {
225
227
  const unique = index.unique ? 'UNIQUE ' : '';
226
228
  const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
227
- db.exec(`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)} (${cols})`);
229
+ db.exec(`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`);
228
230
  }
229
231
  }
230
232
  const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
@@ -248,12 +250,12 @@ function createFtsProjection(db, table, index) {
248
250
  const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
249
251
  const indexedColumns = index.columns.map(quoteIdent);
250
252
  const tokenizer = index.tokenize.replaceAll("'", "''");
251
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED, ${indexedColumns.join(', ')}, tokenize='${tokenizer}')`);
253
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`);
252
254
  const mapping = quoteIdent(`_syncular_fts_${index.name}`);
253
255
  const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
254
256
  `_syncular_fts_${index.name}`,
255
257
  ]).length > 0;
256
- db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
258
+ db.exec(`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`);
257
259
  const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
258
260
  const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
259
261
  const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
@@ -265,9 +267,9 @@ function createFtsProjection(db, table, index) {
265
267
  newSourceId,
266
268
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
267
269
  ].join(', ');
268
- const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid = (SELECT id FROM ${mapping} WHERE source_id = ${value}); DELETE FROM ${mapping} WHERE source_id = ${value}`;
269
- const deleteDisplaced = (select) => `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})`;
270
- 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})`;
270
+ const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
271
+ const deleteDisplaced = (select) => `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})`;
272
+ 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})`;
271
273
  // A clean insert cannot already have a projection row because the source
272
274
  // primary key is unique. Keep clean inserts linear by moving replacement
273
275
  // cleanup behind indexed source-table existence checks. SQLite does not
@@ -304,7 +306,7 @@ function createFtsProjection(db, table, index) {
304
306
  const match = spec.columns
305
307
  .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
306
308
  .join(' AND ');
307
- return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
309
+ return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} !=new.${quoteIdent(table.primaryKey)}`;
308
310
  });
309
311
  const insertGuardCondition = [
310
312
  replacementExists,
@@ -314,10 +316,11 @@ function createFtsProjection(db, table, index) {
314
316
  deleteFor(newSourceId),
315
317
  ...displacedByUnique.map(deleteDisplaced),
316
318
  ].join('; ');
317
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
318
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN ${insertNew}; END`);
319
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
320
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}; END`);
319
+ const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${quoteIdent(table.name)}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`);
320
+ createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
321
+ createTrigger('ai', 'AFTER INSERT', insertNew);
322
+ createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
323
+ createTrigger('au', 'AFTER UPDATE', `${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`);
321
324
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
322
325
  // different-PK row about to be displaced through a secondary unique index by
323
326
  // the new values. Only meaningful when the table has a unique index; with
@@ -327,14 +330,14 @@ function createFtsProjection(db, table, index) {
327
330
  .map((select) => `EXISTS (${select})`)
328
331
  .join(' OR ');
329
332
  const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
330
- db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`);
333
+ createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
331
334
  }
332
335
  if (!existed) {
333
- db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
336
+ db.exec(`INSERT INTO ${quoteIdent(index.name)}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
334
337
  }
335
338
  if (!mapped || !existed) {
336
339
  db.exec(`DELETE FROM ${mapping}`);
337
- db.exec(`INSERT INTO ${mapping}(id, source_id) SELECT rowid, ${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
340
+ db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`);
338
341
  }
339
342
  }
340
343
  /**
@@ -363,35 +366,14 @@ export function ensureLocalSyncedSchema(db, schema) {
363
366
  export function ensureLocalBookkeepingSchema(db) {
364
367
  db.transaction(() => {
365
368
  ensureFailedOverlaySchema(db);
366
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
367
- key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
368
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
369
- commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id))`);
369
+ const createTable = (name, columns) => db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
370
+ createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
371
+ createTable('_syncular_blob_commit_refs', 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
370
372
  db.exec('CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)');
371
- db.exec(`INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`);
372
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
373
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
374
- client_commit_id TEXT NOT NULL UNIQUE,
375
- created_at_ms INTEGER NOT NULL,
376
- operations TEXT NOT NULL)`);
377
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
378
- client_commit_id TEXT NOT NULL,
379
- op_index INTEGER NOT NULL,
380
- existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
381
- sync_version INTEGER,
382
- values_json TEXT,
383
- PRIMARY KEY(client_commit_id, op_index))`);
384
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
385
- seq INTEGER PRIMARY KEY AUTOINCREMENT,
386
- client_commit_id TEXT NOT NULL UNIQUE,
387
- status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
388
- recorded_at_ms INTEGER NOT NULL,
389
- results TEXT NOT NULL,
390
- operations TEXT,
391
- resolution TEXT NOT NULL DEFAULT 'active'
392
- CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
393
- resolved_at_ms INTEGER,
394
- replacement_client_commit_id TEXT)`);
373
+ db.exec("INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')");
374
+ createTable('_syncular_outbox', 'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL');
375
+ createTable('_syncular_outbox_before_images', '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)');
376
+ createTable('_syncular_commit_outcomes', "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");
395
377
  // Outcomes created before durable aggregate recovery retain no commit
396
378
  // envelope. New failed outcomes store it in the protected client DB.
397
379
  try {
@@ -400,32 +382,15 @@ export function ensureLocalBookkeepingSchema(db) {
400
382
  catch {
401
383
  // column already exists — the CREATE above included it
402
384
  }
403
- db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
404
- ON _syncular_commit_outcomes(resolution, seq)`);
405
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
406
- id TEXT PRIMARY KEY,
407
- tbl TEXT NOT NULL,
408
- requested_scopes TEXT NOT NULL,
409
- params TEXT,
410
- cursor INTEGER NOT NULL DEFAULT -1,
411
- bootstrap_state TEXT,
412
- effective_scopes TEXT,
413
- status TEXT NOT NULL DEFAULT 'active',
414
- reason_code TEXT)`);
385
+ db.exec('CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)');
386
+ createTable('_syncular_subscriptions', "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");
415
387
  // §4.8 window registry: which units (scope values) of a window base are
416
388
  // live locally — the completeness oracle (I3) and the shrink driver
417
389
  // (a unit's omission from the next pull unregisters it, §4.1).
418
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_windows(
419
- base TEXT NOT NULL,
420
- unit TEXT NOT NULL,
421
- sub_id TEXT NOT NULL,
422
- PRIMARY KEY (base, unit))`);
390
+ createTable('_syncular_windows', 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
423
391
  // §4.8 E1: units that left the window but still had outbox-pinned rows.
424
392
  // Retried when the outbox drains; cancelled if the unit re-enters.
425
- db.exec(`CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict(
426
- sub_id TEXT PRIMARY KEY,
427
- tbl TEXT NOT NULL,
428
- effective_scopes TEXT NOT NULL)`);
393
+ createTable('_syncular_window_pending_evict', 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
429
394
  });
430
395
  }
431
396
  /** Create both application and protected bookkeeping tables. */
@@ -448,16 +413,12 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
448
413
  export function dropAndRecreateSyncedTables(db, schema) {
449
414
  // Drop virtual tables first. SQLite then removes their shadow tables as one
450
415
  // unit, so the generic discovery below never tears an FTS5 projection apart.
451
- const virtualTables = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
452
- AND sql LIKE 'CREATE VIRTUAL TABLE%'
453
- AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
416
+ const virtualTables = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`);
454
417
  for (const row of virtualTables) {
455
418
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
456
419
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
457
420
  }
458
- const existing = db.query(`SELECT name FROM sqlite_master WHERE type = 'table'
459
- AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
460
- AND name NOT LIKE 'sqlite_%'`);
421
+ const existing = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%' AND name NOT LIKE 'sqlite_%'`);
461
422
  for (const row of existing) {
462
423
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
463
424
  }
@@ -584,3 +545,7 @@ export function jsonToRowValue(value) {
584
545
  }
585
546
  return value;
586
547
  }
548
+ /** Convert each column value without changing the row's presence set. */
549
+ export function mapRowValues(values, convert) {
550
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, convert(value)]));
551
+ }
package/dist/state.d.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.js';
8
9
  import type { ClientDatabase } from './database.js';
9
10
  import type { LocalRevision } from './invalidation.js';
10
11
  export declare const LOCAL_REVISION_KEY = "localRevision";
@@ -31,21 +32,17 @@ export declare function getSubscription(db: ClientDatabase, id: string): Subscri
31
32
  export declare function saveSubscription(db: ClientDatabase, record: SubscriptionRecord): void;
32
33
  export declare function deleteSubscription(db: ClientDatabase, id: string): void;
33
34
  /**
34
- * §7.4.3 reset: keep every subscription REGISTRATION (id, table,
35
+ * §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
35
36
  * requested scopes, params — the app's declared intent) but discard all
36
37
  * synced state (cursor → -1, no resume token, no effective-scope map,
37
38
  * status → active), so the next round fresh-bootstraps exactly the
38
39
  * subscriptions the app still wants. Caller owns the transaction.
39
40
  */
40
41
  export declare function resetSubscriptionsForBump(db: ClientDatabase): void;
41
- /**
42
- * Remove registrations whose table no longer exists in the running schema.
43
- * Keeping one would make every subsequent pull fail with
44
- * `sync.unknown_table`. Window bookkeeping belongs to the registration and
45
- * is removed with it. The caller supplies its startup subscription snapshot
46
- * so pruning and startup-work detection stay a single read.
47
- */
48
- export declare function pruneUnknownSubscriptions(db: ClientDatabase, subscriptions: readonly SubscriptionRecord[], tableNames: ReadonlySet<string>): SubscriptionRecord[];
42
+ /** Persist declarations separately from the previous-version row descriptor. */
43
+ export declare function saveSubscriptionScopeSchema(db: ClientDatabase, schema: CompiledClientSchema): void;
44
+ /** Drop incompatible registrations and their windows; never translate scopes. */
45
+ export declare function pruneUnknownSubscriptions(db: ClientDatabase, subscriptions: readonly SubscriptionRecord[], schema: CompiledClientSchema, bump?: boolean): SubscriptionRecord[];
49
46
  export declare function getMeta(db: ClientDatabase, key: string): string | undefined;
50
47
  export declare function setMeta(db: ClientDatabase, key: string, value: string): void;
51
48
  /** Read the durable client-local observer revision (SPEC §7.5). */
package/dist/state.js CHANGED
@@ -27,16 +27,13 @@ export function loadSubscriptions(db) {
27
27
  .map(rowToRecord);
28
28
  }
29
29
  export function getSubscription(db, id) {
30
- const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id = ?', [
30
+ const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
31
31
  id,
32
32
  ])[0];
33
33
  return row === undefined ? undefined : rowToRecord(row);
34
34
  }
35
35
  export function saveSubscription(db, record) {
36
- db.exec(`INSERT OR REPLACE INTO _syncular_subscriptions(
37
- id, tbl, requested_scopes, params, cursor, bootstrap_state,
38
- effective_scopes, status, reason_code)
39
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
36
+ db.exec('INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)', [
40
37
  record.id,
41
38
  record.table,
42
39
  JSON.stringify(record.scopes),
@@ -51,57 +48,94 @@ export function saveSubscription(db, record) {
51
48
  ]);
52
49
  }
53
50
  export function deleteSubscription(db, id) {
54
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
51
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
55
52
  }
56
53
  /**
57
- * §7.4.3 reset: keep every subscription REGISTRATION (id, table,
54
+ * §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
58
55
  * requested scopes, params — the app's declared intent) but discard all
59
56
  * synced state (cursor → -1, no resume token, no effective-scope map,
60
57
  * status → active), so the next round fresh-bootstraps exactly the
61
58
  * subscriptions the app still wants. Caller owns the transaction.
62
59
  */
63
60
  export function resetSubscriptionsForBump(db) {
64
- db.exec(`UPDATE _syncular_subscriptions
65
- SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
66
- status = 'active', reason_code = NULL`);
61
+ db.exec("UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL");
67
62
  }
68
- /**
69
- * Remove registrations whose table no longer exists in the running schema.
70
- * Keeping one would make every subsequent pull fail with
71
- * `sync.unknown_table`. Window bookkeeping belongs to the registration and
72
- * is removed with it. The caller supplies its startup subscription snapshot
73
- * so pruning and startup-work detection stay a single read.
74
- */
75
- export function pruneUnknownSubscriptions(db, subscriptions, tableNames) {
76
- const retained = subscriptions.filter((record) => tableNames.has(record.table));
77
- const staleIds = subscriptions
78
- .filter((record) => !tableNames.has(record.table))
79
- .map((record) => record.id);
80
- // A redundant subscription scan plus an empty write transaction cut the
81
- // fresh-client bootstrap lane by more than half on bun:sqlite. The supplied
82
- // snapshot and this branch are synchronous, so no application work can
83
- // interleave before a real pruning transaction begins.
84
- if (staleIds.length === 0)
63
+ /** Persist declarations separately from the previous-version row descriptor. */
64
+ export function saveSubscriptionScopeSchema(db, schema) {
65
+ const declarations = {};
66
+ for (const table of schema.tables.values()) {
67
+ declarations[table.name] = Object.fromEntries([...table.scopeColumnByVariable].map(([variable, column]) => [
68
+ variable,
69
+ [table.scopePrefixByVariable.get(variable), column],
70
+ ]));
71
+ }
72
+ setMeta(db, 'subscriptionScopeSchema', JSON.stringify(declarations));
73
+ }
74
+ /** Drop incompatible registrations and their windows; never translate scopes. */
75
+ export function pruneUnknownSubscriptions(db, subscriptions, schema, bump = false) {
76
+ const stored = bump ? getMeta(db, 'subscriptionScopeSchema') : undefined;
77
+ const previous = stored === undefined
78
+ ? undefined
79
+ : JSON.parse(stored);
80
+ const compatible = (record) => {
81
+ const table = schema.tables.get(record.table);
82
+ if (table === undefined)
83
+ return false;
84
+ const variables = Object.keys(record.scopes);
85
+ if (!variables.every((variable) => table.scopeColumnByVariable.has(variable)))
86
+ return false;
87
+ if (!bump)
88
+ return true;
89
+ // An old binary stored no declaration evidence. Re-registration is required
90
+ // at a bump: matching names cannot prove unchanged column/prefix meaning.
91
+ const old = previous?.[record.table];
92
+ if (old === undefined)
93
+ return false;
94
+ const compared = variables.length === 0
95
+ ? [
96
+ ...new Set([
97
+ ...Object.keys(old),
98
+ ...table.scopeColumnByVariable.keys(),
99
+ ]),
100
+ ]
101
+ : variables;
102
+ return compared.every((variable) => old[variable]?.[0] === table.scopePrefixByVariable.get(variable) &&
103
+ old[variable]?.[1] === table.scopeColumnByVariable.get(variable));
104
+ };
105
+ const retained = subscriptions.filter(compatible);
106
+ const staleIds = new Set(subscriptions
107
+ .filter((record) => !compatible(record))
108
+ .map((record) => record.id));
109
+ // Shrinking a pinned window already removed its registration. Its deferred
110
+ // eviction still carries the old scope declaration and needs the same fence.
111
+ for (const row of db.query('SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict')) {
112
+ if (!compatible({
113
+ table: String(row.tbl),
114
+ scopes: JSON.parse(String(row.effective_scopes)),
115
+ }))
116
+ staleIds.add(String(row.sub_id));
117
+ }
118
+ if (staleIds.size === 0)
85
119
  return retained;
86
120
  db.transaction(() => {
87
121
  for (const id of staleIds) {
88
- db.exec('DELETE FROM _syncular_windows WHERE sub_id = ?', [id]);
89
- db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?', [
122
+ db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
123
+ db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
90
124
  id,
91
125
  ]);
92
- db.exec('DELETE FROM _syncular_subscriptions WHERE id = ?', [id]);
126
+ db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
93
127
  }
94
128
  });
95
129
  return retained;
96
130
  }
97
131
  export function getMeta(db, key) {
98
- const row = db.query('SELECT value FROM _syncular_meta WHERE key = ?', [
132
+ const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
99
133
  key,
100
134
  ])[0];
101
135
  return row === undefined ? undefined : row.value;
102
136
  }
103
137
  export function setMeta(db, key, value) {
104
- db.exec('INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?, ?)', [
138
+ db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
105
139
  key,
106
140
  value,
107
141
  ]);
@@ -22,6 +22,7 @@
22
22
  * `ClientDatabase`. Browser-only — exercised by the demo, never imported
23
23
  * by bun tests (subpath export `@syncular/client/wasm`).
24
24
  */
25
+ import { webLocksLeaderLock } from './leader-lock.js';
25
26
  import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
26
27
  import { assertImageAlias, runTransaction, } from './database.js';
27
28
  import { ClientSyncError, invalidRequest, STORAGE_BUSY_CODE, STORAGE_UNAVAILABLE_CODE, } from './errors.js';
@@ -37,6 +38,7 @@ class WasmClientDatabase {
37
38
  #sqlite3;
38
39
  /** RFC 0005: present only for a persistent OPFS database. */
39
40
  #sah;
41
+ #closed = false;
40
42
  #tx = { depth: 0 };
41
43
  constructor(db, sqlite3, sah) {
42
44
  this.#db = db;
@@ -128,7 +130,14 @@ class WasmClientDatabase {
128
130
  }
129
131
  }
130
132
  close() {
133
+ if (this.#closed)
134
+ return;
131
135
  this.#db.close();
136
+ // DB.close() leaves the SAH pool live. Close siblings first, then pause the
137
+ // VFS without deleting files, before releasing this worker's physical lock.
138
+ this.#sah?.util.pauseVfs();
139
+ this.#closed = true;
140
+ void this.#sah?.lease.release();
132
141
  }
133
142
  }
134
143
  let sqlite3Init;
@@ -249,35 +258,42 @@ export async function openPersistentWasmDatabase(name, options) {
249
258
  'requires OPFS (~2023+ browsers). There is no IndexedDB or ' +
250
259
  'in-memory fallback for persistent mode.');
251
260
  }
252
- const sqlite3 = await initSqlite3();
253
- const directory = options?.directory ?? `.syncular/${name}`;
254
- let pool = sahPools.get(directory);
255
- if (pool === undefined) {
256
- pool = sqlite3
257
- .installOpfsSAHPoolVfs({
258
- // VFS registration names must be unique per directory.
259
- name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
260
- directory,
261
- // sqlite-wasm caches a rejected initialization promise by VFS name.
262
- // Syncular removes its own rejected entry below, so allow a later
263
- // open in the same worker to make a real attempt as well.
264
- forceReinitIfPreviouslyFailed: true,
265
- initialCapacity: Math.max(options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY, SAH_POOL_MIN_CAPACITY),
266
- })
267
- .catch((error) => {
268
- sahPools.delete(directory);
269
- throw opfsSahPoolError(error, directory);
270
- });
271
- sahPools.set(directory, pool);
261
+ if (!navigator.locks) {
262
+ throw new ClientSyncError(STORAGE_UNAVAILABLE_CODE, 'Persistent OPFS storage requires Web Locks');
272
263
  }
273
- const util = await pool;
274
- const db = new util.OpfsSAHPoolDb(`/${name}.db`);
264
+ const directory = (options?.directory ?? `.syncular/${name}`).replace(/^\/+|\/+$/g, '');
265
+ // The document's leader lease can end before its worker's SAHs close. This
266
+ // worker-owned lock has exactly the same lifetime as the physical pool.
267
+ const lease = await webLocksLeaderLock().acquire(`syncular-opfs/${directory}`);
268
+ let util;
269
+ let db;
275
270
  try {
271
+ const sqlite3 = await initSqlite3();
272
+ let pool = sahPools.get(directory);
273
+ if (pool === undefined) {
274
+ pool = sqlite3
275
+ .installOpfsSAHPoolVfs({
276
+ name: `syncular-sahpool-${directory.replace(/[^A-Za-z0-9]+/g, '-')}`,
277
+ directory,
278
+ forceReinitIfPreviouslyFailed: true,
279
+ initialCapacity: Math.max(options?.initialCapacity ?? SAH_POOL_DEFAULT_CAPACITY, SAH_POOL_MIN_CAPACITY),
280
+ })
281
+ .catch((error) => {
282
+ sahPools.delete(directory);
283
+ throw opfsSahPoolError(error, directory);
284
+ });
285
+ sahPools.set(directory, pool);
286
+ }
287
+ util = await pool;
288
+ await util.unpauseVfs();
289
+ db = new util.OpfsSAHPoolDb(`/${name}.db`);
276
290
  configureSahCrashRecovery(db, sqlite3);
277
- return new WasmClientDatabase(db, sqlite3, { util });
291
+ return new WasmClientDatabase(db, sqlite3, { util, lease });
278
292
  }
279
293
  catch (error) {
280
- db.close();
294
+ db?.close();
295
+ util?.pauseVfs();
296
+ await lease.release();
281
297
  throw error;
282
298
  }
283
299
  }