@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.
package/dist/schema.d.ts CHANGED
@@ -93,6 +93,7 @@ export declare const OPTIMISTIC_VERSION = -1;
93
93
  * pass through this filter.
94
94
  */
95
95
  export declare function stripSyncColumns(rows: SqlRow[]): SqlRow[];
96
+ export declare function matchesLocalScopes(table: CompiledClientTable, scopes: Readonly<Record<string, readonly string[]>>, values: Readonly<Record<string, JsonRowValue>>): boolean;
96
97
  export declare function quoteIdent(name: string): string;
97
98
  /**
98
99
  * §5.11: the app-side type of a column for local (plaintext) storage. For an
@@ -160,3 +161,5 @@ export type JsonRowValue = string | number | boolean | null | {
160
161
  export declare function hexToBytes(hex: string): Uint8Array;
161
162
  export declare function rowValueToJson(value: RowValue): JsonRowValue;
162
163
  export declare function jsonToRowValue(value: JsonRowValue): RowValue;
164
+ /** Convert each column value without changing the row's presence set. */
165
+ export declare function mapRowValues<T, U>(values: Readonly<Record<string, T>>, convert: (value: T) => U): Record<string, U>;
package/dist/schema.js CHANGED
@@ -10,41 +10,41 @@ const ALLOWED_FTS_TOKENIZERS = new Set([
10
10
  'porter unicode61',
11
11
  'trigram',
12
12
  ]);
