@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/README.md +8 -0
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +118 -117
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +82 -15
- package/dist/outbox.js +11 -21
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +18 -27
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -85
- package/dist/state.d.ts +6 -9
- package/dist/state.js +66 -32
- package/dist/wasm-database.js +40 -24
- package/dist/worker-host.js +37 -11
- package/package.json +3 -3
- package/src/apply.ts +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +222 -163
- package/src/failed-overlay.ts +155 -17
- package/src/outbox.ts +11 -21
- package/src/outcomes.ts +35 -31
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +91 -96
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
116
|
+
throw new Error(`${projectionLabel} names unknown column ${JSON.stringify(columnName)}`);
|
|
115
117
|
}
|
|
116
118
|
if (localColumnType(column) !== 'string') {
|
|
117
|
-
throw new Error(
|
|
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(
|
|
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)}
|
|
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)}
|
|
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
|
|
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}
|
|
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
|
|
269
|
-
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));
|
|
270
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)
|
|
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)} !=
|
|
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}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
333
|
+
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
331
334
|
}
|
|
332
335
|
if (!existed) {
|
|
333
|
-
db.exec(`INSERT INTO ${quoteIdent(index.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,
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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(
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
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
|
|
51
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
55
52
|
}
|
|
56
53
|
/**
|
|
57
|
-
* §7.4.3 reset: keep
|
|
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(
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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,
|
|
138
|
+
db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
|
|
105
139
|
key,
|
|
106
140
|
value,
|
|
107
141
|
]);
|
package/dist/wasm-database.js
CHANGED
|
@@ -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
|
-
|
|
253
|
-
|
|
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
|
|
274
|
-
|
|
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
|
|
294
|
+
db?.close();
|
|
295
|
+
util?.pauseVfs();
|
|
296
|
+
await lease.release();
|
|
281
297
|
throw error;
|
|
282
298
|
}
|
|
283
299
|
}
|