@spooky-sync/core 0.0.1-canary.204 → 0.0.1-canary.206
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 +172 -44
- package/dist/index.js +636 -189
- package/dist/tabs-broker-worker.js +51 -13
- package/dist/types.d.ts +60 -13
- package/package.json +3 -3
- package/src/modules/cache/index.ts +21 -4
- package/src/modules/data/data.membership.test.ts +62 -1
- package/src/modules/data/data.rebind.test.ts +23 -0
- package/src/modules/data/index.ts +57 -17
- package/src/modules/devtools/index.ts +9 -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 +29 -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
|
/**
|
|
@@ -835,6 +874,11 @@ interface QueryConfig {
|
|
|
835
874
|
* "never established" has to fall back to a predicate scan of the local store
|
|
836
875
|
* so a query first run on this device still paints offline. A
|
|
837
876
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
877
|
+
*
|
|
878
|
+
* On a cold start it is seeded from the durable `_00_window` row when that
|
|
879
|
+
* row is non-empty, or empty but `confirmed` (the server reported zero rows
|
|
880
|
+
* for the query). An unconfirmed empty row is ignored, so a device poisoned
|
|
881
|
+
* by an old client that mirrored unflushed reads still self-heals.
|
|
838
882
|
*/
|
|
839
883
|
membershipKnown?: boolean;
|
|
840
884
|
/**
|
|
@@ -850,6 +894,9 @@ interface QueryConfig {
|
|
|
850
894
|
* genuine transition and must be honoured, or removed rows resurrect.
|
|
851
895
|
*
|
|
852
896
|
* In-memory only: a fresh session must re-earn the right to believe empties.
|
|
897
|
+
* What does persist is the `confirmed` marker on the `_00_window` row, which
|
|
898
|
+
* an empty set earns when it arrives with a server row count of zero or after
|
|
899
|
+
* a non-empty set in the same session.
|
|
853
900
|
*/
|
|
854
901
|
remoteSeen?: boolean;
|
|
855
902
|
/**
|
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.206",
|
|
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.206",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.206",
|
|
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(
|
|
@@ -77,7 +77,7 @@ function makeLocal(pendingRows: Array<{ recordId: RecordId; mutationType: string
|
|
|
77
77
|
const bodies = new Map(
|
|
78
78
|
['a', 'b', 'c'].map((k) => [`thread:${k}`, { id: new RecordId('thread', k), title: k }])
|
|
79
79
|
);
|
|
80
|
-
const windowRows = new Map<string, { ids: RecordVersionArray }>();
|
|
80
|
+
const windowRows = new Map<string, { ids: RecordVersionArray; confirmed?: boolean }>();
|
|
81
81
|
const local: any = {
|
|
82
82
|
epoch: 1,
|
|
83
83
|
bodies,
|
|
@@ -424,6 +424,67 @@ describe('membership-authoritative rendering', () => {
|
|
|
424
424
|
expect(state.config.remoteArray).toEqual([]);
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
+
it('marks a non-empty set and a server-confirmed empty set as confirmed', async () => {
|
|
428
|
+
const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
429
|
+
|
|
430
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
431
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ confirmed: true });
|
|
432
|
+
|
|
433
|
+
// The server reports zero rows: a real answer, durable.
|
|
434
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
|
|
435
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
436
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('marks an empty set that follows a seen set as confirmed', async () => {
|
|
441
|
+
// Everything the query matched was deleted while this session watched: the
|
|
442
|
+
// transition is genuine, so the next boot must not resurrect the rows.
|
|
443
|
+
const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
444
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
445
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
446
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
447
|
+
expect.objectContaining({ ids: [], confirmed: true })
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('leaves the retry-budget empty unconfirmed', async () => {
|
|
452
|
+
// Two unreadable-row-count empties are believed for this session, but they
|
|
453
|
+
// are a guess: the durable row must not turn that guess into a permanent
|
|
454
|
+
// empty list on the next boot.
|
|
455
|
+
const { dm, local, hash } = setup({
|
|
456
|
+
membershipKey: 'stable-key',
|
|
457
|
+
remoteArray: [['thread:a', 1]],
|
|
458
|
+
membershipKnown: true,
|
|
459
|
+
});
|
|
460
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
461
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
462
|
+
expect(local.windowRows.get('stable-key')).toEqual(
|
|
463
|
+
expect.objectContaining({ ids: [], confirmed: false })
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('seeds a known-empty membership from a confirmed empty durable row', async () => {
|
|
468
|
+
// The reload after a server-confirmed empty: the list must stay empty
|
|
469
|
+
// instead of re-admitting every cached body until the next poll blanks it.
|
|
470
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
471
|
+
local.windowRows.set('stable-key', { ids: [], confirmed: true });
|
|
472
|
+
|
|
473
|
+
const fresh = await (dm as any).createNewQuery({
|
|
474
|
+
recordId: new RecordId('_00_query', 'h-confirmed-empty'),
|
|
475
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
476
|
+
params: {},
|
|
477
|
+
ttl: '10m',
|
|
478
|
+
tableName: 'thread',
|
|
479
|
+
plan,
|
|
480
|
+
membershipKey: 'stable-key',
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(fresh.config.membershipKnown).toBe(true);
|
|
484
|
+
expect(fresh.config.remoteArray).toEqual([]);
|
|
485
|
+
expect(fresh.records).toEqual([]);
|
|
486
|
+
});
|
|
487
|
+
|
|
427
488
|
it('does not seed membership from an empty durable row', async () => {
|
|
428
489
|
// Self-heals devices poisoned before the guard existed: an empty durable
|
|
429
490
|
// row is indistinguishable from "never had membership", so it must fall
|
|
@@ -122,6 +122,29 @@ describe('DataModule.rebindAfterBucketSwitch', () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
describe('DataModule.rebindAfterBucketSwitch durable seed', () => {
|
|
126
|
+
it('seeds a confirmed-empty membership from the new bucket, and ignores an unconfirmed one', async () => {
|
|
127
|
+
const harness = makeHarness();
|
|
128
|
+
const { dm, local } = harness as any;
|
|
129
|
+
const state = makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }]);
|
|
130
|
+
state.config.membershipKey = 'stable-key';
|
|
131
|
+
(dm as any).activeQueries.set('h1', state);
|
|
132
|
+
local.getById = vi.fn(async () => ({ ids: [], confirmed: true }));
|
|
133
|
+
|
|
134
|
+
await dm.rebindAfterBucketSwitch();
|
|
135
|
+
let qs = (dm as any).activeQueries.get('h1') as QueryState;
|
|
136
|
+
expect(qs.config.membershipKnown).toBe(true);
|
|
137
|
+
expect(qs.config.remoteArray).toEqual([]);
|
|
138
|
+
if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
|
|
139
|
+
|
|
140
|
+
local.getById = vi.fn(async () => ({ ids: [] }));
|
|
141
|
+
await dm.rebindAfterBucketSwitch();
|
|
142
|
+
qs = (dm as any).activeQueries.get('h1') as QueryState;
|
|
143
|
+
expect(qs.config.membershipKnown).toBe(false);
|
|
144
|
+
if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
125
148
|
describe('stale-epoch stream updates', () => {
|
|
126
149
|
it('drops an update whose chain started before a bucket switch', async () => {
|
|
127
150
|
const { dm, local } = makeHarness();
|
|
@@ -82,6 +82,13 @@ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
|
82
82
|
* Merges the functionality of QueryManager and MutationManager.
|
|
83
83
|
* Uses CacheModule for all storage operations.
|
|
84
84
|
*/
|
|
85
|
+
/** A `_00_window` row as read back: the id-set and whether the server vouched
|
|
86
|
+
* for it (which is what allows an empty set to count as known membership). */
|
|
87
|
+
export interface DurableMembership {
|
|
88
|
+
ids: RecordVersionArray;
|
|
89
|
+
confirmed: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
export class DataModule<S extends SchemaStructure> {
|
|
86
93
|
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
87
94
|
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
@@ -1161,32 +1168,54 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1161
1168
|
//
|
|
1162
1169
|
// `_00_window` fixes that: same data, keyed by a session-independent hash, in
|
|
1163
1170
|
// a table nothing wipes. Mirrors the durable `_00_preload` marker above.
|
|
1171
|
+
//
|
|
1172
|
+
// Row shape: `{ ids, confirmed, updatedAt }`. `confirmed` is the one bit that
|
|
1173
|
+
// lets an EMPTY row be trusted on the next boot (see `getWindowMembership`).
|
|
1164
1174
|
|
|
1165
1175
|
/**
|
|
1166
1176
|
* Read the durable membership row, or `null` if this query has never had
|
|
1167
1177
|
* authoritative membership on this device. Any read error is treated as
|
|
1168
1178
|
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
1169
1179
|
* rendering an empty list.
|
|
1180
|
+
*
|
|
1181
|
+
* `confirmed` is true only for rows written after the server itself vouched
|
|
1182
|
+
* for the set (a non-empty id-set, or an empty one it reported a row count of
|
|
1183
|
+
* zero for, or an empty one that followed a non-empty one in the same
|
|
1184
|
+
* session). Rows written before the marker existed, including the `[]` rows a
|
|
1185
|
+
* pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
|
|
1170
1186
|
*/
|
|
1171
|
-
async getWindowMembership(key: string): Promise<
|
|
1187
|
+
async getWindowMembership(key: string): Promise<DurableMembership | null> {
|
|
1172
1188
|
try {
|
|
1173
1189
|
const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
|
|
1174
1190
|
if (!row || typeof row !== 'object') return null;
|
|
1175
1191
|
const ids = (row as any).ids;
|
|
1176
|
-
|
|
1192
|
+
if (!Array.isArray(ids)) return null;
|
|
1193
|
+
return { ids: ids as RecordVersionArray, confirmed: (row as any).confirmed === true };
|
|
1177
1194
|
} catch {
|
|
1178
1195
|
return null;
|
|
1179
1196
|
}
|
|
1180
1197
|
}
|
|
1181
1198
|
|
|
1182
|
-
/**
|
|
1183
|
-
*
|
|
1184
|
-
|
|
1199
|
+
/**
|
|
1200
|
+
* Persist the durable membership row. Best-effort: callers must not fail a
|
|
1201
|
+
* sync round because the mirror write failed.
|
|
1202
|
+
*
|
|
1203
|
+
* `confirmed` says whether a cold start may trust this row even when it is
|
|
1204
|
+
* empty. A confirmed empty is a real answer ("the server says this query has
|
|
1205
|
+
* no rows") and stays empty across a reload; an unconfirmed empty is the
|
|
1206
|
+
* retry budget's guess and falls back to the predicate scan on the next boot,
|
|
1207
|
+
* exactly as every empty row did before the marker existed.
|
|
1208
|
+
*/
|
|
1209
|
+
async writeWindowMembership(
|
|
1210
|
+
key: string,
|
|
1211
|
+
ids: RecordVersionArray,
|
|
1212
|
+
confirmed: boolean
|
|
1213
|
+
): Promise<void> {
|
|
1185
1214
|
try {
|
|
1186
1215
|
await this.local.upsert(
|
|
1187
1216
|
'_00_window',
|
|
1188
1217
|
new RecordId('_00_window', key),
|
|
1189
|
-
{ ids, updatedAt: Date.now() },
|
|
1218
|
+
{ ids, confirmed, updatedAt: Date.now() },
|
|
1190
1219
|
'replace'
|
|
1191
1220
|
);
|
|
1192
1221
|
} catch (err) {
|
|
@@ -1389,9 +1418,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1389
1418
|
// registration, well inside the flush window for a real collection, so
|
|
1390
1419
|
// "believe it the second time" blanked exactly the lists this guard exists
|
|
1391
1420
|
// to protect — reported as rows vanishing ~2s after a page load.
|
|
1421
|
+
// Whether this write may be trusted by the NEXT session. Non-empty sets
|
|
1422
|
+
// always; an empty set only when the server stood behind it (a zero row
|
|
1423
|
+
// count, or a real set was seen this session and it is now gone). The
|
|
1424
|
+
// retry-budget path below accepts an empty without that backing and must
|
|
1425
|
+
// stay non-durable, or the poisoned-device self-heal is lost.
|
|
1426
|
+
let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
|
|
1392
1427
|
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
1393
1428
|
const serverRowCount = opts?.serverRowCount;
|
|
1394
1429
|
const knownEmpty = serverRowCount === 0;
|
|
1430
|
+
confirmed = knownEmpty;
|
|
1395
1431
|
if (!knownEmpty) {
|
|
1396
1432
|
// Unknown row count still gets a bounded escape hatch, so a server that
|
|
1397
1433
|
// cannot report one never strands a device on a durable seed forever.
|
|
@@ -1429,7 +1465,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1429
1465
|
queryState.config.emptyReads = 0;
|
|
1430
1466
|
}
|
|
1431
1467
|
if (queryState.config.membershipKey) {
|
|
1432
|
-
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
|
|
1468
|
+
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
|
|
1433
1469
|
}
|
|
1434
1470
|
try {
|
|
1435
1471
|
await this.local.query(
|
|
@@ -1499,10 +1535,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
1499
1535
|
config.emptyReads = 0;
|
|
1500
1536
|
if (config.membershipKey) {
|
|
1501
1537
|
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
if (durable
|
|
1505
|
-
config.remoteArray = durable;
|
|
1538
|
+
// Same rule as the cold-start read: a non-empty row, or an empty one
|
|
1539
|
+
// the server confirmed, is membership; an unmarked empty row is not.
|
|
1540
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
1541
|
+
config.remoteArray = durable.ids;
|
|
1506
1542
|
config.membershipKnown = true;
|
|
1507
1543
|
}
|
|
1508
1544
|
}
|
|
@@ -2193,12 +2229,16 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
2193
2229
|
// removed row reappearing after a reload, and it works with no network.
|
|
2194
2230
|
if (membershipKey && !config.remoteArray?.length) {
|
|
2195
2231
|
const durable = await this.getWindowMembership(membershipKey);
|
|
2196
|
-
//
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2200
|
-
|
|
2201
|
-
|
|
2232
|
+
// An empty durable row is trusted only when it carries the `confirmed`
|
|
2233
|
+
// marker, i.e. the server itself reported the query empty. Without it an
|
|
2234
|
+
// empty row cannot be told apart from one written before this device ever
|
|
2235
|
+
// saw a real id-set (or by the pre-`ea56f50e` client that mirrored
|
|
2236
|
+
// unflushed reads), and treating it as known would paint an empty list
|
|
2237
|
+
// with no scan fallback. A confirmed empty is the opposite case: the
|
|
2238
|
+
// server said "no rows", so a reload must stay empty rather than re-admit
|
|
2239
|
+
// every cached body until the next poll blanks it again.
|
|
2240
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
2241
|
+
config.remoteArray = durable.ids;
|
|
2202
2242
|
config.membershipKnown = true;
|
|
2203
2243
|
}
|
|
2204
2244
|
} else if (config.remoteArray?.length) {
|
|
@@ -208,6 +208,12 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
208
208
|
data: q.records,
|
|
209
209
|
localArray: q.config.localArray,
|
|
210
210
|
remoteArray: q.config.remoteArray,
|
|
211
|
+
// Membership state, so "why is this list empty" is answerable from
|
|
212
|
+
// the panel: is the server's set known, has a non-empty one been seen
|
|
213
|
+
// this session, how many empty reads were ignored.
|
|
214
|
+
membershipKnown: q.config.membershipKnown === true,
|
|
215
|
+
remoteSeen: q.config.remoteSeen === true,
|
|
216
|
+
emptyReads: q.config.emptyReads ?? 0,
|
|
211
217
|
// Detailed per-phase processing-time breakdown (SSP sub-phases, local/
|
|
212
218
|
// remote record fetch, frontend reconcile, registration). Flows to both
|
|
213
219
|
// the DevTools panel and the MCP (which returns activeQueries verbatim).
|
|
@@ -327,7 +333,9 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
327
333
|
const info = this.unwrapInfo(res);
|
|
328
334
|
this.localTablesAt = Date.now();
|
|
329
335
|
if (info && info.tables) {
|
|
330
|
-
|
|
336
|
+
// The circuit snapshot table holds a BLOB, not JSON rows; the
|
|
337
|
+
// explorer cannot render it and has nothing to show for it.
|
|
338
|
+
const names = Object.keys(info.tables).filter((n) => n !== '_00_circuit_snapshot');
|
|
331
339
|
const changed =
|
|
332
340
|
names.length !== this.localTables.length ||
|
|
333
341
|
names.some((n, i) => n !== this.localTables[i]);
|