13
+ function invalidSchema(message) {
14
+ throw new Error(message);
15
+ }
13
16
  export function compileClientSchema(schema) {
14
17
  const tables = new Map();
15
18
  const schemaObjectNames = new Set();
16
19
  for (const table of schema.tables) {
20
+ const tableLabel = `table ${table.name}`;
17
21
  if (tables.has(table.name)) {
18
- throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
22
+ invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
19
23
  }
20
24
  if (schemaObjectNames.has(table.name)) {
21
- throw new Error(`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`);
25
+ invalidSchema(`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`);
22
26
  }
23
27
  schemaObjectNames.add(table.name);
24
28
  const columnIndex = new Map();
25
29
  table.columns.forEach((column, index) => {
26
30
  if (columnIndex.has(column.name)) {
27
- throw new Error(`table ${table.name}: duplicate column ${JSON.stringify(column.name)}`);
31
+ invalidSchema(`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`);
28
32
  }
29
33
  columnIndex.set(column.name, index);
30
34
  });
31
35
  const primaryKeyIndex = columnIndex.get(table.primaryKey);
32
36
  if (primaryKeyIndex === undefined) {
33
- throw new Error(`table ${table.name}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
37
+ invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
34
38
  }
35
39
  // §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
36
40
  // string every implementation produces identically and local storage
37
41
  // resolves without deferring to a SQLite build.
38
42
  const primaryKeyColumn = table.columns[primaryKeyIndex];
39
- if (primaryKeyColumn !== undefined &&
40
- primaryKeyColumn.type !== 'string' &&
41
- primaryKeyColumn.type !== 'integer' &&
42
- primaryKeyColumn.type !== 'boolean' &&
43
- 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`);
43
+ if (!['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)) {
44
+ invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`);
45
45
  }
46
46
  if (table.scopes.length === 0) {
47
- throw new Error(`table ${table.name}: every synced table declares at least one scope pattern (§3.1)`);
47
+ invalidSchema(`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`);
48
48
  }
49
49
  const scopeColumnByVariable = new Map();
50
50
  const scopePrefixByVariable = new Map();
@@ -52,17 +52,17 @@ export function compileClientSchema(schema) {
52
52
  const pattern = typeof spec === 'string' ? spec : spec.pattern;
53
53
  const match = PATTERN_RE.exec(pattern);
54
54
  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}'`);
55
+ invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
56
56
  }
57
57
  const prefix = match[1];
58
58
  const variable = match[2];
59
59
  const column = typeof spec === 'string' ? variable : spec.column;
60
60
  if (!columnIndex.has(column)) {
61
- throw new Error(`table ${table.name}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
61
+ invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
62
62
  }
63
63
  const existing = scopeColumnByVariable.get(variable);
64
64
  if (existing !== undefined && existing !== column) {
65
- throw new Error(`table ${table.name}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
65
+ invalidSchema(`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
66
66
  }
67
67
  scopeColumnByVariable.set(variable, column);
68
68
  scopePrefixByVariable.set(variable, prefix);
@@ -83,43 +83,28 @@ export function compileClientSchema(schema) {
83
83
  for (const alias of ambiguous)
84
84
  columnIndexByCamel.delete(alias);
85
85
  const indexes = table.indexes ?? [];
86
- for (const index of indexes) {
87
- if (schemaObjectNames.has(index.name)) {
88
- throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} conflicts with another schema object`);
89
- }
90
- schemaObjectNames.add(index.name);
91
- for (const column of index.columns) {
92
- if (!columnIndex.has(column)) {
93
- throw new Error(`table ${table.name}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
94
- }
95
- }
96
- }
97
86
  const ftsIndexes = table.ftsIndexes ?? [];
98
- for (const index of ftsIndexes) {
99
- if (schemaObjectNames.has(index.name)) {
100
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} conflicts with another schema object`);
101
- }
87
+ for (const index of [...indexes, ...ftsIndexes]) {
88
+ const fts = 'tokenize' in index;
89
+ const label = `${tableLabel}: ${fts ? 'FTS projection' : 'index'} ${JSON.stringify(index.name)}`;
90
+ if (schemaObjectNames.has(index.name))
91
+ invalidSchema(`${label} conflicts with another schema object`);
102
92
  schemaObjectNames.add(index.name);
103
- 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`);
105
- }
106
- const seenColumns = new Set();
107
- for (const columnName of index.columns) {
108
- if (seenColumns.has(columnName)) {
109
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} repeats column ${JSON.stringify(columnName)}`);
110
- }
111
- seenColumns.add(columnName);
112
- const column = table.columns.find((candidate) => candidate.name === columnName);
113
- if (column === undefined) {
114
- throw new Error(`table ${table.name}: FTS projection ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(columnName)}`);
115
- }
116
- 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`);
118
- }
119
- }
120
- 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`);
93
+ if (fts && (!index.columns.length || index.columns.length > 32))
94
+ invalidSchema(`${label} needs between 1 and 32 columns`);
95
+ const seen = new Set();
96
+ for (const name of index.columns) {
97
+ if (fts && seen.has(name))
98
+ invalidSchema(`${label} repeats column ${JSON.stringify(name)}`);
99
+ seen.add(name);
100
+ const column = table.columns[columnIndex.get(name) ?? -1];
101
+ if (!column)
102
+ invalidSchema(`${label} names unknown column ${JSON.stringify(name)}`);
103
+ if (fts && localColumnType(column) !== 'string')
104
+ invalidSchema(`${label} column ${JSON.stringify(name)} must have string type`);
122
105
  }
106
+ if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
107
+ invalidSchema(`${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
123
108
  }
124
109
  tables.set(table.name, {
125
110
  name: table.name,
@@ -171,6 +156,14 @@ export function stripSyncColumns(rows) {
171
156
  return copy;
172
157
  });
173
158
  }
159
+ export function matchesLocalScopes(table, scopes, values) {
160
+ const entries = Object.entries(scopes);
161
+ return (entries.length > 0 &&
162
+ entries.every(([variable, allowed]) => {
163
+ const column = table.scopeColumnByVariable.get(variable);
164
+ return column !== undefined && allowed.includes(String(values[column]));
165
+ }));
166
+ }
174
167
  export function quoteIdent(name) {
175
168
  return `"${name.replaceAll('"', '""')}"`;
176
169
  }
@@ -216,7 +209,7 @@ function createSyncedTable(db, table) {
216
209
  return `${quoteIdent(column.name)} ${sqlType(column)}${notNull}${pk}`;
217
210
  });
218
211
  columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
219
- db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${columns.join(', ')})`);
212
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`);
220
213
  // Local secondary indexes (CREATE INDEX subset). Created here so both the
