@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.205
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/dist/index.d.ts +148 -40
- package/dist/index.js +601 -173
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +52 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/devtools/index.ts +3 -1
- package/src/modules/sync/engine.ts +76 -58
- package/src/modules/sync/sync.ts +65 -8
- package/src/services/database/cache-engine.ts +33 -0
- package/src/services/database/sqlite-cache-engine.test.ts +58 -0
- package/src/services/database/sqlite-cache-engine.ts +71 -1
- package/src/services/database/surreal-cache-engine.ts +20 -0
- package/src/services/stream-processor/index.ts +471 -225
- package/src/services/stream-processor/stream-processor.batch.test.ts +3 -13
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +77 -67
- package/src/services/stream-processor/wasm-types.ts +32 -1
- package/src/services/tabs/broker.test.ts +49 -0
- package/src/services/tabs/coordinator.test.ts +6 -1
- package/src/services/tabs/coordinator.ts +35 -3
- package/src/services/tabs/tabs-broker-worker.ts +66 -12
- package/src/sp00ky.ts +54 -8
- package/src/types.ts +21 -13
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
//#region src/services/tabs/tabs-broker-worker.ts
|
|
2
2
|
const PING_INTERVAL_MS = 5e3;
|
|
3
3
|
const PONG_TIMEOUT_MS = 15e3;
|
|
4
|
+
/** How long a tab has to answer `become-leader` with `leader-ready`. A tab
|
|
5
|
+
* that hangs mid-promotion sends neither `leader-ready` nor `leader-failed`,
|
|
6
|
+
* and keeps answering pings, so nothing else here would notice: the
|
|
7
|
+
* namespace would keep a leader that is not `ready`, mint no follower ports,
|
|
8
|
+
* and refuse to re-elect (`electIfNeeded` bails while `ns.leader` is set)
|
|
9
|
+
* until every tab was reloaded. Treat the silence as a failed promotion. */
|
|
10
|
+
const PROMOTION_TIMEOUT_MS = 2e4;
|
|
4
11
|
const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
|
|
5
12
|
const LEADER_FAILURE_BACKOFF_MS = 1e3;
|
|
6
13
|
const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
@@ -35,7 +42,8 @@ function getNamespace(fingerprint, bucketId) {
|
|
|
35
42
|
failedCycles: 0,
|
|
36
43
|
electing: false,
|
|
37
44
|
attachRetry: /* @__PURE__ */ new Map(),
|
|
38
|
-
tabLockMonitor: null
|
|
45
|
+
tabLockMonitor: null,
|
|
46
|
+
promotionTimer: null
|
|
39
47
|
};
|
|
40
48
|
namespaces.set(key, ns);
|
|
41
49
|
}
|
|
@@ -63,6 +71,7 @@ function clearLeader(ns, opts) {
|
|
|
63
71
|
const leader = ns.leader;
|
|
64
72
|
if (!leader) return null;
|
|
65
73
|
ns.leader = null;
|
|
74
|
+
clearPromotionTimer(ns);
|
|
66
75
|
ns.tabLockMonitor?.abort();
|
|
67
76
|
ns.tabLockMonitor = null;
|
|
68
77
|
const tab = ns.tabs.get(leader.tabId);
|
|
@@ -86,6 +95,39 @@ function clearLeader(ns, opts) {
|
|
|
86
95
|
workerLock
|
|
87
96
|
};
|
|
88
97
|
}
|
|
98
|
+
function clearPromotionTimer(ns) {
|
|
99
|
+
if (ns.promotionTimer) clearTimeout(ns.promotionTimer);
|
|
100
|
+
ns.promotionTimer = null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A promotion did not work out: back the tab off, clear the leadership, and
|
|
104
|
+
* re-elect. Shared by the tab's own `leader-failed` report and by the
|
|
105
|
+
* {@link PROMOTION_TIMEOUT_MS} deadline, so a tab that hangs mid-promotion
|
|
106
|
+
* costs the namespace the same 1s backoff as one that fails loudly, instead of
|
|
107
|
+
* owning it forever.
|
|
108
|
+
*
|
|
109
|
+
* Every reason counts toward `failedCycles`. Gating that on 'opfs-unavailable'
|
|
110
|
+
* left any other recurring failure (e.g. a tab lock nobody frees) looping at
|
|
111
|
+
* the backoff interval forever, with allowMemoryFallback never granted, so
|
|
112
|
+
* every tab timed out in start() and booted solo - each one then contending
|
|
113
|
+
* for the OPFS pool on its own, which is exactly what shared-tabs exists to
|
|
114
|
+
* stop.
|
|
115
|
+
*/
|
|
116
|
+
function failPromotion(ns, tabId, leadershipId, opts) {
|
|
117
|
+
if (ns.leader?.tabId !== tabId || ns.leader.leadershipId !== leadershipId) return;
|
|
118
|
+
ns.failedCycles += 1;
|
|
119
|
+
ns.failedUntil.set(tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
120
|
+
const previous = clearLeader(ns, {
|
|
121
|
+
demote: false,
|
|
122
|
+
removeTab: false
|
|
123
|
+
});
|
|
124
|
+
const tab = ns.tabs.get(tabId);
|
|
125
|
+
if (tab) tab.heldLeadership = null;
|
|
126
|
+
electIfNeeded(ns, opts.timedOut && previous ? {
|
|
127
|
+
...previous,
|
|
128
|
+
workerLock: `sp00ky-tabs:${ns.fingerprint}:${ns.bucketId}:worker:${leadershipId}`
|
|
129
|
+
} : previous);
|
|
130
|
+
}
|
|
89
131
|
/**
|
|
90
132
|
* Fast crash detection: queue a Web Lock request on the leader's tab lock.
|
|
91
133
|
* Locks release the instant their holder's tab dies, so being GRANTED means
|
|
@@ -174,6 +216,11 @@ function electIfNeeded(ns, previous = null) {
|
|
|
174
216
|
allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
|
|
175
217
|
resumeHeld
|
|
176
218
|
});
|
|
219
|
+
clearPromotionTimer(ns);
|
|
220
|
+
ns.promotionTimer = setTimeout(() => {
|
|
221
|
+
ns.promotionTimer = null;
|
|
222
|
+
failPromotion(ns, candidate.tabId, leadershipId, { timedOut: true });
|
|
223
|
+
}, PROMOTION_TIMEOUT_MS);
|
|
177
224
|
} finally {
|
|
178
225
|
ns.electing = false;
|
|
179
226
|
}
|
|
@@ -359,6 +406,7 @@ function handleTabMessage(port, msg, ports) {
|
|
|
359
406
|
}
|
|
360
407
|
ns.leader.ready = true;
|
|
361
408
|
ns.failedCycles = 0;
|
|
409
|
+
clearPromotionTimer(ns);
|
|
362
410
|
startTabLockMonitor(ns, msg.leadershipId);
|
|
363
411
|
const tab = ns.tabs.get(msg.tabId);
|
|
364
412
|
if (tab) tab.heldLeadership = {
|
|
@@ -374,19 +422,9 @@ function handleTabMessage(port, msg, ports) {
|
|
|
374
422
|
assignFollowerPorts(ns);
|
|
375
423
|
break;
|
|
376
424
|
}
|
|
377
|
-
case "leader-failed":
|
|
378
|
-
|
|
379
|
-
ns.failedCycles += 1;
|
|
380
|
-
ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
381
|
-
const previous = clearLeader(ns, {
|
|
382
|
-
demote: false,
|
|
383
|
-
removeTab: false
|
|
384
|
-
});
|
|
385
|
-
const tab = ns.tabs.get(msg.tabId);
|
|
386
|
-
if (tab) tab.heldLeadership = null;
|
|
387
|
-
electIfNeeded(ns, previous);
|
|
425
|
+
case "leader-failed":
|
|
426
|
+
failPromotion(ns, msg.tabId, msg.leadershipId, { timedOut: false });
|
|
388
427
|
break;
|
|
389
|
-
}
|
|
390
428
|
case "follower-port-attached":
|
|
391
429
|
if (ns.leader?.tabId === msg.tabId && ns.leader.leadershipId === msg.leadershipId) followerAttached(ns, msg.followerTabId);
|
|
392
430
|
break;
|
package/dist/types.d.ts
CHANGED
|
@@ -334,6 +334,37 @@ interface LocalStore extends LocalCacheEngine {
|
|
|
334
334
|
/** Fires immediately with the current snapshot, then on every change.
|
|
335
335
|
* Returns an unsubscribe function. */
|
|
336
336
|
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
|
337
|
+
/**
|
|
338
|
+
* Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
|
|
339
|
+
* form. The in-browser circuit primes and reconciles itself from this on
|
|
340
|
+
* boot instead of re-downloading the working set. OPTIONAL: an engine
|
|
341
|
+
* without it boots the circuit empty, as before.
|
|
342
|
+
*/
|
|
343
|
+
scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
|
|
344
|
+
/**
|
|
345
|
+
* Circuit snapshot storage, keyed. Lives in the same durable store as the
|
|
346
|
+
* rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
|
|
347
|
+
* readable by follower tabs over the port transport. OPTIONAL: an engine
|
|
348
|
+
* without it primes from `scanVersions` alone.
|
|
349
|
+
*/
|
|
350
|
+
getSnapshot?(key: string): Promise<StoredSnapshot | null>;
|
|
351
|
+
putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
|
|
352
|
+
deleteSnapshot?(key: string): Promise<void>;
|
|
353
|
+
}
|
|
354
|
+
/** Describes a stored circuit snapshot; what decides whether it is usable. */
|
|
355
|
+
interface SnapshotMeta {
|
|
356
|
+
/** Bump when the wire shape the circuit reads changes. */
|
|
357
|
+
formatVersion: number;
|
|
358
|
+
/** Hash of the schema the rows were projected under. */
|
|
359
|
+
schemaHash: string;
|
|
360
|
+
savedAt: number;
|
|
361
|
+
/** Highest `_00_rv` per table at save time (diagnostics). */
|
|
362
|
+
maxRv?: Record<string, number>;
|
|
363
|
+
[key: string]: unknown;
|
|
364
|
+
}
|
|
365
|
+
interface StoredSnapshot {
|
|
366
|
+
bytes: Uint8Array;
|
|
367
|
+
meta: SnapshotMeta;
|
|
337
368
|
}
|
|
338
369
|
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
339
370
|
type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
@@ -538,21 +569,19 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
538
569
|
*/
|
|
539
570
|
sharedTabs?: boolean;
|
|
540
571
|
/**
|
|
541
|
-
* Persist the in-browser SSP circuit
|
|
542
|
-
* reload
|
|
543
|
-
*
|
|
572
|
+
* Persist the in-browser SSP circuit's store as a snapshot in the local
|
|
573
|
+
* store, so a reload restores it and only steps in what changed since.
|
|
574
|
+
* Defaults to `true` under `localEngine: 'sqlite'` (the OPFS-backed store
|
|
575
|
+
* can hold the bytes) and `false` otherwise.
|
|
544
576
|
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* costing a full deep clone of every row of every ingested table plus a JSON
|
|
550
|
-
* encode of the result, `Circuit::save` in the Rust core, mirroring the
|
|
551
|
-
* server's rule in `ssp-node`: *never per-ingest*.
|
|
577
|
+
* Without a snapshot the circuit is primed by reading every cached row back
|
|
578
|
+
* out of the local store; either way a reload never re-downloads the working
|
|
579
|
+
* set, which is what an empty circuit used to force: every id in the
|
|
580
|
+
* server's list_ref classified as missing.
|
|
552
581
|
*
|
|
553
|
-
*
|
|
554
|
-
* ({@link circuitCheckpointMs}) and
|
|
555
|
-
*
|
|
582
|
+
* Snapshots are store-only (views are re-registered per session) and written
|
|
583
|
+
* on a checkpoint interval ({@link circuitCheckpointMs}) and when the page
|
|
584
|
+
* goes hidden, never per ingest or per query registration.
|
|
556
585
|
*/
|
|
557
586
|
persistCircuit?: boolean;
|
|
558
587
|
/**
|
|
@@ -560,6 +589,16 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
560
589
|
* 30000. Ignored when `persistCircuit` is off.
|
|
561
590
|
*/
|
|
562
591
|
circuitCheckpointMs?: number;
|
|
592
|
+
/**
|
|
593
|
+
* Keep only the fields registered queries evaluate (filter predicates, join
|
|
594
|
+
* keys, sort keys, plus `id`/`_00_rv`) per row in the in-browser circuit.
|
|
595
|
+
* Default `true`. The circuit only ever reads those fields; bodies are
|
|
596
|
+
* rendered from the local store. Measured on 7700 rows with 20 KB bodies:
|
|
597
|
+
* the wasm heap peak went from ~500 MB to ~24 MB and the snapshot from
|
|
598
|
+
* 156 MB to 1.2 MB. A query that evaluates a field earlier rows were kept
|
|
599
|
+
* without has that field merged in from the local store on registration.
|
|
600
|
+
*/
|
|
601
|
+
circuitProjection?: boolean;
|
|
563
602
|
/** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel). */
|
|
564
603
|
otelTransmit?: PinoTransmit;
|
|
565
604
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.205",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.205",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.205",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"blurhash": "^2.0.5",
|
|
@@ -88,6 +88,19 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
88
88
|
return this.versionLookups[recordId] ?? 0;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Seed the version memo from rows the circuit was primed with out of the
|
|
93
|
+
* local store, so the first post-reload sync diff does not re-download
|
|
94
|
+
* bodies the browser already has. Only rows the prime actually put into the
|
|
95
|
+
* circuit belong here: a memo entry with no circuit row would make the diff
|
|
96
|
+
* flag the id forever while nothing ever fetches it.
|
|
97
|
+
*/
|
|
98
|
+
public primeVersions(entries: [string, number][]): void {
|
|
99
|
+
for (const [id, rv] of entries) {
|
|
100
|
+
if (rv > (this.versionLookups[id] ?? 0)) this.versionLookups[id] = rv;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
91
104
|
/** Drop the version cache on a bucket switch — a stale version would make
|
|
92
105
|
* the sync diff skip fetching a body the new bucket legitimately needs. */
|
|
93
106
|
public clearVersionLookups(): void {
|
|
@@ -174,13 +187,17 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
174
187
|
// ingestMany coalesces the per-record stream updates into a single
|
|
175
188
|
// notification per affected query — the UI then updates once, after the
|
|
176
189
|
// whole batch is ingested, instead of row-by-row.
|
|
190
|
+
const versionOf = new Map<string, number>();
|
|
177
191
|
const bulk = populatedRecords.map((record) => {
|
|
178
192
|
const recordId = encodeRecordId(record.record.id);
|
|
179
|
-
|
|
193
|
+
versionOf.set(recordId, record.version);
|
|
180
194
|
return { table: record.table, op: record.op, id: recordId, record: record.record };
|
|
181
195
|
});
|
|
182
|
-
|
|
183
|
-
|
|
196
|
+
// Memo and relay only what the circuit actually took: a chunk that
|
|
197
|
+
// failed mid-batch is neither "known" here nor fanned out to followers.
|
|
198
|
+
const ingested = this.streamProcessor.ingestMany(bulk);
|
|
199
|
+
for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
|
|
200
|
+
if (ingested.length > 0) this.ingestRelay?.(ingested as CacheIngestTuple[]);
|
|
184
201
|
|
|
185
202
|
this.logger.debug(
|
|
186
203
|
{ count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
|
|
@@ -221,7 +238,7 @@ export class CacheModule implements StreamUpdateReceiver {
|
|
|
221
238
|
|
|
222
239
|
// 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
|
|
223
240
|
delete this.versionLookups[id];
|
|
224
|
-
this.streamProcessor.
|
|
241
|
+
this.streamProcessor.ingestMany([{ table, op: 'DELETE', id, record: recordData }]);
|
|
225
242
|
this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
|
|
226
243
|
|
|
227
244
|
this.logger.debug(
|
|
@@ -327,7 +327,9 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
327
327
|
const info = this.unwrapInfo(res);
|
|
328
328
|
this.localTablesAt = Date.now();
|
|
329
329
|
if (info && info.tables) {
|
|
330
|
-
|
|
330
|
+
// The circuit snapshot table holds a BLOB, not JSON rows; the
|
|
331
|
+
// explorer cannot render it and has nothing to show for it.
|
|
332
|
+
const names = Object.keys(info.tables).filter((n) => n !== '_00_circuit_snapshot');
|
|
331
333
|
const changed =
|
|
332
334
|
names.length !== this.localTables.length ||
|
|
333
335
|
names.some((n, i) => n !== this.localTables[i]);
|
|
@@ -8,6 +8,9 @@ import { SyncEventTypes, createSyncEventSystem } from './events/index';
|
|
|
8
8
|
import { encodeRecordId } from '../../utils/index';
|
|
9
9
|
import { cleanRecord } from '../../utils/parser';
|
|
10
10
|
|
|
11
|
+
/** Ids per remote `SELECT * FROM $ids` round trip. */
|
|
12
|
+
const FETCH_CHUNK = 500;
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* SyncEngine handles the core sync operations: fetching remote records,
|
|
13
16
|
* caching them locally, and ingesting into DBSP.
|
|
@@ -55,10 +58,16 @@ export class SyncEngine {
|
|
|
55
58
|
stillRemoteIds = await this.handleRemovedRecords(removed);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
// Fetch added/updated records from remote
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// Fetch added/updated records from remote. Skip ids whose body the local
|
|
62
|
+
// store already holds at this version (the circuit prime seeded the memo
|
|
63
|
+
// from `_00_rv`): after a reload every id in the server's list_ref used to
|
|
64
|
+
// classify as `added` against an empty circuit, and this fetched the
|
|
65
|
+
// whole working set again.
|
|
66
|
+
const toFetch = [...added, ...updated].filter((item) => {
|
|
67
|
+
const localVersion = this.cache.lookup(encodeRecordId(item.id));
|
|
68
|
+
return !(localVersion && item.version <= localVersion);
|
|
69
|
+
});
|
|
70
|
+
if (toFetch.length === 0) {
|
|
62
71
|
return { remoteFetchMs: 0, stillRemoteIds };
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -67,66 +76,75 @@ export class SyncEngine {
|
|
|
67
76
|
for (const item of toFetch) {
|
|
68
77
|
versionMap.set(encodeRecordId(item.id), item.version);
|
|
69
78
|
}
|
|
79
|
+
const addedIds = new Set(added.map((item) => encodeRecordId(item.id)));
|
|
70
80
|
|
|
71
81
|
// Fetch records from remote — avoid SELECT *, <subquery> FROM $param
|
|
72
82
|
// pattern which drops the * fields in SurrealDB v3 (known bug).
|
|
73
83
|
// Versions are already known from the diff's list_ref data.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
84
|
+
//
|
|
85
|
+
// Chunked so a genuinely cold load lands progressively: one response
|
|
86
|
+
// holding thousands of bodies, then one transaction MERGEing all of them,
|
|
87
|
+
// was a single main-thread stall and a memory spike on top of it.
|
|
88
|
+
let remoteFetchMs = 0;
|
|
89
|
+
const remoteResults: RecordWithId[] = [];
|
|
90
|
+
for (let i = 0; i < toFetch.length; i += FETCH_CHUNK) {
|
|
91
|
+
const idsToFetch = toFetch.slice(i, i + FETCH_CHUNK).map((x) => x.id);
|
|
92
|
+
const remoteFetchStart = performance.now();
|
|
93
|
+
const [chunkResults] = await this.remote.query<[RecordWithId[]]>(
|
|
94
|
+
'SELECT * FROM $idsToFetch',
|
|
95
|
+
{ idsToFetch }
|
|
96
|
+
);
|
|
97
|
+
remoteFetchMs += performance.now() - remoteFetchStart;
|
|
98
|
+
|
|
99
|
+
// Prepare batch for cache (which handles both DB and DBSP)
|
|
100
|
+
const cacheBatch: CacheRecord[] = [];
|
|
101
|
+
for (const record of chunkResults ?? []) {
|
|
102
|
+
if (!record?.id) {
|
|
103
|
+
this.logger.warn(
|
|
104
|
+
{
|
|
105
|
+
record,
|
|
106
|
+
idsToFetch,
|
|
107
|
+
Category: 'sp00ky-client::SyncEngine::syncRecords',
|
|
108
|
+
},
|
|
109
|
+
'Remote record has no id (possibly deleted). Skipping record'
|
|
110
|
+
);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
remoteResults.push(record);
|
|
114
|
+
const fullId = encodeRecordId(record.id);
|
|
115
|
+
const table = record.id.table.toString();
|
|
116
|
+
const version = versionMap.get(fullId) ?? 0;
|
|
117
|
+
|
|
118
|
+
const localVersion = this.cache.lookup(fullId);
|
|
119
|
+
if (localVersion && version <= localVersion) {
|
|
120
|
+
this.logger.info(
|
|
121
|
+
{
|
|
122
|
+
recordId: fullId,
|
|
123
|
+
version,
|
|
124
|
+
localVersion,
|
|
125
|
+
Category: 'sp00ky-client::SyncEngine::syncRecords',
|
|
126
|
+
},
|
|
127
|
+
'Local version is higher than remote version. Skipping record'
|
|
128
|
+
);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
132
|
+
const cleanedRecord = tableSchema
|
|
133
|
+
? cleanRecord(tableSchema.columns, record)
|
|
134
|
+
: record;
|
|
135
|
+
|
|
136
|
+
cacheBatch.push({
|
|
137
|
+
table,
|
|
138
|
+
op: addedIds.has(fullId) ? 'CREATE' : 'UPDATE',
|
|
139
|
+
record: cleanedRecord as RecordWithId,
|
|
140
|
+
version,
|
|
141
|
+
});
|
|
113
142
|
}
|
|
114
|
-
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
115
|
-
const cleanedRecord = tableSchema
|
|
116
|
-
? cleanRecord(tableSchema.columns, record)
|
|
117
|
-
: record;
|
|
118
|
-
|
|
119
|
-
cacheBatch.push({
|
|
120
|
-
table,
|
|
121
|
-
op: isAdded ? 'CREATE' : 'UPDATE',
|
|
122
|
-
record: cleanedRecord as RecordWithId,
|
|
123
|
-
version,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
143
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
// Use CacheModule to handle both local DB and DBSP ingestion
|
|
145
|
+
if (cacheBatch.length > 0) {
|
|
146
|
+
await this.cache.saveBatch(cacheBatch);
|
|
147
|
+
}
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
this.events.emit(SyncEventTypes.RemoteDataIngested, {
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -527,14 +527,40 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
527
527
|
this.tabId = tabId;
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
/**
|
|
531
|
-
*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
530
|
+
/** In-flight {@link resumeLeaderDuties}, so a second call joins the first
|
|
531
|
+
* instead of double-draining the outbox. */
|
|
532
|
+
private leaderDutiesInFlight: Promise<void> | null = null;
|
|
533
|
+
|
|
534
|
+
/** Resolves once the in-browser circuit has been primed from the local
|
|
535
|
+
* store. Every sync diff waits on it: diffing against an empty circuit
|
|
536
|
+
* classifies the whole working set as missing and re-downloads it. */
|
|
537
|
+
private primeGate: () => Promise<void> = () => Promise.resolve();
|
|
538
|
+
/** The prime we last waited on. `whenPrimed` hands out one promise per
|
|
539
|
+
* prime, so a new identity means a new prime (boot, bucket switch) ran. */
|
|
540
|
+
private settledPrime: Promise<void> | null = null;
|
|
541
|
+
|
|
542
|
+
setPrimeGate(gate: () => Promise<void>): void {
|
|
543
|
+
this.primeGate = gate;
|
|
544
|
+
this.settledPrime = null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Leader WIRING only, and deliberately synchronous.
|
|
549
|
+
*
|
|
550
|
+
* The coordinator publishes the leader role and tells the broker
|
|
551
|
+
* `leader-ready` the moment the store is adopted, and the broker can mint a
|
|
552
|
+
* follower's ports on the very next tick. So the follower-message handler
|
|
553
|
+
* has to be live before this returns, or a mutation forwarded in that window
|
|
554
|
+
* is dropped. Everything that can block (outbox reload, LIVE restart) moved
|
|
555
|
+
* to {@link resumeLeaderDuties}: a promotion that waits on the network holds
|
|
556
|
+
* `leader-ready` back, and a broker whose leader never reports ready serves
|
|
557
|
+
* no follower ports and re-elects no one, which wedges the whole namespace.
|
|
558
|
+
*/
|
|
559
|
+
public promoteToLeader(hub: LeaderSyncHub): void {
|
|
535
560
|
this.tabRole = 'leader';
|
|
536
561
|
this.hub = hub;
|
|
537
562
|
this.forwarder = null;
|
|
563
|
+
this.leaderDutiesInFlight = null;
|
|
538
564
|
hub.onFollowerMessage = (tabId, msg) => {
|
|
539
565
|
void tabId;
|
|
540
566
|
switch (msg.type) {
|
|
@@ -548,19 +574,34 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
548
574
|
break;
|
|
549
575
|
}
|
|
550
576
|
};
|
|
551
|
-
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Leader duties: drain the shared outbox, own the single list_ref LIVE,
|
|
580
|
+
* relay LIVE events and rollbacks to followers. Idempotent for a boot-time
|
|
581
|
+
* leader; a runtime promotion (failover) reloads the outbox, which now
|
|
582
|
+
* holds EVERY tab's rows, and restarts LIVE under this session. Runs in the
|
|
583
|
+
* background off the promotion path, so however long it takes (or if it
|
|
584
|
+
* never finishes) the tab is already a working leader. */
|
|
585
|
+
public resumeLeaderDuties(): Promise<void> {
|
|
586
|
+
if (this.leaderDutiesInFlight) return this.leaderDutiesInFlight;
|
|
587
|
+
const run = (async () => {
|
|
588
|
+
if (!this.isInit || this.tabRole !== 'leader') return;
|
|
552
589
|
await this.upQueue.loadFromDatabase();
|
|
553
590
|
void this.scheduler.syncUp();
|
|
554
591
|
if (this.currentUserId || this.anonLiveEnabled) {
|
|
555
592
|
this.startListRefPoll();
|
|
556
593
|
await this.restartRefLiveQuery().catch((err) => {
|
|
557
594
|
this.logger.warn(
|
|
558
|
-
{ err, Category: 'sp00ky-client::Sp00kySync::
|
|
595
|
+
{ err, Category: 'sp00ky-client::Sp00kySync::resumeLeaderDuties' },
|
|
559
596
|
'LIVE restart failed on promotion; poll fallback covers it'
|
|
560
597
|
);
|
|
561
598
|
});
|
|
562
599
|
}
|
|
563
|
-
}
|
|
600
|
+
})();
|
|
601
|
+
this.leaderDutiesInFlight = run;
|
|
602
|
+
return run.finally(() => {
|
|
603
|
+
if (this.leaderDutiesInFlight === run) this.leaderDutiesInFlight = null;
|
|
604
|
+
});
|
|
564
605
|
}
|
|
565
606
|
|
|
566
607
|
/** Follower duties: no outbox drain, no LIVE. Mutations forward to the
|
|
@@ -570,6 +611,9 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
570
611
|
this.tabRole = 'follower';
|
|
571
612
|
this.hub = null;
|
|
572
613
|
this.forwarder = forwarder;
|
|
614
|
+
// A still-running resumeLeaderDuties self-cancels on its `tabRole` guard;
|
|
615
|
+
// drop the handle so a later re-promotion starts a fresh drain.
|
|
616
|
+
this.leaderDutiesInFlight = null;
|
|
573
617
|
void this.killRefLiveQuery();
|
|
574
618
|
forwarder.onLeaderMessage = (msg) => {
|
|
575
619
|
switch (msg.type) {
|
|
@@ -1531,6 +1575,19 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1531
1575
|
* means the single resulting UI update lands after this completes.
|
|
1532
1576
|
*/
|
|
1533
1577
|
private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
|
|
1578
|
+
// The diff was computed against whatever the circuit held at call time; if
|
|
1579
|
+
// the boot prime is still filling it, wait and recompute from the primed
|
|
1580
|
+
// `localArray` so the delta is real rather than "everything".
|
|
1581
|
+
const prime = this.primeGate();
|
|
1582
|
+
if (prime !== this.settledPrime) {
|
|
1583
|
+
await prime;
|
|
1584
|
+
this.settledPrime = prime;
|
|
1585
|
+
const fresh = this.dataModule.getQueryByHash(hash);
|
|
1586
|
+
if (!fresh) return;
|
|
1587
|
+
const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
|
|
1588
|
+
if (!recomputed) return;
|
|
1589
|
+
diff = recomputed;
|
|
1590
|
+
}
|
|
1534
1591
|
// Don't let sync re-add a record the user just deleted locally. The remote
|
|
1535
1592
|
// delete is queued in the outbox, so until it's processed the server's
|
|
1536
1593
|
// `_00_list_ref` still lists the record — the diff then classifies it as
|
|
@@ -138,6 +138,39 @@ export interface LocalStore extends LocalCacheEngine {
|
|
|
138
138
|
/** Fires immediately with the current snapshot, then on every change.
|
|
139
139
|
* Returns an unsubscribe function. */
|
|
140
140
|
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
|
141
|
+
/**
|
|
142
|
+
* Every cached row's `(id, _00_rv)` per table, ids in stable `table:id`
|
|
143
|
+
* form. The in-browser circuit primes and reconciles itself from this on
|
|
144
|
+
* boot instead of re-downloading the working set. OPTIONAL: an engine
|
|
145
|
+
* without it boots the circuit empty, as before.
|
|
146
|
+
*/
|
|
147
|
+
scanVersions?(tables: string[]): Promise<Record<string, [string, number][]>>;
|
|
148
|
+
/**
|
|
149
|
+
* Circuit snapshot storage, keyed. Lives in the same durable store as the
|
|
150
|
+
* rows (OPFS SQLite), so it is per-bucket by construction, atomic, and
|
|
151
|
+
* readable by follower tabs over the port transport. OPTIONAL: an engine
|
|
152
|
+
* without it primes from `scanVersions` alone.
|
|
153
|
+
*/
|
|
154
|
+
getSnapshot?(key: string): Promise<StoredSnapshot | null>;
|
|
155
|
+
putSnapshot?(key: string, bytes: Uint8Array, meta: SnapshotMeta): Promise<void>;
|
|
156
|
+
deleteSnapshot?(key: string): Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Describes a stored circuit snapshot; what decides whether it is usable. */
|
|
160
|
+
export interface SnapshotMeta {
|
|
161
|
+
/** Bump when the wire shape the circuit reads changes. */
|
|
162
|
+
formatVersion: number;
|
|
163
|
+
/** Hash of the schema the rows were projected under. */
|
|
164
|
+
schemaHash: string;
|
|
165
|
+
savedAt: number;
|
|
166
|
+
/** Highest `_00_rv` per table at save time (diagnostics). */
|
|
167
|
+
maxRv?: Record<string, number>;
|
|
168
|
+
[key: string]: unknown;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface StoredSnapshot {
|
|
172
|
+
bytes: Uint8Array;
|
|
173
|
+
meta: SnapshotMeta;
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
@@ -556,3 +556,61 @@ describe('create() tx result shaping (fast path parity)', () => {
|
|
|
556
556
|
expect(outboxRow.data).toEqual(payload);
|
|
557
557
|
});
|
|
558
558
|
});
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
// The circuit snapshot + version scan the boot-time prime reads through this
|
|
562
|
+
// engine. Bytes and meta must round-trip as ONE batch (a reader must never see
|
|
563
|
+
// new bytes with old meta) and a table that does not exist yet scans as empty.
|
|
564
|
+
describe('SqliteCacheEngine circuit snapshot + scanVersions', () => {
|
|
565
|
+
it('stores bytes and meta in one batch and reads them back', async () => {
|
|
566
|
+
const stored = new Map<string, unknown>();
|
|
567
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
568
|
+
const types: string[] = [];
|
|
569
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
570
|
+
types.push(type);
|
|
571
|
+
if (type === 'open') return { persisted: true };
|
|
572
|
+
if (type === 'batch') {
|
|
573
|
+
for (const stmt of payload as { sql: string; bind: unknown[] }[]) {
|
|
574
|
+
stored.set(stmt.bind[0] as string, stmt.bind[1]);
|
|
575
|
+
}
|
|
576
|
+
return {};
|
|
577
|
+
}
|
|
578
|
+
if (type === 'exec') {
|
|
579
|
+
const ids = (payload.bind ?? []) as string[];
|
|
580
|
+
return { rows: ids.filter((id) => stored.has(id)).map((id) => ({ id, data: stored.get(id) })) };
|
|
581
|
+
}
|
|
582
|
+
return {};
|
|
583
|
+
});
|
|
584
|
+
await engine.connect('anon');
|
|
585
|
+
|
|
586
|
+
expect(await engine.getSnapshot('circuit')).toBeNull();
|
|
587
|
+
const bytes = new Uint8Array([1, 2, 3]);
|
|
588
|
+
await engine.putSnapshot('circuit', bytes, { formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
589
|
+
expect(types.filter((t) => t === 'batch')).toHaveLength(1);
|
|
590
|
+
const snap = await engine.getSnapshot('circuit');
|
|
591
|
+
expect(snap?.bytes).toEqual(bytes);
|
|
592
|
+
expect(snap?.meta).toEqual({ formatVersion: 1, schemaHash: 'h', savedAt: 5 });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('scans (id, rv) per table and reads a missing table as empty', async () => {
|
|
596
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
597
|
+
stubTransport(engine, (type: string, payload: any) => {
|
|
598
|
+
if (type === 'open') return { persisted: true };
|
|
599
|
+
if (type === 'exec') {
|
|
600
|
+
if (String(payload.sql).includes('"thing"')) {
|
|
601
|
+
return { rows: [{ id: 'thing:a', rv: 3 }, { id: 'thing:b', rv: null }] };
|
|
602
|
+
}
|
|
603
|
+
throw new Error('no such table');
|
|
604
|
+
}
|
|
605
|
+
return {};
|
|
606
|
+
});
|
|
607
|
+
await engine.connect('anon');
|
|
608
|
+
expect(await engine.scanVersions(['thing', 'ghost'])).toEqual({
|
|
609
|
+
thing: [
|
|
610
|
+
['thing:a', 3],
|
|
611
|
+
['thing:b', 0],
|
|
612
|
+
],
|
|
613
|
+
ghost: [],
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
});
|