@syncular/client 0.15.42 → 0.15.44
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 +11 -4
- package/dist/client.js +81 -34
- package/dist/encryption.d.ts +6 -1
- package/dist/encryption.js +17 -3
- package/dist/reactive-store.js +9 -2
- package/dist/realtime-supervisor.d.ts +17 -2
- package/dist/realtime-supervisor.js +75 -17
- package/dist/schema.js +70 -4
- package/dist/worker-entry.js +13 -1
- package/dist/worker-host.d.ts +8 -0
- package/dist/worker-host.js +25 -0
- package/package.json +3 -3
- package/src/client.ts +99 -45
- package/src/encryption.ts +21 -2
- package/src/reactive-store.ts +10 -4
- package/src/realtime-supervisor.ts +89 -18
- package/src/schema.ts +73 -4
- package/src/worker-entry.ts +15 -1
- package/src/worker-host.ts +26 -0
package/src/schema.ts
CHANGED
|
@@ -342,6 +342,22 @@ function createSyncedTable(
|
|
|
342
342
|
|
|
343
343
|
const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
|
|
344
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Create (or migrate) one contentful FTS5 projection for a table plus its
|
|
347
|
+
* synchronizing triggers. Every open drops and recreates the full trigger set
|
|
348
|
+
* (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
|
|
349
|
+
* so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
|
|
350
|
+
* and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
|
|
351
|
+
* by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
|
|
352
|
+
* secondary unique index — cases where SQLite does not reliably fire the AFTER
|
|
353
|
+
* DELETE trigger for the displaced row.
|
|
354
|
+
*
|
|
355
|
+
* Limitation: because a BEFORE trigger runs before SQLite resolves the
|
|
356
|
+
* conflict, an `OR IGNORE` write whose row is dropped still executes these
|
|
357
|
+
* displacement DELETEs, transiently removing the surviving row's projection
|
|
358
|
+
* entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
|
|
359
|
+
* comment below for the full rationale.
|
|
360
|
+
*/
|
|
345
361
|
function createFtsProjection(
|
|
346
362
|
db: ClientDatabase,
|
|
347
363
|
table: CompiledClientTable,
|
|
@@ -371,18 +387,58 @@ function createFtsProjection(
|
|
|
371
387
|
].join(', ');
|
|
372
388
|
const deleteFor = (value: string) =>
|
|
373
389
|
`DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
|
|
390
|
+
const deleteDisplaced = (select: string) =>
|
|
391
|
+
`DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
|
|
374
392
|
|
|
375
393
|
// A clean insert cannot already have a projection row because the source
|
|
376
394
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
377
|
-
// cleanup behind
|
|
378
|
-
// reliably invoke DELETE triggers for
|
|
379
|
-
|
|
395
|
+
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
396
|
+
// reliably invoke DELETE triggers for rows displaced by REPLACE, and a
|
|
397
|
+
// REPLACE can displace rows through TWO paths: the primary key AND any
|
|
398
|
+
// secondary UNIQUE index (a different-PK row whose unique key matches the
|
|
399
|
+
// incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
|
|
400
|
+
// the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
|
|
401
|
+
// different-PK row out through a unique index (the AFTER UPDATE trigger only
|
|
402
|
+
// knows the updated row's own old/new ids, so it would leave that displaced
|
|
403
|
+
// row's projection entry behind as a ghost hit). Primary-key displacement by
|
|
404
|
+
// `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
|
|
405
|
+
// row's pk, so the AFTER UPDATE delete of the new source id already clears it.
|
|
406
|
+
//
|
|
407
|
+
// Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
|
|
408
|
+
// resolves the conflict, and SQLite exposes no signal for the eventual
|
|
409
|
+
// resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
|
|
410
|
+
// dropped on conflict still runs these displacement DELETEs, removing the
|
|
411
|
+
// SURVIVING row's projection entry (a false negative until that row is next
|
|
412
|
+
// rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
|
|
413
|
+
// reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
|
|
414
|
+
// per write, so the guards stay BEFORE. Syncular's own mirror writes use
|
|
415
|
+
// `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
|
|
416
|
+
// IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
|
|
417
|
+
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
380
418
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
381
419
|
}
|
|
382
420
|
const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
|
|
421
|
+
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
422
|
+
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
423
|
+
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
424
|
+
const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
|
|
425
|
+
const displacedByUnique = uniqueIndexes.map((spec) => {
|
|
426
|
+
const match = spec.columns
|
|
427
|
+
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
428
|
+
.join(' AND ');
|
|
429
|
+
return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
|
|
430
|
+
});
|
|
431
|
+
const insertGuardCondition = [
|
|
432
|
+
replacementExists,
|
|
433
|
+
...displacedByUnique.map((select) => `EXISTS (${select})`),
|
|
434
|
+
].join(' OR ');
|
|
435
|
+
const insertGuardBody = [
|
|
436
|
+
deleteFor(newSourceId),
|
|
437
|
+
...displacedByUnique.map(deleteDisplaced),
|
|
438
|
+
].join('; ');
|
|
383
439
|
|
|
384
440
|
db.exec(
|
|
385
|
-
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${
|
|
441
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`,
|
|
386
442
|
);
|
|
387
443
|
db.exec(
|
|
388
444
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
|
|
@@ -393,6 +449,19 @@ function createFtsProjection(
|
|
|
393
449
|
db.exec(
|
|
394
450
|
`CREATE TRIGGER ${quoteIdent(`${index.name}_au`)} AFTER UPDATE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; ${deleteFor(newSourceId)}; INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`,
|
|
395
451
|
);
|
|
452
|
+
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
453
|
+
// different-PK row about to be displaced through a secondary unique index by
|
|
454
|
+
// the new values. Only meaningful when the table has a unique index; with
|
|
455
|
+
// none, no update can displace a foreign row, so the trigger is omitted.
|
|
456
|
+
if (displacedByUnique.length > 0) {
|
|
457
|
+
const updateGuardCondition = displacedByUnique
|
|
458
|
+
.map((select) => `EXISTS (${select})`)
|
|
459
|
+
.join(' OR ');
|
|
460
|
+
const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
|
|
461
|
+
db.exec(
|
|
462
|
+
`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
396
465
|
|
|
397
466
|
if (!existed) {
|
|
398
467
|
db.exec(
|
package/src/worker-entry.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type WorkerErrorShape,
|
|
43
43
|
type WorkerInitConfig,
|
|
44
44
|
type WorkerInitResult,
|
|
45
|
+
type WorkerMethod,
|
|
45
46
|
type WorkerToMainMessage,
|
|
46
47
|
} from './worker-protocol';
|
|
47
48
|
|
|
@@ -491,9 +492,22 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
491
492
|
},
|
|
492
493
|
};
|
|
493
494
|
|
|
495
|
+
// Local purge/rebootstrap rewrite the same durable state an in-flight
|
|
496
|
+
// sync round captured at send time; running their RPCs on the sync chain
|
|
497
|
+
// orders them against RPC- and auto-driven rounds (the client core's
|
|
498
|
+
// reset fence covers hosts that call the core directly).
|
|
499
|
+
const syncChainMethods: ReadonlySet<WorkerMethod> = new Set<WorkerMethod>([
|
|
500
|
+
'purgeLocalData',
|
|
501
|
+
'rebootstrapLocalData',
|
|
502
|
+
]);
|
|
503
|
+
|
|
494
504
|
async function dispatch(message: WorkerCallMessage): Promise<unknown> {
|
|
495
505
|
const method = api[message.method] as (...args: unknown[]) => unknown;
|
|
496
|
-
|
|
506
|
+
const invoke = () => method.apply(api, message.args as unknown[]);
|
|
507
|
+
if (syncChainMethods.has(message.method)) {
|
|
508
|
+
return await serializedSync(async () => invoke());
|
|
509
|
+
}
|
|
510
|
+
return await invoke();
|
|
497
511
|
}
|
|
498
512
|
|
|
499
513
|
function run(
|
package/src/worker-host.ts
CHANGED
|
@@ -334,6 +334,17 @@ export class SyncClientHandle {
|
|
|
334
334
|
});
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
/**
|
|
338
|
+
* @internal — whether `close()` has run.
|
|
339
|
+
*
|
|
340
|
+
* A follower keeps a blocking `acquire` outstanding for the whole time it is a
|
|
341
|
+
* follower, so its promotion can fire long after the application has discarded
|
|
342
|
+
* the handle. The promotion path consults this before opening anything.
|
|
343
|
+
*/
|
|
344
|
+
get __isClosed(): boolean {
|
|
345
|
+
return this.#closed;
|
|
346
|
+
}
|
|
347
|
+
|
|
337
348
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
338
349
|
__becomeLeader(core: LeaderCore): void {
|
|
339
350
|
this.#follower?.close();
|
|
@@ -1020,6 +1031,14 @@ async function bootFollower(
|
|
|
1020
1031
|
await lease.release();
|
|
1021
1032
|
return;
|
|
1022
1033
|
}
|
|
1034
|
+
if (handle.__isClosed) {
|
|
1035
|
+
// The application discarded this handle while it was queued for
|
|
1036
|
+
// leadership. Promoting now would open a database nobody is holding and
|
|
1037
|
+
// then keep the lock forever, so no other tab could ever take over.
|
|
1038
|
+
// Release instead, and let the next waiter have it.
|
|
1039
|
+
await lease.release();
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1023
1042
|
// The follower saw the departing leader's epoch; the new leader must
|
|
1024
1043
|
// strictly exceed it so stale replies/events are discarded everywhere.
|
|
1025
1044
|
const nextEpoch = follower.maxEpochSeen + 1;
|
|
@@ -1058,6 +1077,13 @@ async function bootFollower(
|
|
|
1058
1077
|
}
|
|
1059
1078
|
: {}),
|
|
1060
1079
|
});
|
|
1080
|
+
if (handle.__isClosed) {
|
|
1081
|
+
// Closed while the worker was starting. `close()` already ran and found
|
|
1082
|
+
// no core to shut down, so this one would leak: tear it down here rather
|
|
1083
|
+
// than hand it to a discarded handle. `close(true)` releases the lease.
|
|
1084
|
+
await core.close(true);
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1061
1087
|
handle.__becomeLeader(core);
|
|
1062
1088
|
} catch {
|
|
1063
1089
|
// Promotion failed to spawn a worker — release so the next tab tries.
|