221
214
  // initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
222
215
  // materialize them. IF NOT EXISTS keeps it idempotent; the DROP TABLE in the
@@ -224,7 +217,7 @@ function createSyncedTable(db, table) {
224
217
  for (const index of table.indexes) {
225
218
  const unique = index.unique ? 'UNIQUE ' : '';
226
219
  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})`);
220
+ db.exec(`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`);
228
221
  }
229
222
  }
230
223
  const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
@@ -245,18 +238,21 @@ const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
245
238
  * comment below for the full rationale.
246
239
  */
247
240
  function createFtsProjection(db, table, index) {
241
+ const projection = quoteIdent(index.name);
242
+ const sourceTable = quoteIdent(table.name);
243
+ const primaryKey = quoteIdent(table.primaryKey);
248
244
  const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
249
245
  const indexedColumns = index.columns.map(quoteIdent);
250
246
  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}')`);
247
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`);
252
248
  const mapping = quoteIdent(`_syncular_fts_${index.name}`);
253
249
  const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
254
250
  `_syncular_fts_${index.name}`,
255
251
  ]).length > 0;
256
- db.exec(`CREATE TABLE IF NOT EXISTS ${mapping} (id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)`);
257
- const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
258
- const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
259
- const oldSourceId = `CAST(old.${quoteIdent(table.primaryKey)} AS TEXT)`;
252
+ db.exec(`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`);
253
+ const sourceId = `CAST(${primaryKey} AS TEXT)`;
254
+ const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
255
+ const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
260
256
  const projectionColumns = [
261
257
  quoteIdent(FTS_SOURCE_ID_COLUMN),
262
258
  ...indexedColumns,
@@ -265,9 +261,9 @@ function createFtsProjection(db, table, index) {
265
261
  newSourceId,
266
262
  ...index.columns.map((column) => `new.${quoteIdent(column)}`),
267
263
  ].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})`;
264
+ const deleteFor = (value) => `DELETE FROM ${projection} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
265
+ const deleteDisplaced = (select) => `DELETE FROM ${projection} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));DELETE FROM ${mapping} WHERE source_id IN (${select})`;
266
+ const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${projection}(rowid,${projectionColumns})VALUES((SELECT id FROM ${mapping} WHERE source_id=${newSourceId}),${newValues})`;
271
267
  // A clean insert cannot already have a projection row because the source
272
268
  // primary key is unique. Keep clean inserts linear by moving replacement
273
269
  // cleanup behind indexed source-table existence checks. SQLite does not
@@ -295,7 +291,7 @@ function createFtsProjection(db, table, index) {
295
291
  for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
296
292
  db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
297
293
  }
298
- const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
294
+ const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
299
295
  // Per secondary UNIQUE index: the different-PK rows about to be displaced
300
296
  // via that unique key. `=` makes NULL unique values match nothing, which is
301
297
  // exactly SQLite's unique-index semantics (NULLs never conflict).
@@ -304,7 +300,7 @@ function createFtsProjection(db, table, index) {
304
300
  const match = spec.columns
305
301
  .map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
306
302
  .join(' AND ');
307
- return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
303
+ return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
308
304
  });
309
305
  const insertGuardCondition = [
310
306
  replacementExists,
@@ -314,10 +310,11 @@ function createFtsProjection(db, table, index) {
314
310
  deleteFor(newSourceId),
315
311
  ...displacedByUnique.map(deleteDisplaced),
316
312
  ].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`);
313
+ const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`);
314
+ createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
315
+ createTrigger('ai', 'AFTER INSERT', insertNew);
316
+ createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
317
+ createTrigger('au', 'AFTER UPDATE', `${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`);
321
318
  // BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
322
319
  // different-PK row about to be displaced through a secondary unique index by
323
320
  // the new values. Only meaningful when the table has a unique index; with
