@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/README.md +15 -0
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +58 -58
- package/dist/blob.js +13 -39
- package/dist/client.js +178 -161
- package/dist/failed-overlay.d.ts +10 -5
- package/dist/failed-overlay.js +118 -36
- package/dist/outbox.d.ts +2 -1
- package/dist/outbox.js +20 -40
- package/dist/outcomes.d.ts +8 -0
- package/dist/outcomes.js +22 -30
- package/dist/previous-version.js +14 -28
- package/dist/schema.d.ts +3 -0
- package/dist/schema.js +89 -133
- 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 +95 -72
- package/src/blob.ts +17 -39
- package/src/client.ts +312 -214
- package/src/failed-overlay.ts +197 -48
- package/src/outbox.ts +25 -44
- package/src/outcomes.ts +40 -35
- package/src/previous-version.ts +14 -28
- package/src/schema.ts +154 -166
- package/src/state.ts +90 -33
- package/src/wasm-database.ts +56 -29
- package/src/worker-host.ts +34 -12
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
|
-
|
|
22
|
+
invalidSchema(`duplicate table ${JSON.stringify(table.name)}`);
|
|
19
23
|
}
|
|
20
24
|
if (schemaObjectNames.has(table.name)) {
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (column
|
|
114
|
-
|
|
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)}
|
|
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)}
|
|
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 ${
|
|
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}
|
|
257
|
-
const sourceId = `CAST(${
|
|
258
|
-
const newSourceId = `CAST(new.${
|
|
259
|
-
const oldSourceId = `CAST(old.${
|
|
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 ${
|
|
269
|
-
const deleteDisplaced = (select) => `DELETE FROM ${
|
|
270
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)
|
|
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 ${
|
|
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 ${
|
|
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}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
327
|
+
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
331
328
|
}
|
|
332
329
|
if (!existed) {
|
|
333
|
-
db.exec(`INSERT INTO ${
|
|
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,
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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(
|
|
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)`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
]);
|