@syncular/client 0.30.9 → 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 +14 -2
- package/dist/apply.js +17 -25
- package/dist/blob.js +13 -39
- package/dist/client.js +128 -126
- package/dist/failed-overlay.d.ts +6 -2
- package/dist/failed-overlay.js +87 -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 +5 -4
- package/src/apply.ts +17 -25
- package/src/blob.ts +17 -39
- package/src/client.ts +238 -177
- package/src/failed-overlay.ts +164 -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/src/schema.ts
CHANGED
|
@@ -98,6 +98,7 @@ export function compileClientSchema(
|
|
|
98
98
|
const tables = new Map<string, CompiledClientTable>();
|
|
99
99
|
const schemaObjectNames = new Set<string>();
|
|
100
100
|
for (const table of schema.tables) {
|
|
101
|
+
const tableLabel = `table ${table.name}`;
|
|
101
102
|
if (tables.has(table.name)) {
|
|
102
103
|
throw new Error(`duplicate table ${JSON.stringify(table.name)}`);
|
|
103
104
|
}
|
|
@@ -111,7 +112,7 @@ export function compileClientSchema(
|
|
|
111
112
|
table.columns.forEach((column, index) => {
|
|
112
113
|
if (columnIndex.has(column.name)) {
|
|
113
114
|
throw new Error(
|
|
114
|
-
|
|
115
|
+
`${tableLabel}: duplicate column ${JSON.stringify(column.name)}`,
|
|
115
116
|
);
|
|
116
117
|
}
|
|
117
118
|
columnIndex.set(column.name, index);
|
|
@@ -119,7 +120,7 @@ export function compileClientSchema(
|
|
|
119
120
|
const primaryKeyIndex = columnIndex.get(table.primaryKey);
|
|
120
121
|
if (primaryKeyIndex === undefined) {
|
|
121
122
|
throw new Error(
|
|
122
|
-
|
|
123
|
+
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} is not a column`,
|
|
123
124
|
);
|
|
124
125
|
}
|
|
125
126
|
// §2.4 primary-key eligibility: a primary key MUST render to a `rowId`
|
|
@@ -134,12 +135,12 @@ export function compileClientSchema(
|
|
|
134
135
|
primaryKeyColumn.type !== 'json'
|
|
135
136
|
) {
|
|
136
137
|
throw new Error(
|
|
137
|
-
|
|
138
|
+
`${tableLabel}: primary key ${JSON.stringify(table.primaryKey)} has an ineligible column type (§2.4); a primary key must be string, integer, boolean, or json`,
|
|
138
139
|
);
|
|
139
140
|
}
|
|
140
141
|
if (table.scopes.length === 0) {
|
|
141
142
|
throw new Error(
|
|
142
|
-
|
|
143
|
+
`${tableLabel}: every synced table declares at least one scope pattern (§3.1)`,
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
const scopeColumnByVariable = new Map<string, string>();
|
|
@@ -149,7 +150,7 @@ export function compileClientSchema(
|
|
|
149
150
|
const match = PATTERN_RE.exec(pattern);
|
|
150
151
|
if (match === null || match[1] === undefined || match[2] === undefined) {
|
|
151
152
|
throw new Error(
|
|
152
|
-
|
|
153
|
+
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} must be 'prefix:{variable}'`,
|
|
153
154
|
);
|
|
154
155
|
}
|
|
155
156
|
const prefix = match[1];
|
|
@@ -157,13 +158,13 @@ export function compileClientSchema(
|
|
|
157
158
|
const column = typeof spec === 'string' ? variable : spec.column;
|
|
158
159
|
if (!columnIndex.has(column)) {
|
|
159
160
|
throw new Error(
|
|
160
|
-
|
|
161
|
+
`${tableLabel}: scope pattern ${JSON.stringify(pattern)} names unknown column ${JSON.stringify(column)}`,
|
|
161
162
|
);
|
|
162
163
|
}
|
|
163
164
|
const existing = scopeColumnByVariable.get(variable);
|
|
164
165
|
if (existing !== undefined && existing !== column) {
|
|
165
166
|
throw new Error(
|
|
166
|
-
|
|
167
|
+
`${tableLabel}: variable ${JSON.stringify(variable)} maps to two different columns (§3.1)`,
|
|
167
168
|
);
|
|
168
169
|
}
|
|
169
170
|
scopeColumnByVariable.set(variable, column);
|
|
@@ -186,36 +187,35 @@ export function compileClientSchema(
|
|
|
186
187
|
for (const index of indexes) {
|
|
187
188
|
if (schemaObjectNames.has(index.name)) {
|
|
188
189
|
throw new Error(
|
|
189
|
-
|
|
190
|
+
`${tableLabel}: index ${JSON.stringify(index.name)} conflicts with another schema object`,
|
|
190
191
|
);
|
|
191
192
|
}
|
|
192
193
|
schemaObjectNames.add(index.name);
|
|
193
194
|
for (const column of index.columns) {
|
|
194
195
|
if (!columnIndex.has(column)) {
|
|
195
196
|
throw new Error(
|
|
196
|
-
|
|
197
|
+
`${tableLabel}: index ${JSON.stringify(index.name)} names unknown column ${JSON.stringify(column)}`,
|
|
197
198
|
);
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
202
|
const ftsIndexes = table.ftsIndexes ?? [];
|
|
202
203
|
for (const index of ftsIndexes) {
|
|
204
|
+
const projectionLabel = `${tableLabel}: FTS projection ${JSON.stringify(index.name)}`;
|
|
203
205
|
if (schemaObjectNames.has(index.name)) {
|
|
204
206
|
throw new Error(
|
|
205
|
-
|
|
207
|
+
`${projectionLabel} conflicts with another schema object`,
|
|
206
208
|
);
|
|
207
209
|
}
|
|
208
210
|
schemaObjectNames.add(index.name);
|
|
209
211
|
if (index.columns.length === 0 || index.columns.length > 32) {
|
|
210
|
-
throw new Error(
|
|
211
|
-
`table ${table.name}: FTS projection ${JSON.stringify(index.name)} needs between 1 and 32 columns`,
|
|
212
|
-
);
|
|
212
|
+
throw new Error(`${projectionLabel} needs between 1 and 32 columns`);
|
|
213
213
|
}
|
|
214
214
|
const seenColumns = new Set<string>();
|
|
215
215
|
for (const columnName of index.columns) {
|
|
216
216
|
if (seenColumns.has(columnName)) {
|
|
217
217
|
throw new Error(
|
|
218
|
-
|
|
218
|
+
`${projectionLabel} repeats column ${JSON.stringify(columnName)}`,
|
|
219
219
|
);
|
|
220
220
|
}
|
|
221
221
|
seenColumns.add(columnName);
|
|
@@ -224,18 +224,18 @@ export function compileClientSchema(
|
|
|
224
224
|
);
|
|
225
225
|
if (column === undefined) {
|
|
226
226
|
throw new Error(
|
|
227
|
-
|
|
227
|
+
`${projectionLabel} names unknown column ${JSON.stringify(columnName)}`,
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
if (localColumnType(column) !== 'string') {
|
|
231
231
|
throw new Error(
|
|
232
|
-
|
|
232
|
+
`${projectionLabel} column ${JSON.stringify(columnName)} must have string type`,
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
if (!ALLOWED_FTS_TOKENIZERS.has(index.tokenize)) {
|
|
237
237
|
throw new Error(
|
|
238
|
-
|
|
238
|
+
`${projectionLabel} tokenizer ${JSON.stringify(index.tokenize)} is not allowlisted`,
|
|
239
239
|
);
|
|
240
240
|
}
|
|
241
241
|
}
|
|
@@ -344,7 +344,7 @@ function createSyncedTable(
|
|
|
344
344
|
});
|
|
345
345
|
columns.push(`${quoteIdent(SYNC_VERSION_COLUMN)} INTEGER NOT NULL DEFAULT 0`);
|
|
346
346
|
db.exec(
|
|
347
|
-
`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}
|
|
347
|
+
`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)}(${columns.join(', ')})`,
|
|
348
348
|
);
|
|
349
349
|
// Local secondary indexes (CREATE INDEX subset). Created here so both the
|
|
350
350
|
// initial ensureLocalSchema and the §7.4.3 drop-and-recreate reset path
|
|
@@ -354,7 +354,7 @@ function createSyncedTable(
|
|
|
354
354
|
const unique = index.unique ? 'UNIQUE ' : '';
|
|
355
355
|
const cols = index.columns.map((c) => quoteIdent(c)).join(', ');
|
|
356
356
|
db.exec(
|
|
357
|
-
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}
|
|
357
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdent(index.name)} ON ${quoteIdent(table.name)}(${cols})`,
|
|
358
358
|
);
|
|
359
359
|
}
|
|
360
360
|
}
|
|
@@ -390,7 +390,7 @@ function createFtsProjection(
|
|
|
390
390
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
391
391
|
const tokenizer = index.tokenize.replaceAll("'", "''");
|
|
392
392
|
db.exec(
|
|
393
|
-
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED
|
|
393
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS ${quoteIdent(index.name)} USING fts5(${quoteIdent(FTS_SOURCE_ID_COLUMN)} UNINDEXED,${indexedColumns.join(', ')},tokenize='${tokenizer}')`,
|
|
394
394
|
);
|
|
395
395
|
|
|
396
396
|
const mapping = quoteIdent(`_syncular_fts_${index.name}`);
|
|
@@ -399,7 +399,7 @@ function createFtsProjection(
|
|
|
399
399
|
`_syncular_fts_${index.name}`,
|
|
400
400
|
]).length > 0;
|
|
401
401
|
db.exec(
|
|
402
|
-
`CREATE TABLE IF NOT EXISTS ${mapping}
|
|
402
|
+
`CREATE TABLE IF NOT EXISTS ${mapping}(id INTEGER PRIMARY KEY,source_id TEXT NOT NULL UNIQUE)`,
|
|
403
403
|
);
|
|
404
404
|
const sourceId = `CAST(${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
405
405
|
const newSourceId = `CAST(new.${quoteIdent(table.primaryKey)} AS TEXT)`;
|
|
@@ -413,10 +413,10 @@ function createFtsProjection(
|
|
|
413
413
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
414
414
|
].join(', ');
|
|
415
415
|
const deleteFor = (value: string) =>
|
|
416
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE rowid
|
|
416
|
+
`DELETE FROM ${quoteIdent(index.name)} WHERE rowid=(SELECT id FROM ${mapping} WHERE source_id=${value});DELETE FROM ${mapping} WHERE source_id=${value}`;
|
|
417
417
|
const deleteDisplaced = (select: string) =>
|
|
418
|
-
`DELETE FROM ${quoteIdent(index.name)} WHERE rowid IN (SELECT id FROM ${mapping} WHERE source_id IN (${select}));
|
|
419
|
-
const insertNew = `INSERT INTO ${mapping}(source_id)
|
|
418
|
+
`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})`;
|
|
419
|
+
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})`;
|
|
420
420
|
|
|
421
421
|
// A clean insert cannot already have a projection row because the source
|
|
422
422
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
@@ -454,7 +454,7 @@ function createFtsProjection(
|
|
|
454
454
|
const match = spec.columns
|
|
455
455
|
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
456
456
|
.join(' AND ');
|
|
457
|
-
return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} !=
|
|
457
|
+
return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} !=new.${quoteIdent(table.primaryKey)}`;
|
|
458
458
|
});
|
|
459
459
|
const insertGuardCondition = [
|
|
460
460
|
replacementExists,
|
|
@@ -465,17 +465,22 @@ function createFtsProjection(
|
|
|
465
465
|
...displacedByUnique.map(deleteDisplaced),
|
|
466
466
|
].join('; ');
|
|
467
467
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
468
|
+
const createTrigger = (
|
|
469
|
+
suffix: string,
|
|
470
|
+
timing: string,
|
|
471
|
+
body: string,
|
|
472
|
+
when = '',
|
|
473
|
+
) =>
|
|
474
|
+
db.exec(
|
|
475
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_${suffix}`)} ${timing} ON ${quoteIdent(table.name)}${when ? ` WHEN ${when}` : ''} BEGIN ${body};END`,
|
|
476
|
+
);
|
|
477
|
+
createTrigger('bi', 'BEFORE INSERT', insertGuardBody, insertGuardCondition);
|
|
478
|
+
createTrigger('ai', 'AFTER INSERT', insertNew);
|
|
479
|
+
createTrigger('ad', 'AFTER DELETE', deleteFor(oldSourceId));
|
|
480
|
+
createTrigger(
|
|
481
|
+
'au',
|
|
482
|
+
'AFTER UPDATE',
|
|
483
|
+
`${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; ${insertNew}`,
|
|
479
484
|
);
|
|
480
485
|
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
481
486
|
// different-PK row about to be displaced through a secondary unique index by
|
|
@@ -486,20 +491,18 @@ function createFtsProjection(
|
|
|
486
491
|
.map((select) => `EXISTS (${select})`)
|
|
487
492
|
.join(' OR ');
|
|
488
493
|
const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
|
|
489
|
-
|
|
490
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
|
|
491
|
-
);
|
|
494
|
+
createTrigger('bu', 'BEFORE UPDATE', updateGuardBody, updateGuardCondition);
|
|
492
495
|
}
|
|
493
496
|
|
|
494
497
|
if (!existed) {
|
|
495
498
|
db.exec(
|
|
496
|
-
`INSERT INTO ${quoteIdent(index.name)}
|
|
499
|
+
`INSERT INTO ${quoteIdent(index.name)}(${projectionColumns})SELECT ${sourceId},${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`,
|
|
497
500
|
);
|
|
498
501
|
}
|
|
499
502
|
if (!mapped || !existed) {
|
|
500
503
|
db.exec(`DELETE FROM ${mapping}`);
|
|
501
504
|
db.exec(
|
|
502
|
-
`INSERT INTO ${mapping}(id,
|
|
505
|
+
`INSERT INTO ${mapping}(id,source_id)SELECT rowid,${quoteIdent(FTS_SOURCE_ID_COLUMN)} FROM ${quoteIdent(index.name)}`,
|
|
503
506
|
);
|
|
504
507
|
}
|
|
505
508
|
}
|
|
@@ -534,39 +537,31 @@ export function ensureLocalSyncedSchema(
|
|
|
534
537
|
export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
535
538
|
db.transaction(() => {
|
|
536
539
|
ensureFailedOverlaySchema(db);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
540
|
+
const createTable = (name: string, columns: string) =>
|
|
541
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${name}(${columns})`);
|
|
542
|
+
createTable('_syncular_meta', 'key TEXT PRIMARY KEY,value TEXT NOT NULL');
|
|
543
|
+
createTable(
|
|
544
|
+
'_syncular_blob_commit_refs',
|
|
545
|
+
'commit_id TEXT NOT NULL,blob_id TEXT NOT NULL,PRIMARY KEY(commit_id,blob_id)',
|
|
546
|
+
);
|
|
541
547
|
db.exec(
|
|
542
548
|
'CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id)',
|
|
543
549
|
);
|
|
544
550
|
db.exec(
|
|
545
|
-
|
|
551
|
+
"INSERT OR IGNORE INTO _syncular_meta(key,value)VALUES('localRevision','0')",
|
|
552
|
+
);
|
|
553
|
+
createTable(
|
|
554
|
+
'_syncular_outbox',
|
|
555
|
+
'seq INTEGER PRIMARY KEY AUTOINCREMENT,client_commit_id TEXT NOT NULL UNIQUE,created_at_ms INTEGER NOT NULL,operations TEXT NOT NULL',
|
|
556
|
+
);
|
|
557
|
+
createTable(
|
|
558
|
+
'_syncular_outbox_before_images',
|
|
559
|
+
'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)',
|
|
560
|
+
);
|
|
561
|
+
createTable(
|
|
562
|
+
'_syncular_commit_outcomes',
|
|
563
|
+
"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",
|
|
546
564
|
);
|
|
547
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
548
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
549
|
-
client_commit_id TEXT NOT NULL UNIQUE,
|
|
550
|
-
created_at_ms INTEGER NOT NULL,
|
|
551
|
-
operations TEXT NOT NULL)`);
|
|
552
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox_before_images(
|
|
553
|
-
client_commit_id TEXT NOT NULL,
|
|
554
|
-
op_index INTEGER NOT NULL,
|
|
555
|
-
existed INTEGER NOT NULL CHECK(existed IN (0, 1)),
|
|
556
|
-
sync_version INTEGER,
|
|
557
|
-
values_json TEXT,
|
|
558
|
-
PRIMARY KEY(client_commit_id, op_index))`);
|
|
559
|
-
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
|
|
560
|
-
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
561
|
-
client_commit_id TEXT NOT NULL UNIQUE,
|
|
562
|
-
status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
|
|
563
|
-
recorded_at_ms INTEGER NOT NULL,
|
|
564
|
-
results TEXT NOT NULL,
|
|
565
|
-
operations TEXT,
|
|
566
|
-
resolution TEXT NOT NULL DEFAULT 'active'
|
|
567
|
-
CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
|
|
568
|
-
resolved_at_ms INTEGER,
|
|
569
|
-
replacement_client_commit_id TEXT)`);
|
|
570
565
|
// Outcomes created before durable aggregate recovery retain no commit
|
|
571
566
|
// envelope. New failed outcomes store it in the protected client DB.
|
|
572
567
|
try {
|
|
@@ -576,32 +571,26 @@ export function ensureLocalBookkeepingSchema(db: ClientDatabase): void {
|
|
|
576
571
|
} catch {
|
|
577
572
|
// column already exists — the CREATE above included it
|
|
578
573
|
}
|
|
579
|
-
db.exec(
|
|
580
|
-
ON _syncular_commit_outcomes(resolution,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
requested_scopes TEXT NOT NULL,
|
|
585
|
-
|
|
586
|
-
cursor INTEGER NOT NULL DEFAULT -1,
|
|
587
|
-
bootstrap_state TEXT,
|
|
588
|
-
effective_scopes TEXT,
|
|
589
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
590
|
-
reason_code TEXT)`);
|
|
574
|
+
db.exec(
|
|
575
|
+
'CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq ON _syncular_commit_outcomes(resolution,seq)',
|
|
576
|
+
);
|
|
577
|
+
createTable(
|
|
578
|
+
'_syncular_subscriptions',
|
|
579
|
+
"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",
|
|
580
|
+
);
|
|
591
581
|
// §4.8 window registry: which units (scope values) of a window base are
|
|
592
582
|
// live locally — the completeness oracle (I3) and the shrink driver
|
|
593
583
|
// (a unit's omission from the next pull unregisters it, §4.1).
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
unit TEXT NOT NULL,
|
|
597
|
-
|
|
598
|
-
PRIMARY KEY (base, unit))`);
|
|
584
|
+
createTable(
|
|
585
|
+
'_syncular_windows',
|
|
586
|
+
'base TEXT NOT NULL,unit TEXT NOT NULL,sub_id TEXT NOT NULL,PRIMARY KEY(base,unit)',
|
|
587
|
+
);
|
|
599
588
|
// §4.8 E1: units that left the window but still had outbox-pinned rows.
|
|
600
589
|
// Retried when the outbox drains; cancelled if the unit re-enters.
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
tbl TEXT NOT NULL,
|
|
604
|
-
|
|
590
|
+
createTable(
|
|
591
|
+
'_syncular_window_pending_evict',
|
|
592
|
+
'sub_id TEXT PRIMARY KEY,tbl TEXT NOT NULL,effective_scopes TEXT NOT NULL',
|
|
593
|
+
);
|
|
605
594
|
});
|
|
606
595
|
}
|
|
607
596
|
|
|
@@ -634,9 +623,7 @@ export function dropAndRecreateSyncedTables(
|
|
|
634
623
|
// Drop virtual tables first. SQLite then removes their shadow tables as one
|
|
635
624
|
// unit, so the generic discovery below never tears an FTS5 projection apart.
|
|
636
625
|
const virtualTables = db.query(
|
|
637
|
-
`SELECT name FROM sqlite_master WHERE type
|
|
638
|
-
AND sql LIKE 'CREATE VIRTUAL TABLE%'
|
|
639
|
-
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
626
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE 'CREATE VIRTUAL TABLE%' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'`,
|
|
640
627
|
);
|
|
641
628
|
for (const row of virtualTables) {
|
|
642
629
|
db.exec(
|
|
@@ -645,9 +632,7 @@ export function dropAndRecreateSyncedTables(
|
|
|
645
632
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
646
633
|
}
|
|
647
634
|
const existing = db.query(
|
|
648
|
-
`SELECT name FROM sqlite_master WHERE type
|
|
649
|
-
AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%'
|
|
650
|
-
AND name NOT LIKE 'sqlite_%'`,
|
|
635
|
+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '${RESERVED_TABLE_PREFIX}%' AND name NOT LIKE 'sqlite_%'`,
|
|
651
636
|
);
|
|
652
637
|
for (const row of existing) {
|
|
653
638
|
db.exec(`DROP TABLE IF EXISTS ${quoteIdent(String(row.name))}`);
|
|
@@ -810,3 +795,13 @@ export function jsonToRowValue(value: JsonRowValue): RowValue {
|
|
|
810
795
|
}
|
|
811
796
|
return value;
|
|
812
797
|
}
|
|
798
|
+
|
|
799
|
+
/** Convert each column value without changing the row's presence set. */
|
|
800
|
+
export function mapRowValues<T, U>(
|
|
801
|
+
values: Readonly<Record<string, T>>,
|
|
802
|
+
convert: (value: T) => U,
|
|
803
|
+
): Record<string, U> {
|
|
804
|
+
return Object.fromEntries(
|
|
805
|
+
Object.entries(values).map(([key, value]) => [key, convert(value)]),
|
|
806
|
+
);
|
|
807
|
+
}
|
package/src/state.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';
|
|
8
9
|
import type { ClientDatabase } from './database';
|
|
9
10
|
import type { LocalRevision } from './invalidation';
|
|
10
11
|
|
|
@@ -65,7 +66,7 @@ export function getSubscription(
|
|
|
65
66
|
db: ClientDatabase,
|
|
66
67
|
id: string,
|
|
67
68
|
): SubscriptionRecord | undefined {
|
|
68
|
-
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id
|
|
69
|
+
const row = db.query('SELECT * FROM _syncular_subscriptions WHERE id=?', [
|
|
69
70
|
id,
|
|
70
71
|
])[0];
|
|
71
72
|
return row === undefined ? undefined : rowToRecord(row);
|
|
@@ -76,10 +77,7 @@ export function saveSubscription(
|
|
|
76
77
|
record: SubscriptionRecord,
|
|
77
78
|
): void {
|
|
78
79
|
db.exec(
|
|
79
|
-
|
|
80
|
-
id, tbl, requested_scopes, params, cursor, bootstrap_state,
|
|
81
|
-
effective_scopes, status, reason_code)
|
|
82
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
80
|
+
'INSERT OR REPLACE INTO _syncular_subscriptions(id,tbl,requested_scopes,params,cursor,bootstrap_state,effective_scopes,status,reason_code)VALUES(?,?,?,?,?,?,?,?,?)',
|
|
83
81
|
[
|
|
84
82
|
record.id,
|
|
85
83
|
record.table,
|
|
@@ -97,11 +95,11 @@ export function saveSubscription(
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
100
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id
|
|
98
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
101
99
|
}
|
|
102
100
|
|
|
103
101
|
/**
|
|
104
|
-
* §7.4.3 reset: keep
|
|
102
|
+
* §7.4.3 reset: keep compatible subscription REGISTRATIONS (id, table,
|
|
105
103
|
* requested scopes, params — the app's declared intent) but discard all
|
|
106
104
|
* synced state (cursor → -1, no resume token, no effective-scope map,
|
|
107
105
|
* status → active), so the next round fresh-bootstraps exactly the
|
|
@@ -109,56 +107,115 @@ export function deleteSubscription(db: ClientDatabase, id: string): void {
|
|
|
109
107
|
*/
|
|
110
108
|
export function resetSubscriptionsForBump(db: ClientDatabase): void {
|
|
111
109
|
db.exec(
|
|
112
|
-
|
|
113
|
-
SET cursor = -1, bootstrap_state = NULL, effective_scopes = NULL,
|
|
114
|
-
status = 'active', reason_code = NULL`,
|
|
110
|
+
"UPDATE _syncular_subscriptions SET cursor=-1,bootstrap_state=NULL,effective_scopes=NULL,status='active',reason_code=NULL",
|
|
115
111
|
);
|
|
116
112
|
}
|
|
117
113
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
114
|
+
type ScopeDeclarations = Record<
|
|
115
|
+
string,
|
|
116
|
+
Record<string, readonly [string, string]>
|
|
117
|
+
>;
|
|
118
|
+
|
|
119
|
+
/** Persist declarations separately from the previous-version row descriptor. */
|
|
120
|
+
export function saveSubscriptionScopeSchema(
|
|
121
|
+
db: ClientDatabase,
|
|
122
|
+
schema: CompiledClientSchema,
|
|
123
|
+
): void {
|
|
124
|
+
const declarations: ScopeDeclarations = {};
|
|
125
|
+
for (const table of schema.tables.values()) {
|
|
126
|
+
declarations[table.name] = Object.fromEntries(
|
|
127
|
+
[...table.scopeColumnByVariable].map(([variable, column]) => [
|
|
128
|
+
variable,
|
|
129
|
+
[table.scopePrefixByVariable.get(variable)!, column],
|
|
130
|
+
]),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
setMeta(db, 'subscriptionScopeSchema', JSON.stringify(declarations));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drop incompatible registrations and their windows; never translate scopes. */
|
|
125
137
|
export function pruneUnknownSubscriptions(
|
|
126
138
|
db: ClientDatabase,
|
|
127
139
|
subscriptions: readonly SubscriptionRecord[],
|
|
128
|
-
|
|
140
|
+
schema: CompiledClientSchema,
|
|
141
|
+
bump = false,
|
|
129
142
|
): SubscriptionRecord[] {
|
|
130
|
-
const
|
|
131
|
-
|
|
143
|
+
const stored = bump ? getMeta(db, 'subscriptionScopeSchema') : undefined;
|
|
144
|
+
const previous =
|
|
145
|
+
stored === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: (JSON.parse(stored) as ScopeDeclarations);
|
|
148
|
+
const compatible = (
|
|
149
|
+
record: Pick<SubscriptionRecord, 'table' | 'scopes'>,
|
|
150
|
+
): boolean => {
|
|
151
|
+
const table = schema.tables.get(record.table);
|
|
152
|
+
if (table === undefined) return false;
|
|
153
|
+
const variables = Object.keys(record.scopes);
|
|
154
|
+
if (
|
|
155
|
+
!variables.every((variable) => table.scopeColumnByVariable.has(variable))
|
|
156
|
+
)
|
|
157
|
+
return false;
|
|
158
|
+
if (!bump) return true;
|
|
159
|
+
// An old binary stored no declaration evidence. Re-registration is required
|
|
160
|
+
// at a bump: matching names cannot prove unchanged column/prefix meaning.
|
|
161
|
+
const old = previous?.[record.table];
|
|
162
|
+
if (old === undefined) return false;
|
|
163
|
+
const compared =
|
|
164
|
+
variables.length === 0
|
|
165
|
+
? [
|
|
166
|
+
...new Set([
|
|
167
|
+
...Object.keys(old),
|
|
168
|
+
...table.scopeColumnByVariable.keys(),
|
|
169
|
+
]),
|
|
170
|
+
]
|
|
171
|
+
: variables;
|
|
172
|
+
return compared.every(
|
|
173
|
+
(variable) =>
|
|
174
|
+
old[variable]?.[0] === table.scopePrefixByVariable.get(variable) &&
|
|
175
|
+
old[variable]?.[1] === table.scopeColumnByVariable.get(variable),
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
const retained = subscriptions.filter(compatible);
|
|
179
|
+
const staleIds = new Set(
|
|
180
|
+
subscriptions
|
|
181
|
+
.filter((record) => !compatible(record))
|
|
182
|
+
.map((record) => record.id),
|
|
132
183
|
);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
184
|
+
// Shrinking a pinned window already removed its registration. Its deferred
|
|
185
|
+
// eviction still carries the old scope declaration and needs the same fence.
|
|
186
|
+
for (const row of db.query(
|
|
187
|
+
'SELECT sub_id,tbl,effective_scopes FROM _syncular_window_pending_evict',
|
|
188
|
+
)) {
|
|
189
|
+
if (
|
|
190
|
+
!compatible({
|
|
191
|
+
table: String(row.tbl),
|
|
192
|
+
scopes: JSON.parse(String(row.effective_scopes)) as ScopeMap,
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
staleIds.add(String(row.sub_id));
|
|
196
|
+
}
|
|
197
|
+
if (staleIds.size === 0) return retained;
|
|
141
198
|
db.transaction(() => {
|
|
142
199
|
for (const id of staleIds) {
|
|
143
|
-
db.exec('DELETE FROM _syncular_windows WHERE sub_id
|
|
144
|
-
db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id
|
|
200
|
+
db.exec('DELETE FROM _syncular_windows WHERE sub_id=?', [id]);
|
|
201
|
+
db.exec('DELETE FROM _syncular_window_pending_evict WHERE sub_id=?', [
|
|
145
202
|
id,
|
|
146
203
|
]);
|
|
147
|
-
db.exec('DELETE FROM _syncular_subscriptions WHERE id
|
|
204
|
+
db.exec('DELETE FROM _syncular_subscriptions WHERE id=?', [id]);
|
|
148
205
|
}
|
|
149
206
|
});
|
|
150
207
|
return retained;
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
export function getMeta(db: ClientDatabase, key: string): string | undefined {
|
|
154
|
-
const row = db.query('SELECT value FROM _syncular_meta WHERE key
|
|
211
|
+
const row = db.query('SELECT value FROM _syncular_meta WHERE key=?', [
|
|
155
212
|
key,
|
|
156
213
|
])[0];
|
|
157
214
|
return row === undefined ? undefined : (row.value as string);
|
|
158
215
|
}
|
|
159
216
|
|
|
160
217
|
export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
161
|
-
db.exec('INSERT OR REPLACE INTO _syncular_meta(key,
|
|
218
|
+
db.exec('INSERT OR REPLACE INTO _syncular_meta(key,value)VALUES(?,?)', [
|
|
162
219
|
key,
|
|
163
220
|
value,
|
|
164
221
|
]);
|