@@ -327,14 +324,14 @@ function createFtsProjection(db, table, index) {
327
324
  .map((select) => `EXISTS (${select})`)
328
325
  .join(' OR ');
329
326
  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`);
327
+ createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
331
328
  }
332
329
  if (!existed) {
333
- db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
330
+ db.exec(`INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`);
334
331
  }
335
332
  if (!mapped || !existed) {
336
333
  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)}`);
334
+ db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`);
338
335
  }
339
336
  }
340
337
  /**
@@ -363,35 +360,18 @@ export function ensureLocalSyncedSchema(db, schema) {
363
360
  export function ensureLocalBookkeepingSchema(db) {
364
361
  db.transaction(() => {
365
362
  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))`);
363
+ const createTable = (name, columns) => db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
364
+ createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
365
+ createTable('_syncular_blob_commit_refs', 'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)');
370
366
  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)`);
367
+ db.exec("INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')");
368
+ 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');
369
+ 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)');
370
+ if (!db
371
+ .query('PRAGMA table_info(_syncular_outbox_before_images)')
372
+ .some((row) => row.name === 'delivery_seq'))
373
+ db.exec('ALTER TABLE _syncular_outbox_before_images ADD COLUMN delivery_seq INTEGER');
374
+ 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
375
  // Outcomes created before durable aggregate recovery retain no commit
396
376
  // envelope. New failed outcomes store it in the protected client DB.
397
377
  try {
@@ -400,32 +380,15 @@ export function ensureLocalBookkeepingSchema(db) {
400
380
  catch {
401
381
  // column already exists — the CREATE above included it
402
382
  }
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)`);
383
+ db.exec('CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)');
384
+ 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
385
  // §4.8 window registry: which units (scope values) of a window base are
416
386
  // live locally — the completeness oracle (I3) and the shrink driver
417
387
  // (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))`);
388
+ createTable('_syncular_windows', 'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)');
423
389
  // §4.8 E1: units that left the window but still had outbox-pinned rows.
424
390
  // 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)`);
391
+ createTable('_syncular_window_pending_evict', 'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL');
429
392
  });
430
393
  }
431
394
  /** Create both application and protected bookkeeping tables. */
@@ -448,16 +411,12 @@ const RESERVED_TABLE_PREFIX = '_syncular_';
448
411
  export function dropAndRecreateSyncedTables(db, schema) {
449
412
  // Drop virtual tables first. SQLite then removes their shadow tables as one
450
413
  // 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}%'`);
414
+ 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
415
  for (const row of virtualTables) {
455
416
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(`_syncular_fts_${String(row.name)}`)}`);
456
417
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
457
418
  }
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_%'`);
419
+ 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
420
  for (const row of existing) {
462
421
  db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
463
422
  }
@@ -488,14 +447,7 @@ export function fromSqlValue(column, value) {
488
447
  return value !== 0 && value !== false;
489
448
  case 'integer':
490
449
  return typeof value === 'bigint' ? Number(value) : value;
491
- case 'float':
492
- return value;
493
- case 'bytes':
494
- case 'crdt':
495
- return value;
496
- case 'string':
497
- case 'json':
498
- case 'blob_ref':
450
+ default:
499
451
  return value;
500
452
  }
501
453
  }
@@ -512,13 +464,13 @@ export function normalizeRecordKeys(table, record) {
512
464
  const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
513
465
  if (index === undefined) {
514
466
  if (key.startsWith('_sync_')) {
515
- throw invalidRequest(`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)`);
467
+ throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`);
516
468
  }
517
- throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values (snake_case and camelCase keys are accepted)`);
469
+ throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`);
518
470
  }
519
471
  const sqlName = table.columns[index].name;
520
472
  if (normalized.has(sqlName)) {
521
- throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values (as both snake_case and camelCase) — pass it once`);
473
+ throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`);
522
474
  }
523
475
  normalized.set(sqlName, value);
524
476
  }
@@ -584,3 +536,7 @@ export function jsonToRowValue(value) {
584
536
  }
585
537
  return value;
586
538
  }
539
+ /** Convert each column value without changing the row's presence set. */
540
+ export function mapRowValues(values, convert) {
541
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, convert(value)]));
542
+ }
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
  ]);