@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/README.md
CHANGED
|
@@ -85,7 +85,14 @@ installRealtimeSupervisor(handle, {
|
|
|
85
85
|
It owns one connection attempt, retries initial failure and later socket loss
|
|
86
86
|
with bounded exponential jitter, suspends while offline/background/protected,
|
|
87
87
|
runs `syncUntilIdle()` before reporting `connected`, and cancels before
|
|
88
|
-
`close()`.
|
|
88
|
+
`close()`.
|
|
89
|
+
|
|
90
|
+
Multi-tab handles share ONE leader socket across every tab, so pass
|
|
91
|
+
`sharedTransport: true` (the templates do): backgrounding this tab then
|
|
92
|
+
leaves the shared socket alone — a sibling tab may still be visible — while
|
|
93
|
+
offline and protection continue to suspend. Each client accepts exactly one
|
|
94
|
+
supervisor for its lifetime; a second `installRealtimeSupervisor` call
|
|
95
|
+
throws. `realtimeSupervisorSnapshot()` and
|
|
89
96
|
`subscribeRealtimeSupervisor()` expose only `phase`, `attempt`, and the bounded
|
|
90
97
|
library delay for UI/diagnostics—never raw transport prose or identities.
|
|
91
98
|
|
|
@@ -350,9 +357,9 @@ visible. Reusing the same id returns `alreadyApplied: true` together with the
|
|
|
350
357
|
original `retainedCommits` and `resetSubscriptions` counts. The core stores
|
|
351
358
|
that counts-only receipt atomically with the reset, so an application crash
|
|
352
359
|
after the SQLite commit but before its own acknowledgement does not turn the
|
|
353
|
-
retry into a misleading zero-impact result. Markers
|
|
354
|
-
|
|
355
|
-
zero-count
|
|
360
|
+
retry into a misleading zero-impact result. Markers persisted in the legacy
|
|
361
|
+
format carry no counts receipt; a retry against one of them keeps returning
|
|
362
|
+
the zero-count result. A malformed or unreadable persisted receipt fails
|
|
356
363
|
closed with the sanitized `sync.local_corrupt` client-local code and performs
|
|
357
364
|
no reset. No receipt exposes ids, rows, scopes, or clinical values.
|
|
358
365
|
|
package/dist/client.js
CHANGED
|
@@ -110,6 +110,16 @@ export class SyncClient {
|
|
|
110
110
|
* (the fast-bail the delta path reads).
|
|
111
111
|
*/
|
|
112
112
|
#syncOutstanding = false;
|
|
113
|
+
/**
|
|
114
|
+
* Bumped by every local projection reset (`rebootstrapLocalData`). A sync
|
|
115
|
+
* round captures this epoch together with its subscription state; a
|
|
116
|
+
* mismatch at SUB_END means a reset landed while the round was in flight,
|
|
117
|
+
* so the captured cursors predate the rewind and persisting them would
|
|
118
|
+
* overwrite it and silently skip the fresh bootstrap. The Rust core is
|
|
119
|
+
* synchronous over `&mut self` and gets this fencing for free; the epoch
|
|
120
|
+
* restores the equivalent semantics across the round's await points.
|
|
121
|
+
*/
|
|
122
|
+
#localResetEpoch = 0;
|
|
113
123
|
/** Retry policy belongs to the operation that classified the failure. */
|
|
114
124
|
#retryDelayMs = 250;
|
|
115
125
|
#hasBlobs;
|
|
@@ -1409,38 +1419,49 @@ export class SyncClient {
|
|
|
1409
1419
|
const rejectionCount = this.#rejections.length;
|
|
1410
1420
|
try {
|
|
1411
1421
|
return this.#applyBatch((batch) => {
|
|
1412
|
-
const initialRowIds = this.#localPurgeRowIds(purge);
|
|
1413
1422
|
const targetsByTable = this.#localPurgeTargetsByTable(purge);
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1423
|
+
// Doomed detection runs to fixpoint, matching the Rust core's rule:
|
|
1424
|
+
// a commit is doomed when any of its ops touches a base-matching
|
|
1425
|
+
// row. Base values only become visible here as rollbacks restore
|
|
1426
|
+
// before-images and rebase later commits' images onto them (a
|
|
1427
|
+
// stacked edit over a moved row initially shows the moved values),
|
|
1428
|
+
// so each pass rolls back what it caught — newest-first, so an
|
|
1429
|
+
// older doomed write never reappears — and re-scans until the
|
|
1430
|
+
// outbox is stable.
|
|
1431
|
+
const doomed = [];
|
|
1432
|
+
let rowIds = this.#localPurgeRowIds(purge);
|
|
1433
|
+
for (;;) {
|
|
1434
|
+
const caught = listOutbox(this.#db)
|
|
1435
|
+
.filter((commit) => {
|
|
1436
|
+
const images = new Map(listOutboxBeforeImages(this.#db, commit.clientCommitId).map((image) => [image.opIndex, image]));
|
|
1437
|
+
return commit.operations.some((operation, opIndex) => {
|
|
1438
|
+
const targets = targetsByTable.get(operation.table);
|
|
1439
|
+
if (targets === undefined)
|
|
1440
|
+
return false;
|
|
1441
|
+
if (rowIds.get(operation.table)?.has(operation.rowId) === true) {
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
if (operation.values !== undefined &&
|
|
1445
|
+
targets.some((target) => localDataPurgeTargetMatches(target, operation.values ?? {}))) {
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
const beforeValues = images.get(opIndex)?.values;
|
|
1449
|
+
return (beforeValues !== undefined &&
|
|
1450
|
+
targets.some((target) => localDataPurgeTargetMatches(target, beforeValues)));
|
|
1451
|
+
});
|
|
1452
|
+
})
|
|
1453
|
+
.sort((a, b) => b.seq - a.seq);
|
|
1454
|
+
if (caught.length === 0)
|
|
1455
|
+
break;
|
|
1456
|
+
for (const commit of caught) {
|
|
1457
|
+
this.#rollbackFailedCommit(commit, batch);
|
|
1458
|
+
}
|
|
1459
|
+
doomed.push(...caught);
|
|
1460
|
+
// Rollback may reveal a target row hidden by an optimistic delete
|
|
1461
|
+
// or move; the re-selected set drives both the next scan and the
|
|
1462
|
+
// final row deletion below.
|
|
1463
|
+
rowIds = this.#localPurgeRowIds(purge);
|
|
1439
1464
|
}
|
|
1440
|
-
// Rollback may reveal a target row hidden by an optimistic delete or
|
|
1441
|
-
// move, so select the final base/visible set only after doomed commits
|
|
1442
|
-
// have been removed.
|
|
1443
|
-
const rowIds = this.#localPurgeRowIds(purge);
|
|
1444
1465
|
let purgedRows = 0;
|
|
1445
1466
|
for (const [tableName, ids] of rowIds) {
|
|
1446
1467
|
if (ids.size === 0)
|
|
@@ -1551,6 +1572,9 @@ export class SyncClient {
|
|
|
1551
1572
|
this.#needsPull = priorNeedsPull;
|
|
1552
1573
|
throw error;
|
|
1553
1574
|
}
|
|
1575
|
+
// Fence any in-flight sync round: its captured subscription state now
|
|
1576
|
+
// predates the rewind, so its SUB_END cursors must stay unpersisted.
|
|
1577
|
+
this.#localResetEpoch += 1;
|
|
1554
1578
|
if (!priorUpgrading)
|
|
1555
1579
|
this.#config.onUpgrading?.(true);
|
|
1556
1580
|
this.#config.onSyncNeeded?.('startup');
|
|
@@ -1785,6 +1809,9 @@ export class SyncClient {
|
|
|
1785
1809
|
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
1786
1810
|
// mapping.
|
|
1787
1811
|
const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
|
|
1812
|
+
// Captured together with the subscription state below: the response
|
|
1813
|
+
// apply persists SUB_END cursors only while this epoch is current.
|
|
1814
|
+
const resetEpoch = this.#localResetEpoch;
|
|
1788
1815
|
const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
|
|
1789
1816
|
const limits = this.#config.limits;
|
|
1790
1817
|
const frames = [
|
|
@@ -1823,7 +1850,7 @@ export class SyncClient {
|
|
|
1823
1850
|
if (message.msgKind !== 'response') {
|
|
1824
1851
|
throw new ClientSyncError('sync.invalid_request', 'transport returned a non-response message');
|
|
1825
1852
|
}
|
|
1826
|
-
const summary = await this.#processResponse(message, outbox, subs, 'pull');
|
|
1853
|
+
const summary = await this.#processResponse(message, outbox, subs, 'pull', resetEpoch);
|
|
1827
1854
|
// §4.8 E1: the push half may have drained commits that pinned rows of
|
|
1828
1855
|
// a shrunk window unit — retry any deferred evictions now.
|
|
1829
1856
|
this.#drainPendingEvictions();
|
|
@@ -2148,7 +2175,7 @@ export class SyncClient {
|
|
|
2148
2175
|
this.#sendAck(Math.min(...cursors));
|
|
2149
2176
|
}
|
|
2150
2177
|
// -- response processing ------------------------------------------------------
|
|
2151
|
-
async #processResponse(message, sentCommits, sentSubs, mode) {
|
|
2178
|
+
async #processResponse(message, sentCommits, sentSubs, mode, resetEpoch = this.#localResetEpoch) {
|
|
2152
2179
|
const summary = emptySummary(sentCommits.length);
|
|
2153
2180
|
const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
|
|
2154
2181
|
const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
|
|
@@ -2320,7 +2347,12 @@ export class SyncClient {
|
|
|
2320
2347
|
case 'SUB_END': {
|
|
2321
2348
|
if (section !== undefined &&
|
|
2322
2349
|
!section.skip &&
|
|
2323
|
-
section.sub !== undefined
|
|
2350
|
+
section.sub !== undefined &&
|
|
2351
|
+
// Reset fence: a rebootstrap that landed mid-round already
|
|
2352
|
+
// rewound this subscription's cursor; the captured SUB_END
|
|
2353
|
+
// state predates the rewind and must stay unpersisted so the
|
|
2354
|
+
// next round runs the fresh bootstrap.
|
|
2355
|
+
this.#localResetEpoch === resetEpoch) {
|
|
2324
2356
|
const applied = this.#finishSection(section.sub, section.start, frame.nextCursor, frame.bootstrapState, summary);
|
|
2325
2357
|
if (mode === 'delta' && applied) {
|
|
2326
2358
|
deltaCursor = Math.max(deltaCursor, frame.nextCursor);
|
|
@@ -2367,7 +2399,12 @@ export class SyncClient {
|
|
|
2367
2399
|
.map((sub) => sub.id);
|
|
2368
2400
|
// §7.4.5: the reset is over once the first post-reset pull round leaves
|
|
2369
2401
|
// no subscription mid-bootstrap — the tables are rebuilt and current.
|
|
2370
|
-
|
|
2402
|
+
// The epoch check keeps a round that predates a mid-flight rebootstrap
|
|
2403
|
+
// from declaring that reset finished before its bootstrap ran.
|
|
2404
|
+
if (this.#upgrading &&
|
|
2405
|
+
mode === 'pull' &&
|
|
2406
|
+
bootstrapping.length === 0 &&
|
|
2407
|
+
this.#localResetEpoch === resetEpoch) {
|
|
2371
2408
|
this.#setUpgrading(false);
|
|
2372
2409
|
}
|
|
2373
2410
|
return { ...summary, bootstrapping };
|
|
@@ -2376,6 +2413,13 @@ export class SyncClient {
|
|
|
2376
2413
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2377
2414
|
if (commit === undefined)
|
|
2378
2415
|
return false;
|
|
2416
|
+
// `commitsById` is the round's send-time snapshot. The commit can leave
|
|
2417
|
+
// the outbox before this frame is handled — a local purge doomed it while
|
|
2418
|
+
// the round was in flight, or an earlier duplicate frame in this response
|
|
2419
|
+
// already drained it. Its recorded outcome stands, and skipping keeps the
|
|
2420
|
+
// cached outbox count exact (a drain may only be counted once).
|
|
2421
|
+
if (!this.#outboxCommitExists(frame.clientCommitId))
|
|
2422
|
+
return false;
|
|
2379
2423
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
2380
2424
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2381
2425
|
// "already applied, you may have missed the ack".
|
|
@@ -2470,6 +2514,9 @@ export class SyncClient {
|
|
|
2470
2514
|
summary.rejected.push(frame.clientCommitId);
|
|
2471
2515
|
return true;
|
|
2472
2516
|
}
|
|
2517
|
+
#outboxCommitExists(clientCommitId) {
|
|
2518
|
+
return (this.#db.query('SELECT 1 FROM _syncular_outbox WHERE client_commit_id = ? LIMIT 1', [clientCommitId]).length > 0);
|
|
2519
|
+
}
|
|
2473
2520
|
#decodeServerRow(tableName, payload) {
|
|
2474
2521
|
if (tableName === undefined)
|
|
2475
2522
|
return {};
|
package/dist/encryption.d.ts
CHANGED
|
@@ -43,7 +43,12 @@ export interface EncryptionKeyringConfig {
|
|
|
43
43
|
readonly keys: Readonly<Record<string, Uint8Array>>;
|
|
44
44
|
readonly keyIdColumns?: Readonly<Record<string, string>>;
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Convert a portable keyring into the direct-client encryption contract.
|
|
48
|
+
* Key lengths are validated here, at install time: every side of the wire
|
|
49
|
+
* enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
|
|
50
|
+
* with a clear config error before any sync round runs.
|
|
51
|
+
*/
|
|
47
52
|
export declare function encryptionConfigFromKeyring(keyring: EncryptionKeyringConfig): EncryptionConfig;
|
|
48
53
|
/**
|
|
49
54
|
* Encrypt the encrypted columns of a positional row value array in place-safe
|
package/dist/encryption.js
CHANGED
|
@@ -8,11 +8,25 @@
|
|
|
8
8
|
* bridge between the positional `RowValue[]` and the `@syncular/core` §5.11
|
|
9
9
|
* envelope primitives.
|
|
10
10
|
*/
|
|
11
|
-
import { DecryptError, decryptValue, encryptValue, } from '@syncular/core';
|
|
12
|
-
|
|
11
|
+
import { DecryptError, decryptValue, encryptValue, KEY_LENGTH, } from '@syncular/core';
|
|
12
|
+
import { ClientSyncError } from './errors.js';
|
|
13
|
+
/**
|
|
14
|
+
* Convert a portable keyring into the direct-client encryption contract.
|
|
15
|
+
* Key lengths are validated here, at install time: every side of the wire
|
|
16
|
+
* enforces {@link KEY_LENGTH}-byte keys, so a wrong-length key fails loud
|
|
17
|
+
* with a clear config error before any sync round runs.
|
|
18
|
+
*/
|
|
13
19
|
export function encryptionConfigFromKeyring(keyring) {
|
|
20
|
+
for (const [keyId, key] of Object.entries(keyring.keys)) {
|
|
21
|
+
if (key.length !== KEY_LENGTH) {
|
|
22
|
+
throw new ClientSyncError('sync.invalid_request', `encryption key ${JSON.stringify(keyId)} must be ${KEY_LENGTH} bytes (AES-256), got ${key.length}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
return {
|
|
15
|
-
|
|
26
|
+
// Own-property lookup only: the keyId arrives from the wire envelope, so
|
|
27
|
+
// a prototype-chain name like "constructor" must read as a missing key
|
|
28
|
+
// and take the clean DecryptError path.
|
|
29
|
+
keyProvider: (keyId) => Object.hasOwn(keyring.keys, keyId) ? keyring.keys[keyId] : undefined,
|
|
16
30
|
...(keyring.keyIdColumns !== undefined
|
|
17
31
|
? { keyIdColumns: keyring.keyIdColumns }
|
|
18
32
|
: {}),
|
package/dist/reactive-store.js
CHANGED
|
@@ -691,8 +691,15 @@ export class ReactiveClientStore {
|
|
|
691
691
|
// Releasing a window is best-effort teardown. A resource owner may have
|
|
692
692
|
// already closed the underlying worker/native handle before React effect
|
|
693
693
|
// cleanup runs (notably during schema-changing HMR). Do not let that
|
|
694
|
-
// harmless ordering race escape as an unhandled rejection
|
|
695
|
-
void
|
|
694
|
+
// harmless ordering race escape as an unhandled rejection — and the
|
|
695
|
+
// interface permits a plain void return, so a closed handle may also
|
|
696
|
+
// throw synchronously; swallow that the same way.
|
|
697
|
+
try {
|
|
698
|
+
void Promise.resolve(this.client.setWindow(group.base, [])).catch(() => undefined);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Same teardown race, surfaced synchronously.
|
|
702
|
+
}
|
|
696
703
|
}
|
|
697
704
|
this.#windowClaims.clear();
|
|
698
705
|
}
|
|
@@ -28,8 +28,18 @@ export interface RealtimeSupervisorSnapshot {
|
|
|
28
28
|
export interface RealtimeSupervisorOptions {
|
|
29
29
|
/** Host online/offline evidence. Unknown remains connectable and observable. */
|
|
30
30
|
readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
|
|
31
|
-
/** Browser/native foreground evidence. Background
|
|
31
|
+
/** Browser/native foreground evidence. Background suspends a tab-owned
|
|
32
|
+
* socket; with `sharedTransport` it only serves as a resume nudge. */
|
|
32
33
|
readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
|
|
34
|
+
/**
|
|
35
|
+
* The client's realtime transport is shared with sibling tabs or webviews
|
|
36
|
+
* (multi-tab handles proxying to one leader socket, native cores behind
|
|
37
|
+
* several views). Backgrounding THIS view then keeps the socket open,
|
|
38
|
+
* because a sibling may still be visible and a follower's disconnect would
|
|
39
|
+
* tear down realtime for everyone. Offline and protection evidence still
|
|
40
|
+
* suspend.
|
|
41
|
+
*/
|
|
42
|
+
readonly sharedTransport?: boolean;
|
|
33
43
|
/** Publish preflight before draining keys so reconnect stops in the same turn. */
|
|
34
44
|
readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
|
|
35
45
|
/** Deterministic test/host timer seam. */
|
|
@@ -72,7 +82,12 @@ export declare class RealtimeSupervisor {
|
|
|
72
82
|
start(): void;
|
|
73
83
|
stop(): void;
|
|
74
84
|
}
|
|
75
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Install one supervisor and make client disposal cancel it before close.
|
|
87
|
+
* A client accepts exactly one supervisor for its lifetime; installing a
|
|
88
|
+
* second throws, because silently keeping the first would discard the second
|
|
89
|
+
* call's options.
|
|
90
|
+
*/
|
|
76
91
|
export declare function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(client: T, options?: RealtimeSupervisorOptions): T;
|
|
77
92
|
export declare function realtimeSupervisorSnapshot(client: object): RealtimeSupervisorSnapshot;
|
|
78
93
|
export declare function subscribeRealtimeSupervisor(client: object, listener: () => void): () => void;
|
|
@@ -117,6 +117,7 @@ export class RealtimeSupervisor {
|
|
|
117
117
|
#connectivity;
|
|
118
118
|
#lifecycle;
|
|
119
119
|
#protection;
|
|
120
|
+
#sharedTransport;
|
|
120
121
|
#scheduleTimer;
|
|
121
122
|
#random;
|
|
122
123
|
#initialDelayMs;
|
|
@@ -144,6 +145,7 @@ export class RealtimeSupervisor {
|
|
|
144
145
|
this.#connectivity = options.connectivity;
|
|
145
146
|
this.#lifecycle = options.lifecycle;
|
|
146
147
|
this.#protection = options.protection;
|
|
148
|
+
this.#sharedTransport = options.sharedTransport === true;
|
|
147
149
|
this.#scheduleTimer = options.schedule ?? scheduleTimer;
|
|
148
150
|
this.#random = options.random ?? Math.random;
|
|
149
151
|
this.#initialDelayMs = boundedDelay(options.initialDelayMs, DEFAULT_INITIAL_DELAY_MS);
|
|
@@ -184,6 +186,7 @@ export class RealtimeSupervisor {
|
|
|
184
186
|
this.#unsubscribeLifecycle?.();
|
|
185
187
|
this.#unsubscribeProtection?.();
|
|
186
188
|
this.#publish({ phase: 'stopped', attempt: 0 });
|
|
189
|
+
this.#listeners.clear();
|
|
187
190
|
if (disconnect)
|
|
188
191
|
this.#disconnect();
|
|
189
192
|
}
|
|
@@ -214,8 +217,9 @@ export class RealtimeSupervisor {
|
|
|
214
217
|
this.#connectivity?.current() === 'offline') {
|
|
215
218
|
return 'offline';
|
|
216
219
|
}
|
|
217
|
-
if (this.#lifecycle?.current() === 'background')
|
|
220
|
+
if (!this.#sharedTransport && this.#lifecycle?.current() === 'background') {
|
|
218
221
|
return 'background';
|
|
222
|
+
}
|
|
219
223
|
return undefined;
|
|
220
224
|
}
|
|
221
225
|
#canConnect() {
|
|
@@ -273,43 +277,77 @@ export class RealtimeSupervisor {
|
|
|
273
277
|
this.#reconcileHostState();
|
|
274
278
|
return;
|
|
275
279
|
}
|
|
280
|
+
// Count an attempt only when this call actually schedules one. Repeated
|
|
281
|
+
// 'disconnected' diagnostics beside a pending retry keep the backoff and
|
|
282
|
+
// the worker-mode reconnect nudge intact.
|
|
283
|
+
if (this.#connected ||
|
|
284
|
+
this.#connecting ||
|
|
285
|
+
this.#cancelRetry !== undefined) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
276
288
|
const delay = this.#retryDelay();
|
|
277
289
|
this.#attempt = Math.min(32, this.#attempt + 1);
|
|
278
290
|
this.#schedule(delay);
|
|
279
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* A superseded attempt observed a generation bump mid-flight. Whenever a
|
|
294
|
+
* newer generation is mid-connect or holds the transport, that generation
|
|
295
|
+
* owns the shared socket and the stale attempt leaves every field alone.
|
|
296
|
+
* With the supervisor otherwise quiescent (suspended or stopped), the
|
|
297
|
+
* socket this attempt opened is unwanted — release it.
|
|
298
|
+
*/
|
|
299
|
+
#releaseSupersededSocket() {
|
|
300
|
+
if (this.#connecting || this.#connected || this.#transportConnected) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.#disconnect();
|
|
304
|
+
}
|
|
280
305
|
async #connect() {
|
|
281
306
|
if (!this.#canConnect() || this.#connected || this.#connecting)
|
|
282
307
|
return;
|
|
308
|
+
const generation = this.#generation;
|
|
283
309
|
this.#connecting = true;
|
|
284
310
|
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
285
|
-
const generation = this.#generation;
|
|
286
311
|
let failed = false;
|
|
287
312
|
try {
|
|
288
313
|
await this.#client.connectRealtime();
|
|
314
|
+
if (generation !== this.#generation) {
|
|
315
|
+
this.#releaseSupersededSocket();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
289
318
|
this.#transportConnected = true;
|
|
290
|
-
if (
|
|
319
|
+
if (!this.#canConnect()) {
|
|
320
|
+
this.#transportConnected = false;
|
|
291
321
|
this.#disconnect();
|
|
292
322
|
return;
|
|
293
323
|
}
|
|
294
324
|
await this.#catchUpConnectedTransport(generation);
|
|
295
325
|
}
|
|
296
326
|
catch {
|
|
327
|
+
if (generation !== this.#generation) {
|
|
328
|
+
this.#releaseSupersededSocket();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
297
331
|
failed = true;
|
|
298
332
|
this.#connected = false;
|
|
299
333
|
this.#transportConnected = false;
|
|
300
334
|
this.#disconnect();
|
|
301
335
|
}
|
|
302
336
|
finally {
|
|
303
|
-
|
|
337
|
+
// A superseded attempt leaves the successor's in-flight state alone.
|
|
338
|
+
if (generation === this.#generation)
|
|
339
|
+
this.#connecting = false;
|
|
304
340
|
}
|
|
305
341
|
if (failed && generation === this.#generation)
|
|
306
342
|
this.#scheduleRetry();
|
|
307
343
|
}
|
|
308
344
|
async #catchUpConnectedTransport(generation) {
|
|
309
345
|
await this.#client.syncUntilIdle();
|
|
310
|
-
if (generation !== this.#generation
|
|
311
|
-
|
|
312
|
-
|
|
346
|
+
if (generation !== this.#generation) {
|
|
347
|
+
this.#releaseSupersededSocket();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!this.#canConnect() || !this.#transportConnected) {
|
|
313
351
|
this.#disconnect();
|
|
314
352
|
return;
|
|
315
353
|
}
|
|
@@ -321,22 +359,28 @@ export class RealtimeSupervisor {
|
|
|
321
359
|
if (!this.#canConnect() || this.#connecting || !this.#transportConnected) {
|
|
322
360
|
return;
|
|
323
361
|
}
|
|
362
|
+
const generation = this.#generation;
|
|
324
363
|
this.#connecting = true;
|
|
325
364
|
this.#clearRetry();
|
|
326
365
|
this.#publish({ phase: 'connecting', attempt: this.#attempt });
|
|
327
|
-
const generation = this.#generation;
|
|
328
366
|
let failed = false;
|
|
329
367
|
try {
|
|
330
368
|
await this.#catchUpConnectedTransport(generation);
|
|
331
369
|
}
|
|
332
370
|
catch {
|
|
371
|
+
if (generation !== this.#generation) {
|
|
372
|
+
this.#releaseSupersededSocket();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
333
375
|
failed = true;
|
|
334
376
|
this.#connected = false;
|
|
335
377
|
this.#transportConnected = false;
|
|
336
378
|
this.#disconnect();
|
|
337
379
|
}
|
|
338
380
|
finally {
|
|
339
|
-
|
|
381
|
+
// A superseded adoption leaves the successor's in-flight state alone.
|
|
382
|
+
if (generation === this.#generation)
|
|
383
|
+
this.#connecting = false;
|
|
340
384
|
}
|
|
341
385
|
if (failed && generation === this.#generation)
|
|
342
386
|
this.#scheduleRetry();
|
|
@@ -359,9 +403,14 @@ export class RealtimeSupervisor {
|
|
|
359
403
|
this.#disconnect();
|
|
360
404
|
return;
|
|
361
405
|
}
|
|
362
|
-
if (!this.#realtimeSupported)
|
|
363
|
-
|
|
364
|
-
|
|
406
|
+
if (!this.#realtimeSupported) {
|
|
407
|
+
// A live socket is positive evidence the earlier 'unsupported' report
|
|
408
|
+
// was transient (a mid-restart or mis-detected capability probe), so it
|
|
409
|
+
// recovers the supervisor. Every other report keeps the latch closed.
|
|
410
|
+
if (snapshot.host.realtime !== 'connected')
|
|
411
|
+
return;
|
|
412
|
+
this.#realtimeSupported = true;
|
|
413
|
+
}
|
|
365
414
|
const block = this.#hostBlock();
|
|
366
415
|
if (block) {
|
|
367
416
|
this.#suspend(block);
|
|
@@ -369,9 +418,11 @@ export class RealtimeSupervisor {
|
|
|
369
418
|
}
|
|
370
419
|
if (snapshot.host.realtime === 'connected') {
|
|
371
420
|
this.#transportConnected = true;
|
|
372
|
-
|
|
421
|
+
// Steady state: diagnostics re-announce 'connected' on every sync
|
|
422
|
+
// round and subscription change. An already-adopted transport keeps
|
|
423
|
+
// its phase and skips the redundant catch-up.
|
|
424
|
+
if (this.#connecting || this.#connected)
|
|
373
425
|
return;
|
|
374
|
-
this.#connected = false;
|
|
375
426
|
void this.#adoptConnectedTransport();
|
|
376
427
|
return;
|
|
377
428
|
}
|
|
@@ -428,10 +479,17 @@ export class RealtimeSupervisor {
|
|
|
428
479
|
void Promise.resolve(this.#client.disconnectRealtime()).catch(() => undefined);
|
|
429
480
|
}
|
|
430
481
|
}
|
|
431
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* Install one supervisor and make client disposal cancel it before close.
|
|
484
|
+
* A client accepts exactly one supervisor for its lifetime; installing a
|
|
485
|
+
* second throws, because silently keeping the first would discard the second
|
|
486
|
+
* call's options.
|
|
487
|
+
*/
|
|
432
488
|
export function installRealtimeSupervisor(client, options) {
|
|
433
|
-
if (attachment(client))
|
|
434
|
-
|
|
489
|
+
if (attachment(client)) {
|
|
490
|
+
throw new Error('installRealtimeSupervisor: this client already has a supervisor; ' +
|
|
491
|
+
'install one supervisor per client lifetime');
|
|
492
|
+
}
|
|
435
493
|
const supervisor = new RealtimeSupervisor(client, options);
|
|
436
494
|
Object.defineProperty(client, REALTIME_SUPERVISOR_KEY, {
|
|
437
495
|
configurable: false,
|
package/dist/schema.js
CHANGED
|
@@ -213,6 +213,22 @@ function createSyncedTable(db, table) {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
const FTS_SOURCE_ID_COLUMN = '_syncular_source_id';
|
|
216
|
+
/**
|
|
217
|
+
* Create (or migrate) one contentful FTS5 projection for a table plus its
|
|
218
|
+
* synchronizing triggers. Every open drops and recreates the full trigger set
|
|
219
|
+
* (`_bi`, `_ai`, `_ad`, `_au`, and, when the table has a unique index, `_bu`),
|
|
220
|
+
* so existing databases pick up newly added guards. The BEFORE INSERT (`_bi`)
|
|
221
|
+
* and BEFORE UPDATE (`_bu`) guards remove projection rows for entries displaced
|
|
222
|
+
* by `INSERT OR REPLACE` / `UPDATE OR REPLACE` through the primary key or a
|
|
223
|
+
* secondary unique index — cases where SQLite does not reliably fire the AFTER
|
|
224
|
+
* DELETE trigger for the displaced row.
|
|
225
|
+
*
|
|
226
|
+
* Limitation: because a BEFORE trigger runs before SQLite resolves the
|
|
227
|
+
* conflict, an `OR IGNORE` write whose row is dropped still executes these
|
|
228
|
+
* displacement DELETEs, transiently removing the surviving row's projection
|
|
229
|
+
* entry. Syncular's mirror writes never use `OR IGNORE`; see the guard-block
|
|
230
|
+
* comment below for the full rationale.
|
|
231
|
+
*/
|
|
216
232
|
function createFtsProjection(db, table, index) {
|
|
217
233
|
const existed = db.query("SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?", [index.name]).length > 0;
|
|
218
234
|
const indexedColumns = index.columns.map(quoteIdent);
|
|
@@ -230,18 +246,68 @@ function createFtsProjection(db, table, index) {
|
|
|
230
246
|
...index.columns.map((column) => `new.${quoteIdent(column)}`),
|
|
231
247
|
].join(', ');
|
|
232
248
|
const deleteFor = (value) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} = ${value}`;
|
|
249
|
+
const deleteDisplaced = (select) => `DELETE FROM ${quoteIdent(index.name)} WHERE ${quoteIdent(FTS_SOURCE_ID_COLUMN)} IN (${select})`;
|
|
233
250
|
// A clean insert cannot already have a projection row because the source
|
|
234
251
|
// primary key is unique. Keep clean inserts linear by moving replacement
|
|
235
|
-
// cleanup behind
|
|
236
|
-
// reliably invoke DELETE triggers for
|
|
237
|
-
|
|
252
|
+
// cleanup behind indexed source-table existence checks. SQLite does not
|
|
253
|
+
// reliably invoke DELETE triggers for rows displaced by REPLACE, and a
|
|
254
|
+
// REPLACE can displace rows through TWO paths: the primary key AND any
|
|
255
|
+
// secondary UNIQUE index (a different-PK row whose unique key matches the
|
|
256
|
+
// incoming row). The BEFORE INSERT guard covers both for `INSERT OR REPLACE`;
|
|
257
|
+
// the mirroring BEFORE UPDATE guard covers `UPDATE OR REPLACE` that pushes a
|
|
258
|
+
// different-PK row out through a unique index (the AFTER UPDATE trigger only
|
|
259
|
+
// knows the updated row's own old/new ids, so it would leave that displaced
|
|
260
|
+
// row's projection entry behind as a ghost hit). Primary-key displacement by
|
|
261
|
+
// `UPDATE OR REPLACE` needs no BEFORE guard: the new pk equals the displaced
|
|
262
|
+
// row's pk, so the AFTER UPDATE delete of the new source id already clears it.
|
|
263
|
+
//
|
|
264
|
+
// Known limitation — `OR IGNORE`: a BEFORE trigger fires before SQLite
|
|
265
|
+
// resolves the conflict, and SQLite exposes no signal for the eventual
|
|
266
|
+
// resolution. So an `INSERT OR IGNORE` / `UPDATE OR IGNORE` whose row is
|
|
267
|
+
// dropped on conflict still runs these displacement DELETEs, removing the
|
|
268
|
+
// SURVIVING row's projection entry (a false negative until that row is next
|
|
269
|
+
// rewritten). Moving cleanup to AFTER triggers would dodge the no-op but
|
|
270
|
+
// reintroduce the REPLACE-doesn't-fire-DELETE gap and a full-projection scan
|
|
271
|
+
// per write, so the guards stay BEFORE. Syncular's own mirror writes use
|
|
272
|
+
// `INSERT … ON CONFLICT (pk) DO UPDATE` (apply.ts), which never takes the
|
|
273
|
+
// IGNORE path; only hand-written `OR IGNORE` against a mirror table is exposed.
|
|
274
|
+
for (const suffix of ['bi', 'ai', 'ad', 'au', 'bu']) {
|
|
238
275
|
db.exec(`DROP TRIGGER IF EXISTS ${quoteIdent(`${index.name}_${suffix}`)}`);
|
|
239
276
|
}
|
|
240
277
|
const replacementExists = `EXISTS (SELECT 1 FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)} = new.${quoteIdent(table.primaryKey)})`;
|
|
241
|
-
|
|
278
|
+
// Per secondary UNIQUE index: the different-PK rows about to be displaced
|
|
279
|
+
// via that unique key. `=` makes NULL unique values match nothing, which is
|
|
280
|
+
// exactly SQLite's unique-index semantics (NULLs never conflict).
|
|
281
|
+
const uniqueIndexes = table.indexes.filter((spec) => spec.unique);
|
|
282
|
+
const displacedByUnique = uniqueIndexes.map((spec) => {
|
|
283
|
+
const match = spec.columns
|
|
284
|
+
.map((column) => `${quoteIdent(column)} = new.${quoteIdent(column)}`)
|
|
285
|
+
.join(' AND ');
|
|
286
|
+
return `SELECT ${sourceId} FROM ${quoteIdent(table.name)} WHERE ${match} AND ${quoteIdent(table.primaryKey)} != new.${quoteIdent(table.primaryKey)}`;
|
|
287
|
+
});
|
|
288
|
+
const insertGuardCondition = [
|
|
289
|
+
replacementExists,
|
|
290
|
+
...displacedByUnique.map((select) => `EXISTS (${select})`),
|
|
291
|
+
].join(' OR ');
|
|
292
|
+
const insertGuardBody = [
|
|
293
|
+
deleteFor(newSourceId),
|
|
294
|
+
...displacedByUnique.map(deleteDisplaced),
|
|
295
|
+
].join('; ');
|
|
296
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bi`)} BEFORE INSERT ON ${quoteIdent(table.name)} WHEN ${insertGuardCondition} BEGIN ${insertGuardBody}; END`);
|
|
242
297
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ai`)} AFTER INSERT ON ${quoteIdent(table.name)} BEGIN INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) VALUES (${newValues}); END`);
|
|
243
298
|
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_ad`)} AFTER DELETE ON ${quoteIdent(table.name)} BEGIN ${deleteFor(oldSourceId)}; END`);
|
|
244
299
|
db.exec(`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`);
|
|
300
|
+
// BEFORE UPDATE guard for `UPDATE OR REPLACE`: clear the projection of any
|
|
301
|
+
// different-PK row about to be displaced through a secondary unique index by
|
|
302
|
+
// the new values. Only meaningful when the table has a unique index; with
|
|
303
|
+
// none, no update can displace a foreign row, so the trigger is omitted.
|
|
304
|
+
if (displacedByUnique.length > 0) {
|
|
305
|
+
const updateGuardCondition = displacedByUnique
|
|
306
|
+
.map((select) => `EXISTS (${select})`)
|
|
307
|
+
.join(' OR ');
|
|
308
|
+
const updateGuardBody = displacedByUnique.map(deleteDisplaced).join('; ');
|
|
309
|
+
db.exec(`CREATE TRIGGER ${quoteIdent(`${index.name}_bu`)} BEFORE UPDATE ON ${quoteIdent(table.name)} WHEN ${updateGuardCondition} BEGIN ${updateGuardBody}; END`);
|
|
310
|
+
}
|
|
245
311
|
if (!existed) {
|
|
246
312
|
db.exec(`INSERT INTO ${quoteIdent(index.name)} (${projectionColumns}) SELECT ${sourceId}, ${indexedColumns.join(', ')} FROM ${quoteIdent(table.name)}`);
|
|
247
313
|
}
|
package/dist/worker-entry.js
CHANGED
|
@@ -391,9 +391,21 @@ export function startSyncWorker(overrides = {}) {
|
|
|
391
391
|
database = undefined;
|
|
392
392
|
},
|
|
393
393
|
};
|
|
394
|
+
// Local purge/rebootstrap rewrite the same durable state an in-flight
|
|
395
|
+
// sync round captured at send time; running their RPCs on the sync chain
|
|
396
|
+
// orders them against RPC- and auto-driven rounds (the client core's
|
|
397
|
+
// reset fence covers hosts that call the core directly).
|
|
398
|
+
const syncChainMethods = new Set([
|
|
399
|
+
'purgeLocalData',
|
|
400
|
+
'rebootstrapLocalData',
|
|
401
|
+
]);
|
|
394
402
|
async function dispatch(message) {
|
|
395
403
|
const method = api[message.method];
|
|
396
|
-
|
|
404
|
+
const invoke = () => method.apply(api, message.args);
|
|
405
|
+
if (syncChainMethods.has(message.method)) {
|
|
406
|
+
return await serializedSync(async () => invoke());
|
|
407
|
+
}
|
|
408
|
+
return await invoke();
|
|
397
409
|
}
|
|
398
410
|
function run(id, fn, transfer) {
|
|
399
411
|
void (async () => {
|