@spooky-sync/core 0.0.1-canary.205 → 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 CHANGED
@@ -606,6 +606,12 @@ declare class CacheModule implements StreamUpdateReceiver {
606
606
  * Merges the functionality of QueryManager and MutationManager.
607
607
  * Uses CacheModule for all storage operations.
608
608
  */
609
+ /** A `_00_window` row as read back: the id-set and whether the server vouched
610
+ * for it (which is what allows an empty set to count as known membership). */
611
+ interface DurableMembership {
612
+ ids: RecordVersionArray;
613
+ confirmed: boolean;
614
+ }
609
615
  declare class DataModule<S extends SchemaStructure> {
610
616
  private cache;
611
617
  private local;
@@ -901,11 +907,25 @@ declare class DataModule<S extends SchemaStructure> {
901
907
  * authoritative membership on this device. Any read error is treated as
902
908
  * "unknown" so a broken row degrades to the predicate scan rather than
903
909
  * rendering an empty list.
910
+ *
911
+ * `confirmed` is true only for rows written after the server itself vouched
912
+ * for the set (a non-empty id-set, or an empty one it reported a row count of
913
+ * zero for, or an empty one that followed a non-empty one in the same
914
+ * session). Rows written before the marker existed, including the `[]` rows a
915
+ * pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
916
+ */
917
+ getWindowMembership(key: string): Promise<DurableMembership | null>;
918
+ /**
919
+ * Persist the durable membership row. Best-effort: callers must not fail a
920
+ * sync round because the mirror write failed.
921
+ *
922
+ * `confirmed` says whether a cold start may trust this row even when it is
923
+ * empty. A confirmed empty is a real answer ("the server says this query has
924
+ * no rows") and stays empty across a reload; an unconfirmed empty is the
925
+ * retry budget's guess and falls back to the predicate scan on the next boot,
926
+ * exactly as every empty row did before the marker existed.
904
927
  */
905
- getWindowMembership(key: string): Promise<RecordVersionArray | null>;
906
- /** Persist the durable membership row. Best-effort: callers must not fail a
907
- * sync round because the mirror write failed. */
908
- writeWindowMembership(key: string, ids: RecordVersionArray): Promise<void>;
928
+ writeWindowMembership(key: string, ids: RecordVersionArray, confirmed: boolean): Promise<void>;
909
929
  /**
910
930
  * Record ids with a mutation still in the outbox, split by direction.
911
931
  *
package/dist/index.js CHANGED
@@ -3321,12 +3321,6 @@ function phaseStatOf(samples, lastMs) {
3321
3321
  count: samples.length
3322
3322
  };
3323
3323
  }
3324
- /**
3325
- * DataModule - Unified query and mutation management
3326
- *
3327
- * Merges the functionality of QueryManager and MutationManager.
3328
- * Uses CacheModule for all storage operations.
3329
- */
3330
3324
  var DataModule = class DataModule {
3331
3325
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
3332
3326
  * undefined in solo mode, where mutation-id falls back to a session id. */
@@ -4030,23 +4024,42 @@ var DataModule = class DataModule {
4030
4024
  * authoritative membership on this device. Any read error is treated as
4031
4025
  * "unknown" so a broken row degrades to the predicate scan rather than
4032
4026
  * rendering an empty list.
4027
+ *
4028
+ * `confirmed` is true only for rows written after the server itself vouched
4029
+ * for the set (a non-empty id-set, or an empty one it reported a row count of
4030
+ * zero for, or an empty one that followed a non-empty one in the same
4031
+ * session). Rows written before the marker existed, including the `[]` rows a
4032
+ * pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
4033
4033
  */
4034
4034
  async getWindowMembership(key) {
4035
4035
  try {
4036
4036
  const row = await this.local.getById("_00_window", new RecordId("_00_window", key));
4037
4037
  if (!row || typeof row !== "object") return null;
4038
4038
  const ids = row.ids;
4039
- return Array.isArray(ids) ? ids : null;
4039
+ if (!Array.isArray(ids)) return null;
4040
+ return {
4041
+ ids,
4042
+ confirmed: row.confirmed === true
4043
+ };
4040
4044
  } catch {
4041
4045
  return null;
4042
4046
  }
4043
4047
  }
4044
- /** Persist the durable membership row. Best-effort: callers must not fail a
4045
- * sync round because the mirror write failed. */
4046
- async writeWindowMembership(key, ids) {
4048
+ /**
4049
+ * Persist the durable membership row. Best-effort: callers must not fail a
4050
+ * sync round because the mirror write failed.
4051
+ *
4052
+ * `confirmed` says whether a cold start may trust this row even when it is
4053
+ * empty. A confirmed empty is a real answer ("the server says this query has
4054
+ * no rows") and stays empty across a reload; an unconfirmed empty is the
4055
+ * retry budget's guess and falls back to the predicate scan on the next boot,
4056
+ * exactly as every empty row did before the marker existed.
4057
+ */
4058
+ async writeWindowMembership(key, ids, confirmed) {
4047
4059
  try {
4048
4060
  await this.local.upsert("_00_window", new RecordId("_00_window", key), {
4049
4061
  ids,
4062
+ confirmed,
4050
4063
  updatedAt: Date.now()
4051
4064
  }, "replace");
4052
4065
  } catch (err) {
@@ -4205,9 +4218,12 @@ var DataModule = class DataModule {
4205
4218
  }, "Query to update remote array not found");
4206
4219
  return;
4207
4220
  }
4221
+ let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
4208
4222
  if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
4209
4223
  const serverRowCount = opts?.serverRowCount;
4210
- if (!(serverRowCount === 0)) {
4224
+ const knownEmpty = serverRowCount === 0;
4225
+ confirmed = knownEmpty;
4226
+ if (!knownEmpty) {
4211
4227
  const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
4212
4228
  queryState.config.emptyReads = emptyReads;
4213
4229
  if (!(serverRowCount === null || serverRowCount === void 0 ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS : false)) {
@@ -4228,7 +4244,7 @@ var DataModule = class DataModule {
4228
4244
  queryState.config.remoteSeen = true;
4229
4245
  queryState.config.emptyReads = 0;
4230
4246
  }
4231
- if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray);
4247
+ if (queryState.config.membershipKey) await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
4232
4248
  try {
4233
4249
  await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
4234
4250
  id: queryState.config.id,
@@ -4281,8 +4297,8 @@ var DataModule = class DataModule {
4281
4297
  config.emptyReads = 0;
4282
4298
  if (config.membershipKey) {
4283
4299
  const durable = await this.getWindowMembership(config.membershipKey);
4284
- if (durable?.length) {
4285
- config.remoteArray = durable;
4300
+ if (durable && (durable.ids.length > 0 || durable.confirmed)) {
4301
+ config.remoteArray = durable.ids;
4286
4302
  config.membershipKnown = true;
4287
4303
  }
4288
4304
  }
@@ -4712,8 +4728,8 @@ var DataModule = class DataModule {
4712
4728
  };
4713
4729
  if (membershipKey && !config.remoteArray?.length) {
4714
4730
  const durable = await this.getWindowMembership(membershipKey);
4715
- if (durable?.length) {
4716
- config.remoteArray = durable;
4731
+ if (durable && (durable.ids.length > 0 || durable.confirmed)) {
4732
+ config.remoteArray = durable.ids;
4717
4733
  config.membershipKnown = true;
4718
4734
  }
4719
4735
  } else if (config.remoteArray?.length) config.membershipKnown = true;
@@ -7337,8 +7353,8 @@ function selfAllowlistedVariant(flag, userId) {
7337
7353
 
7338
7354
  //#endregion
7339
7355
  //#region src/modules/devtools/index.ts
7340
- const CORE_VERSION = "0.0.1-canary.205";
7341
- const WASM_VERSION = "0.0.1-canary.205";
7356
+ const CORE_VERSION = "0.0.1-canary.206";
7357
+ const WASM_VERSION = "0.0.1-canary.206";
7342
7358
  const SURREAL_VERSION = "3.0.3";
7343
7359
  var DevToolsService = class DevToolsService {
7344
7360
  eventsHistory = [];
@@ -7439,6 +7455,9 @@ var DevToolsService = class DevToolsService {
7439
7455
  data: q.records,
7440
7456
  localArray: q.config.localArray,
7441
7457
  remoteArray: q.config.remoteArray,
7458
+ membershipKnown: q.config.membershipKnown === true,
7459
+ remoteSeen: q.config.remoteSeen === true,
7460
+ emptyReads: q.config.emptyReads ?? 0,
7442
7461
  timings: this.dataManager.phaseTimings(q)
7443
7462
  });
7444
7463
  });
@@ -12284,7 +12303,7 @@ var Sp00kyClient = class {
12284
12303
  return new TabsCoordinator({
12285
12304
  tabId,
12286
12305
  fingerprint: computeTabsFingerprint({
12287
- coreVersion: "0.0.1-canary.205",
12306
+ coreVersion: "0.0.1-canary.206",
12288
12307
  schemaHash: hash53(this.config.schemaSurql),
12289
12308
  endpoint: this.config.database.endpoint ?? "",
12290
12309
  namespace: this.config.database.namespace,
package/dist/types.d.ts CHANGED
@@ -874,6 +874,11 @@ interface QueryConfig {
874
874
  * "never established" has to fall back to a predicate scan of the local store
875
875
  * so a query first run on this device still paints offline. A
876
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.
877
882
  */
878
883
  membershipKnown?: boolean;
879
884
  /**
@@ -889,6 +894,9 @@ interface QueryConfig {
889
894
  * genuine transition and must be honoured, or removed rows resurrect.
890
895
  *
891
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.
892
900
  */
893
901
  remoteSeen?: boolean;
894
902
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.205",
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.205",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.205",
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",
@@ -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).
package/src/types.ts CHANGED
@@ -513,6 +513,11 @@ export interface QueryConfig {
513
513
  * "never established" has to fall back to a predicate scan of the local store
514
514
  * so a query first run on this device still paints offline. A
515
515
  * `remoteArray.length === 0` check cannot tell those apart.
516
+ *
517
+ * On a cold start it is seeded from the durable `_00_window` row when that
518
+ * row is non-empty, or empty but `confirmed` (the server reported zero rows
519
+ * for the query). An unconfirmed empty row is ignored, so a device poisoned
520
+ * by an old client that mirrored unflushed reads still self-heals.
516
521
  */
517
522
  membershipKnown?: boolean;
518
523
  /**
@@ -528,6 +533,9 @@ export interface QueryConfig {
528
533
  * genuine transition and must be honoured, or removed rows resurrect.
529
534
  *
530
535
  * In-memory only: a fresh session must re-earn the right to believe empties.
536
+ * What does persist is the `confirmed` marker on the `_00_window` row, which
537
+ * an empty set earns when it arrives with a server row count of zero or after
538
+ * a non-empty set in the same session.
531
539
  */
532
540
  remoteSeen?: boolean;
533
541
  /**