@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.
@@ -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
- if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
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 (store + view caches) as a snapshot so a
542
- * reload can restore it instead of re-materializing. Default `false`, and
543
- * that default is deliberate.
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
- * The circuit is DERIVED state: the durable local store (OPFS SQLite) is the
546
- * source of truth, and every first paint already reads row bodies from it
547
- * (`DataManager.createNewQuery` / `materializeRecords`) using the circuit only
548
- * for row identity and ordering. A snapshot buys nothing on reload while
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
- * When enabled, snapshots are written on a checkpoint interval
554
- * ({@link circuitCheckpointMs}) and on `pagehide`, never per ingest or per
555
- * query registration. Enable only for a workload that has measured a win.
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.204",
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.204",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.204",
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
- this.versionLookups[recordId] = record.version;
193
+ versionOf.set(recordId, record.version);
180
194
  return { table: record.table, op: record.op, id: recordId, record: record.record };
181
195
  });
182
- this.streamProcessor.ingestMany(bulk);
183
- this.ingestRelay?.(bulk as CacheIngestTuple[]);
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.ingest(table, 'DELETE', id, recordData);
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<RecordVersionArray | null> {
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
- return Array.isArray(ids) ? (ids as RecordVersionArray) : null;
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
- /** Persist the durable membership row. Best-effort: callers must not fail a
1183
- * sync round because the mirror write failed. */
1184
- async writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void> {
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
- // Length-checked for the same reason as the cold-start read above: an
1503
- // empty durable row is indistinguishable from "never had membership".
1504
- if (durable?.length) {
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
- // `durable.length` on purpose: an empty durable row cannot be told apart
2197
- // from one written before this device ever saw a real id-set, and treating
2198
- // it as known means the first paint is empty with no scan fallback. It
2199
- // also self-heals devices poisoned by the pre-fix `writeWindowMembership`.
2200
- if (durable?.length) {
2201
- config.remoteArray = durable;
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
- const names = Object.keys(info.tables);
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]);