@syncular/client 0.30.11 → 0.30.13
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 +7 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +44 -36
- package/dist/client.d.ts +3 -0
- package/dist/client.js +205 -181
- package/dist/failed-overlay.d.ts +4 -3
- package/dist/failed-overlay.js +58 -43
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +14 -24
- package/dist/outcomes.js +6 -5
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +63 -72
- package/dist/worker-entry.js +14 -35
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +1 -0
- package/package.json +3 -3
- package/src/apply.ts +81 -50
- package/src/client.ts +263 -226
- package/src/failed-overlay.ts +68 -57
- package/src/outbox.ts +19 -28
- package/src/outcomes.ts +7 -6
- package/src/schema.ts +81 -88
- package/src/worker-entry.ts +13 -50
- package/src/worker-host.ts +4 -0
- package/src/worker-protocol.ts +1 -0
package/dist/schema.js
CHANGED
|
@@ -10,42 +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) {
|
|
17
20
|
const tableLabel = `table ${table.name}`;
|
|
18
21
|
if (tables.has(table.name)) {
|
|
19
|
-
|
|
22
|
+
invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
|
|
20
23
|
}
|
|
21
24
|
if (schemaObjectNames.has(table.name)) {
|
|
22
|
-
|
|
25
|
+
invalidSchema(`table ${JSON.stringify(table.name)} conflicts with an index or FTS projection`);
|
|
23
26
|
}
|
|
24
27
|
schemaObjectNames.add(table.name);
|
|
25
28
|
const columnIndex = new Map();
|
|
26
29
|
table.columns.forEach((column, index) => {
|
|
27
30
|
if (columnIndex.has(column.name)) {
|
|
28
|
-
|
|
31
|
+
invalidSchema(`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`);
|
|
29
32
|
}
|
|
30
33
|
columnIndex.set(column.name, index);
|
|
31
34
|
});
|
|
32
35
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
33
36
|
if (primaryKeyIndex === undefined) {
|
|
34
|
-
|
|
37
|
+
invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`);
|
|
35
38
|
}
|
|
36
39
|
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
37
40
|
// string every implementation produces identically and local storage
|
|
38
41
|
// resolves without deferring to a SQLite build.
|
|
39
42
|
const primaryKeyColumn = table.columns[primaryKeyIndex];
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
primaryKeyColumn.type !== 'integer' &&
|
|
43
|
-
primaryKeyColumn.type !== 'boolean' &&
|
|
44
|
-
primaryKeyColumn.type !== '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`);
|
|
43
|
+
if (!['string', 'integer', 'boolean', 'json'].includes(primaryKeyColumn.type)) {
|
|
44
|
+
invalidSchema(`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible type (§2.4)`);
|
|
46
45
|
}
|
|
47
46
|
if (table.scopes.length === 0) {
|
|
48
|
-
|
|
47
|
+
invalidSchema(`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`);
|
|
49
48
|
}
|
|
50
49
|
const scopeColumnByVariable = new Map();
|
|
51
50
|
const scopePrefixByVariable = new Map();
|
|
@@ -53,17 +52,17 @@ export function compileClientSchema(schema) {
|
|
|
53
52
|
const pattern = typeof spec === 'string' ? spec : spec.pattern;
|
|
54
53
|
const match = PATTERN_RE.exec(pattern);
|
|
55
54
|
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
56
|
-
|
|
55
|
+
invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`);
|
|
57
56
|
}
|
|
58
57
|
const prefix = match[1];
|
|
59
58
|
const variable = match[2];
|
|
60
59
|
const column = typeof spec === 'string' ? variable : spec.column;
|
|
61
60
|
if (!columnIndex.has(column)) {
|
|
62
|
-
|
|
61
|
+
invalidSchema(`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`);
|
|
63
62
|
}
|
|
64
63
|
const existing = scopeColumnByVariable.get(variable);
|
|
65
64
|
if (existing !== undefined && existing !== column) {
|
|
66
|
-
|
|
65
|
+
invalidSchema(`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`);
|
|
67
66
|
}
|
|
68
67
|
scopeColumnByVariable.set(variable, column);
|
|
69
68
|
scopePrefixByVariable.set(variable, prefix);
|
|
@@ -84,44 +83,28 @@ export function compileClientSchema(schema) {
|
|
|
84
83
|
for (const alias of ambiguous)
|
|
85
84
|
columnIndexByCamel.delete(alias);
|
|
86
85
|
const indexes = table.indexes ?? [];
|
|
87
|
-
for (const index of indexes) {
|
|
88
|
-
if (schemaObjectNames.has(index.name)) {
|
|
89
|
-
throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`);
|
|
90
|
-
}
|
|
91
|
-
schemaObjectNames.add(index.name);
|
|
92
|
-
for (const column of index.columns) {
|
|
93
|
-
if (!columnIndex.has(column)) {
|
|
94
|
-
throw new Error(`${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
86
|
const ftsIndexes = table.ftsIndexes ?? [];
|
|
99
|
-
for (const index of ftsIndexes) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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`);
|
|
104
92
|
schemaObjectNames.add(index.name);
|
|
105
|
-
if (index.columns.length
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (column
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
if (localColumnType(column) !== 'string') {
|
|
119
|
-
throw new Error(`${projectionLabel} column ${JSON.stringify(columnName)} must have string type`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
|
|
123
|
-
throw new Error(`${projectionLabel} 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`);
|
|
124
105
|
}
|
|
106
|
+
if (fts && !ALLOWED_FTS_TOKENIZERS.has(index.tokenize))
|
|
107
|
+
invalidSchema(`${label} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`);
|
|
125
108
|
}
|
|
126
109
|
tables.set(table.name, {
|
|
127
110
|
name: table.name,
|
|
@@ -173,6 +156,14 @@ export function stripSyncColumns(rows) {
|
|
|
173
156
|
return copy;
|
|
174
157
|
});
|
|
175
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
|
+
}
|
|
176
167
|
export function quoteIdent(name) {
|
|
177
168
|
return `"${name.replaceAll('"', '""')}"`;
|
|
178
169
|
}
|
|
@@ -247,18 +238,21 @@ const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
|
|
|
247
238
|
* comment below for the full rationale.
|
|
248
239
|
*/
|
|
249
240
|
function createFtsProjection(db, table, index) {
|
|
241
|
+
const projection = quoteIdent(index.name);
|
|
242
|
+
const sourceTable = quoteIdent(table.name);
|
|
243
|
+
const primaryKey = quoteIdent(table.primaryKey);
|
|
250
244
|
const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
|
|
251
245
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
252
246
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
253
|
-
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${
|
|
247
|
+
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${projection} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`);
|
|
254
248
|
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
255
249
|
const mapped = db.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [
|
|
256
250
|
`_syncular_fts_${index.name}`,
|
|
257
251
|
]).length > 0;
|
|
258
252
|
db.exec(`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`);
|
|
259
|
-
const sourceId = `CAST(${
|
|
260
|
-
const newSourceId = `CAST(new.${
|
|
261
|
-
const oldSourceId = `CAST(old.${
|
|
253
|
+
const sourceId = `CAST(${primaryKey} AS TEXT)`;
|
|
254
|
+
const newSourceId = `CAST(new.${primaryKey} AS TEXT)`;
|
|
255
|
+
const oldSourceId = `CAST(old.${primaryKey} AS TEXT)`;
|
|
262
256
|
const projectionColumns = [
|
|
263
257
|
quoteIdent(FTS_SOURCE_ID_COLUMN),
|
|
264
258
|
...indexedColumns,
|
|
@@ -267,9 +261,9 @@ function createFtsProjection(db, table, index) {
|
|
|
267
261
|
newSourceId,
|
|
268
262
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
269
263
|
].join(', ');
|
|
270
|
-
const deleteFor = (value) => `DELETE FROM ${
|
|
271
|
-
const deleteDisplaced = (select) => `DELETE FROM ${
|
|
272
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)VALUES(${newSourceId});INSERT INTO ${
|
|
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})`;
|
|
273
267
|
// A clean insert cannot already have a projection row because the source
|
|
274
268
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
275
269
|
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
@@ -297,7 +291,7 @@ function createFtsProjection(db, table, index) {
|
|
|
297
291
|
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
298
292
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
299
293
|
}
|
|
300
|
-
const replacementExists = `EXISTS (SELECT 1 FROM ${
|
|
294
|
+
const replacementExists = `EXISTS (SELECT 1 FROM ${sourceTable} WHERE ${primaryKey} = new.${primaryKey})`;
|
|
301
295
|
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
302
296
|
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
303
297
|
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
@@ -306,7 +300,7 @@ function createFtsProjection(db, table, index) {
|
|
|
306
300
|
const match = spec.columns
|
|
307
301
|
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
308
302
|
.join(' AND ');
|
|
309
|
-
return `SELECT ${sourceId} FROM ${
|
|
303
|
+
return `SELECT ${sourceId} FROM ${sourceTable} WHERE ${match} AND ${primaryKey} !=new.${primaryKey}`;
|
|
310
304
|
});
|
|
311
305
|
const insertGuardCondition = [
|
|
312
306
|
replacementExists,
|
|
@@ -316,7 +310,7 @@ function createFtsProjection(db, table, index) {
|
|
|
316
310
|
deleteFor(newSourceId),
|
|
317
311
|
...displacedByUnique.map(deleteDisplaced),
|
|
318
312
|
].join('; ');
|
|
319
|
-
const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${
|
|
313
|
+
const createTrigger = (suffix, timing, body, when = '') => db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${sourceTable}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`);
|
|
320
314
|
createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
|
|
321
315
|
createTrigger('ai', 'AFTER INSERT', insertNew);
|
|
322
316
|
createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
|
|
@@ -333,11 +327,11 @@ function createFtsProjection(db, table, index) {
|
|
|
333
327
|
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
334
328
|
}
|
|
335
329
|
if (!existed) {
|
|
336
|
-
db.exec(`INSERT INTO ${
|
|
330
|
+
db.exec(`INSERT INTO ${projection}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${sourceTable}`);
|
|
337
331
|
}
|
|
338
332
|
if (!mapped || !existed) {
|
|
339
333
|
db.exec(`DELETE FROM ${mapping}`);
|
|
340
|
-
db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${
|
|
334
|
+
db.exec(`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${projection}`);
|
|
341
335
|
}
|
|
342
336
|
}
|
|
343
337
|
/**
|
|
@@ -373,6 +367,10 @@ export function ensureLocalBookkeepingSchema(db) {
|
|
|
373
367
|
db.exec("INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')");
|
|
374
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');
|
|
375
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');
|
|
376
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");
|
|
377
375
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
378
376
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
@@ -449,14 +447,7 @@ export function fromSqlValue(column, value) {
|
|
|
449
447
|
return value !== 0 && value !== false;
|
|
450
448
|
case 'integer':
|
|
451
449
|
return typeof value === 'bigint' ? Number(value) : value;
|
|
452
|
-
|
|
453
|
-
return value;
|
|
454
|
-
case 'bytes':
|
|
455
|
-
case 'crdt':
|
|
456
|
-
return value;
|
|
457
|
-
case 'string':
|
|
458
|
-
case 'json':
|
|
459
|
-
case 'blob_ref':
|
|
450
|
+
default:
|
|
460
451
|
return value;
|
|
461
452
|
}
|
|
462
453
|
}
|
|
@@ -473,13 +464,13 @@ export function normalizeRecordKeys(table, record) {
|
|
|
473
464
|
const index = table.columnIndex.get(key) ?? table.columnIndexByCamel.get(key);
|
|
474
465
|
if (index === undefined) {
|
|
475
466
|
if (key.startsWith('_sync_')) {
|
|
476
|
-
throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column
|
|
467
|
+
throw invalidRequest(`table ${table.name}: ${JSON.stringify(key)} is an internal sync column; omit it from mutation values`);
|
|
477
468
|
}
|
|
478
|
-
throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values
|
|
469
|
+
throw invalidRequest(`table ${table.name}: unknown column ${JSON.stringify(key)} in mutation values`);
|
|
479
470
|
}
|
|
480
471
|
const sqlName = table.columns[index].name;
|
|
481
472
|
if (normalized.has(sqlName)) {
|
|
482
|
-
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values
|
|
473
|
+
throw invalidRequest(`table ${table.name}: column ${JSON.stringify(sqlName)} appears twice in mutation values`);
|
|
483
474
|
}
|
|
484
475
|
normalized.set(sqlName, value);
|
|
485
476
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -94,6 +94,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
94
94
|
autoSyncScheduled = false;
|
|
95
95
|
if (closed ||
|
|
96
96
|
client === undefined ||
|
|
97
|
+
offline ||
|
|
97
98
|
client.securityLifecycle() === 'preflight') {
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
@@ -116,6 +117,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
116
117
|
if (!autoSync ||
|
|
117
118
|
closed ||
|
|
118
119
|
client === undefined ||
|
|
120
|
+
offline ||
|
|
119
121
|
client.securityLifecycle() === 'preflight' ||
|
|
120
122
|
intent.kind === 'none') {
|
|
121
123
|
return;
|
|
@@ -156,31 +158,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
156
158
|
}
|
|
157
159
|
return client;
|
|
158
160
|
}
|
|
159
|
-
function assertOnline() {
|
|
160
|
-
if (offline) {
|
|
161
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker transport is offline (setOffline(true))', true);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
function gateOffline(inner) {
|
|
165
|
-
return (request) => {
|
|
166
|
-
assertOnline();
|
|
167
|
-
return inner(request);
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
/** Offline gate that preserves the §5.4 `fetchUrl` capability marker. */
|
|
171
|
-
function gateSegmentsOffline(inner) {
|
|
172
|
-
const fetchUrl = inner.fetchUrl;
|
|
173
|
-
return Object.assign(gateOffline(inner), {
|
|
174
|
-
...(fetchUrl !== undefined
|
|
175
|
-
? {
|
|
176
|
-
fetchUrl: (url, onProgress) => {
|
|
177
|
-
assertOnline();
|
|
178
|
-
return fetchUrl(url, onProgress);
|
|
179
|
-
},
|
|
180
|
-
}
|
|
181
|
-
: {}),
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
161
|
async function init(config) {
|
|
185
162
|
if (client !== undefined) {
|
|
186
163
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
@@ -215,9 +192,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
215
192
|
}
|
|
216
193
|
}
|
|
217
194
|
}
|
|
218
|
-
const transport =
|
|
195
|
+
const transport = overrides.createTransport !== undefined
|
|
219
196
|
? overrides.createTransport(config)
|
|
220
|
-
: httpSyncTransport(config.endpoints.syncUrl, http)
|
|
197
|
+
: httpSyncTransport(config.endpoints.syncUrl, http);
|
|
221
198
|
const segments = overrides.createSegments !== undefined
|
|
222
199
|
? overrides.createSegments(config)
|
|
223
200
|
: config.endpoints.segmentsUrl !== undefined
|
|
@@ -231,18 +208,15 @@ export function startSyncWorker(overrides = {}) {
|
|
|
231
208
|
// `{clientId}` URL placeholder — start the client first, then attach
|
|
232
209
|
// the connector through a mutable slot read at connect time.
|
|
233
210
|
let realtimeConnector;
|
|
211
|
+
offline = config.transportEnabled === false;
|
|
234
212
|
const started = new SyncClient({
|
|
213
|
+
transportEnabled: !offline,
|
|
235
214
|
database,
|
|
236
215
|
schema: config.schema,
|
|
237
216
|
transport,
|
|
238
|
-
...(segments !== undefined
|
|
239
|
-
? { segments: gateSegmentsOffline(segments) }
|
|
240
|
-
: {}),
|
|
217
|
+
...(segments !== undefined ? { segments } : {}),
|
|
241
218
|
...(blobs !== undefined ? { blobs } : {}),
|
|
242
219
|
realtime: (handlers) => {
|
|
243
|
-
if (offline) {
|
|
244
|
-
throw new ClientSyncError('sync.transport_failed', 'the worker realtime channel is offline (setOffline(true))', true);
|
|
245
|
-
}
|
|
246
220
|
if (realtimeConnector === undefined) {
|
|
247
221
|
throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
|
|
248
222
|
}
|
|
@@ -399,8 +373,13 @@ export function startSyncWorker(overrides = {}) {
|
|
|
399
373
|
},
|
|
400
374
|
setOffline: (value) => {
|
|
401
375
|
offline = value;
|
|
402
|
-
|
|
403
|
-
|
|
376
|
+
requireClient().setTransportEnabled(!value);
|
|
377
|
+
if (offline) {
|
|
378
|
+
if (backgroundTimer !== undefined)
|
|
379
|
+
clearTimeout(backgroundTimer);
|
|
380
|
+
backgroundTimer = undefined;
|
|
381
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
382
|
+
}
|
|
404
383
|
else if (client !== undefined) {
|
|
405
384
|
const snapshot = client.diagnosticsSnapshot();
|
|
406
385
|
post({
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -85,6 +85,7 @@ export interface SyncClientHandleConfig {
|
|
|
85
85
|
readonly encryption?: EncryptionKeyringConfig;
|
|
86
86
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
87
87
|
readonly securityPreflight?: boolean;
|
|
88
|
+
readonly transportEnabled?: boolean;
|
|
88
89
|
readonly clientId?: string;
|
|
89
90
|
readonly limits?: SyncClientLimits;
|
|
90
91
|
readonly retainFailedCommits?: boolean;
|
package/dist/worker-host.js
CHANGED
|
@@ -566,6 +566,9 @@ function buildInitConfig(config, headers = config.headers) {
|
|
|
566
566
|
...(config.encryption !== undefined
|
|
567
567
|
? { encryption: config.encryption }
|
|
568
568
|
: {}),
|
|
569
|
+
...(config.transportEnabled !== undefined
|
|
570
|
+
? { transportEnabled: config.transportEnabled }
|
|
571
|
+
: {}),
|
|
569
572
|
...(config.securityPreflight !== undefined
|
|
570
573
|
? { securityPreflight: config.securityPreflight }
|
|
571
574
|
: {}),
|
|
@@ -78,6 +78,7 @@ export interface WorkerInitConfig {
|
|
|
78
78
|
readonly encryption?: EncryptionKeyringConfig;
|
|
79
79
|
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
80
80
|
readonly securityPreflight?: boolean;
|
|
81
|
+
readonly transportEnabled?: boolean;
|
|
81
82
|
readonly clientId?: string;
|
|
82
83
|
readonly limits?: SyncClientLimits;
|
|
83
84
|
readonly retainFailedCommits?: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.13",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.30.
|
|
92
|
+
"@syncular/core": "0.30.13"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.30.
|
|
95
|
+
"@syncular/server": "0.30.13",
|
|
96
96
|
"playwright": "1.63.0",
|
|
97
97
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
98
|
}
|
package/src/apply.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RETAINED_ROWS } from './failed-overlay';
|
|
1
2
|
import { retainedBaseWrite, restoreFailedBases } from './failed-overlay';
|
|
2
3
|
/**
|
|
3
4
|
* Local application of server data: `COMMIT` frames (§4.5), rows segments
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
type CompiledClientSchema,
|
|
20
21
|
type CompiledClientTable,
|
|
21
22
|
quoteIdent,
|
|
23
|
+
matchesLocalScopes,
|
|
24
|
+
type JsonRowValue,
|
|
22
25
|
SYNC_VERSION_COLUMN,
|
|
23
26
|
toSqlValue,
|
|
24
27
|
rowValueToJson,
|
|
@@ -93,7 +96,8 @@ export async function applyCommitFrame(
|
|
|
93
96
|
schema: CompiledClientSchema,
|
|
94
97
|
frame: CommitFrame,
|
|
95
98
|
encryption?: EncryptionConfig,
|
|
96
|
-
transaction:
|
|
99
|
+
transaction: (fn: () => readonly string[] | undefined) => unknown = (fn) =>
|
|
100
|
+
db.transaction(fn),
|
|
97
101
|
): Promise<void> {
|
|
98
102
|
type Resolved =
|
|
99
103
|
| { op: 'delete'; table: CompiledClientTable; rowId: string }
|
|
@@ -138,14 +142,31 @@ export async function applyCommitFrame(
|
|
|
138
142
|
});
|
|
139
143
|
}
|
|
140
144
|
transaction(() => {
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
const rowKeys = frame.tables.some((name) =>
|
|
146
|
+
schema.tables.get(name)?.indexes.some((index) => index.unique),
|
|
147
|
+
)
|
|
148
|
+
? undefined
|
|
149
|
+
: frame.changes.map((change) =>
|
|
150
|
+
JSON.stringify([frame.tables[change.tableIndex], change.rowId]),
|
|
151
|
+
);
|
|
152
|
+
restoreFailedBases(db, schema, false, rowKeys);
|
|
153
|
+
for (const [index, change] of resolved.entries()) {
|
|
154
|
+
const rowId = frame.changes[index]!.rowId;
|
|
155
|
+
db.exec(
|
|
156
|
+
"UPDATE _syncular_outbox_before_images SET delivery_seq=MAX(COALESCE(delivery_seq,0),?) WHERE (client_commit_id,op_index) IN(SELECT client_commit_id,json_each.key FROM _syncular_outbox,json_each(operations) WHERE json_extract(value,'$.table')=? AND json_extract(value,'$.rowId')=?)",
|
|
157
|
+
[frame.commitSeq, change.table.name, rowId],
|
|
158
|
+
);
|
|
159
|
+
db.exec(
|
|
160
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq<=?`,
|
|
161
|
+
[change.table.name, rowId, frame.commitSeq],
|
|
162
|
+
);
|
|
143
163
|
if (change.op === 'delete') {
|
|
144
164
|
deleteLocalRow(db, change.table, change.rowId);
|
|
145
165
|
} else {
|
|
146
166
|
upsertLocalRow(db, change.table, change.values, change.rowVersion);
|
|
147
167
|
}
|
|
148
168
|
}
|
|
169
|
+
return rowKeys;
|
|
149
170
|
});
|
|
150
171
|
}
|
|
151
172
|
|
|
@@ -195,39 +216,43 @@ export function validateSegmentColumns(
|
|
|
195
216
|
* (`sync.scope_revoked`) when the table has no local mapping for a key:
|
|
196
217
|
* precision or nothing, never clear-the-table.
|
|
197
218
|
*/
|
|
219
|
+
export function localScopePredicate(
|
|
220
|
+
table: CompiledClientTable,
|
|
221
|
+
effective: ScopeMap,
|
|
222
|
+
): { sql: string; params: string[] } | undefined {
|
|
223
|
+
const entries = Object.entries(effective);
|
|
224
|
+
if (!entries.length || entries.some(([, values]) => !values.length)) return;
|
|
225
|
+
const params: string[] = [];
|
|
226
|
+
const sql = entries
|
|
227
|
+
.map(([variable, values]) => {
|
|
228
|
+
const column = table.scopeColumnByVariable.get(variable);
|
|
229
|
+
if (column === undefined)
|
|
230
|
+
throw new ClientSyncError(
|
|
231
|
+
'sync.scope_revoked',
|
|
232
|
+
'table has no local scope-column mapping',
|
|
233
|
+
);
|
|
234
|
+
params.push(...values);
|
|
235
|
+
return `${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`;
|
|
236
|
+
})
|
|
237
|
+
.join(' AND ');
|
|
238
|
+
return { sql, params };
|
|
239
|
+
}
|
|
240
|
+
|
|
198
241
|
export function deleteScopedRows(
|
|
199
242
|
db: ClientDatabase,
|
|
200
243
|
table: CompiledClientTable,
|
|
201
244
|
effective: ScopeMap,
|
|
202
245
|
): void {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
const
|
|
206
|
-
const params: string[] = [];
|
|
207
|
-
for (const [variable, values] of entries) {
|
|
208
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
209
|
-
if (column === undefined) {
|
|
210
|
-
throw new ClientSyncError(
|
|
211
|
-
'sync.scope_revoked',
|
|
212
|
-
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§3.3 fail-closed)`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
if (values.length === 0) return;
|
|
216
|
-
clauses.push(
|
|
217
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
218
|
-
);
|
|
219
|
-
params.push(...values);
|
|
220
|
-
}
|
|
246
|
+
const scope = localScopePredicate(table, effective);
|
|
247
|
+
if (!scope) return;
|
|
248
|
+
const { sql, params } = scope;
|
|
221
249
|
for (const row of db.query(
|
|
222
|
-
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${
|
|
250
|
+
`SELECT ${quoteIdent(table.primaryKey)} AS id FROM ${quoteIdent(table.name)} WHERE ${sql}`,
|
|
223
251
|
params,
|
|
224
252
|
)) {
|
|
225
253
|
retainedBaseWrite(db, table, String(row.id));
|
|
226
254
|
}
|
|
227
|
-
db.exec(
|
|
228
|
-
`DELETE FROM ${quoteIdent(table.name)} WHERE ${clauses.join(' AND ')}`,
|
|
229
|
-
params,
|
|
230
|
-
);
|
|
255
|
+
db.exec(`DELETE FROM ${quoteIdent(table.name)} WHERE ${sql}`, params);
|
|
231
256
|
}
|
|
232
257
|
|
|
233
258
|
/**
|
|
@@ -246,24 +271,9 @@ export function evictScopedRows(
|
|
|
246
271
|
effective: ScopeMap,
|
|
247
272
|
pinnedRowIds: ReadonlySet<string>,
|
|
248
273
|
): { remaining: boolean; deferred: boolean } {
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
const
|
|
252
|
-
const params: string[] = [];
|
|
253
|
-
for (const [variable, values] of entries) {
|
|
254
|
-
const column = table.scopeColumnByVariable.get(variable);
|
|
255
|
-
if (column === undefined) {
|
|
256
|
-
throw new ClientSyncError(
|
|
257
|
-
'sync.scope_revoked',
|
|
258
|
-
`table ${JSON.stringify(table.name)} has no local scope-column mapping for ${JSON.stringify(variable)} (§4.8/§3.3 fail-closed)`,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
if (values.length === 0) return { remaining: false, deferred: false };
|
|
262
|
-
clauses.push(
|
|
263
|
-
`${quoteIdent(column)} IN (${values.map(() => '?').join(', ')})`,
|
|
264
|
-
);
|
|
265
|
-
params.push(...values);
|
|
266
|
-
}
|
|
274
|
+
const scope = localScopePredicate(table, effective);
|
|
275
|
+
if (!scope) return { remaining: false, deferred: false };
|
|
276
|
+
const { sql, params } = scope;
|
|
267
277
|
const pk = quoteIdent(table.primaryKey);
|
|
268
278
|
let pinnedClause = '';
|
|
269
279
|
if (pinnedRowIds.size > 0) {
|
|
@@ -272,19 +282,40 @@ export function evictScopedRows(
|
|
|
272
282
|
params.push(...ids);
|
|
273
283
|
}
|
|
274
284
|
const target = quoteIdent(table.name);
|
|
285
|
+
for (const row of db.query(
|
|
286
|
+
`SELECT id,intent,base FROM ${RETAINED_ROWS} WHERE commit_seq IS NOT NULL AND tbl=?`,
|
|
287
|
+
[table.name],
|
|
288
|
+
)) {
|
|
289
|
+
if (
|
|
290
|
+
!pinnedRowIds.has(row.id as string) &&
|
|
291
|
+
[row.intent, row.base].some(
|
|
292
|
+
(json) =>
|
|
293
|
+
json !== null &&
|
|
294
|
+
matchesLocalScopes(
|
|
295
|
+
table,
|
|
296
|
+
effective,
|
|
297
|
+
JSON.parse(json as string) as Record<string, JsonRowValue>,
|
|
298
|
+
),
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
db.exec(
|
|
302
|
+
`DELETE FROM ${RETAINED_ROWS} WHERE tbl=? AND id=? AND commit_seq IS NOT NULL`,
|
|
303
|
+
[table.name, row.id as string],
|
|
304
|
+
);
|
|
305
|
+
}
|
|
275
306
|
db.exec(
|
|
276
|
-
`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${
|
|
307
|
+
`DELETE FROM ${target} WHERE ${pk} IN(SELECT ${pk} FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1024)`,
|
|
277
308
|
params,
|
|
278
309
|
);
|
|
279
310
|
const remaining =
|
|
280
311
|
db.query(
|
|
281
|
-
`SELECT 1 FROM ${target} WHERE ${
|
|
312
|
+
`SELECT 1 FROM ${target} WHERE ${sql}${pinnedClause} LIMIT 1`,
|
|
282
313
|
params,
|
|
283
314
|
).length > 0;
|
|
284
315
|
const deferred =
|
|
285
316
|
remaining ||
|
|
286
317
|
db.query(
|
|
287
|
-
`SELECT 1 FROM ${target} WHERE ${
|
|
318
|
+
`SELECT 1 FROM ${target} WHERE ${sql} LIMIT 1`,
|
|
288
319
|
params.slice(0, params.length - pinnedRowIds.size),
|
|
289
320
|
).length > 0;
|
|
290
321
|
return { remaining, deferred };
|
|
@@ -329,7 +360,7 @@ export async function applySqliteSegment(
|
|
|
329
360
|
const withImage = db.withSqliteImage?.bind(db);
|
|
330
361
|
if (withImage === undefined) {
|
|
331
362
|
throw invalidRequest(
|
|
332
|
-
'
|
|
363
|
+
'database cannot import images; disable accept bit 2 (§4.2)',
|
|
333
364
|
);
|
|
334
365
|
}
|
|
335
366
|
if (descriptor.table !== table.name) {
|
|
@@ -506,12 +537,12 @@ export async function applyRowsSegment(
|
|
|
506
537
|
first = false;
|
|
507
538
|
if (!clearThisBlock && rows.length === 0) continue;
|
|
508
539
|
(options.transaction ?? ((fn) => db.transaction(fn)))(() => {
|
|
509
|
-
restoreFailedBases(db, schema);
|
|
540
|
+
const protectedRows = restoreFailedBases(db, schema);
|
|
510
541
|
if (clearThisBlock) {
|
|
511
542
|
deleteScopedRows(db, table, options.effective);
|
|
512
543
|
}
|
|
513
544
|
for (const row of rows) {
|
|
514
|
-
upsertLocalRow(db, table, row.values, row.serverVersion);
|
|
545
|
+
upsertLocalRow(db, table, row.values, row.serverVersion, protectedRows);
|
|
515
546
|
applied += 1;
|
|
516
547
|
if (applied % 1024 === 0) options.onProgress?.(applied);
|
|
517
548
|
}
|