@spooky-sync/core 0.0.1-canary.237 → 0.0.1-canary.239

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.
@@ -9,6 +9,8 @@ import type {
9
9
  RecordVersionDiff,
10
10
  SyncHealth,
11
11
  SyncHealthStatus,
12
+ ServerViewMeta,
13
+ MembershipOutcome,
12
14
  } from '../../types';
13
15
  import { createSyncEventSystem, SyncEventTypes, SyncQueueEventTypes } from './events/index';
14
16
  import type { Logger } from '../../services/logger/index';
@@ -38,6 +40,60 @@ interface ListRefSnapshot {
38
40
  subquery: RecordVersionArray;
39
41
  /** `_00_query.rowCount`; `null` when the row was not readable. */
40
42
  rowCount: number | null;
43
+ /** The `_00_query` row's presence, `rowCount` and publish `state`. */
44
+ meta: ServerViewMeta;
45
+ }
46
+
47
+ /** The `{ rowCount, state }` object the row-count selects return per row. */
48
+ interface QueryMetaRow {
49
+ id?: RecordId<string>;
50
+ rowCount?: number | null;
51
+ state?: string | null;
52
+ }
53
+
54
+ /** Fold a row-count select result into {@link ServerViewMeta}. */
55
+ function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
56
+ if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
57
+ const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
58
+ return {
59
+ present: true,
60
+ rowCount: typeof row.rowCount === 'number' ? row.rowCount : null,
61
+ state,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Re-read ladder for a registration that answered while the SSP was still
67
+ * publishing (`state = 'materializing'`): the flusher window is ~100ms, so the
68
+ * first rung usually lands it; the rest cover a slow write without waiting
69
+ * for a backed-off poll tick.
70
+ */
71
+ const MATERIALIZING_REFETCH_LADDER_MS: readonly number[] = [150, 400, 1000];
72
+
73
+ /**
74
+ * How long LIVE `_00_list_ref` DELETEs for one query are held before they are
75
+ * verified upstream as a batch. A view teardown (sweep, reset, republish)
76
+ * arrives as one DELETE per edge within a few ms; a single peer delete costs
77
+ * this plus one round trip.
78
+ */
79
+ const LIVE_REMOVAL_COALESCE_MS = 100;
80
+
81
+ /**
82
+ * Collapse duplicate `(id, version)` pairs to one per id, keeping the highest
83
+ * version. `_00_list_ref` has no unique index on `(in, out)`, and a full
84
+ * republish over edges that were never cleaned up (a scheduler restart
85
+ * orphans them) used to leave two rows per record; the client must render
86
+ * each record once regardless.
87
+ */
88
+ function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
89
+ if (pairs.length < 2) return pairs;
90
+ const best = new Map<string, number>();
91
+ for (const [id, version] of pairs) {
92
+ const prev = best.get(id);
93
+ if (prev === undefined || version > prev) best.set(id, version);
94
+ }
95
+ if (best.size === pairs.length) return pairs;
96
+ return Array.from(best.entries());
41
97
  }
42
98
 
43
99
  interface ListRefEdgeRow {
@@ -788,6 +844,8 @@ export class Sp00kySync<S extends SchemaStructure> {
788
844
  public async prepareBucketSwitch(): Promise<void> {
789
845
  this.stopSelfHeal();
790
846
  this.stopListRefPoll();
847
+ this.clearListRefRefetches();
848
+ this.clearLiveRemovals();
791
849
  if (this.listRefPollInFlight) await this.listRefPollInFlight;
792
850
  await this.killRefLiveQuery();
793
851
  this.upQueue.clearDebounceTimers();
@@ -1011,7 +1069,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1011
1069
  if (!snapshot) continue;
1012
1070
  this.listRefPolledAt.set(hash, now);
1013
1071
  try {
1014
- if (await this.applyListRefSnapshot(hash, snapshot)) anyChanged = true;
1072
+ if ((await this.applyListRefSnapshot(hash, snapshot)).changed) anyChanged = true;
1015
1073
  } catch (err) {
1016
1074
  this.logger.debug(
1017
1075
  {
@@ -1069,11 +1127,16 @@ export class Sp00kySync<S extends SchemaStructure> {
1069
1127
  return out;
1070
1128
  }
1071
1129
  const [edges, counts] = await this.remote.query<
1072
- [ListRefEdgeRow[] | null, ({ id?: RecordId<string>; rowCount?: number | null } | null)[] | null]
1130
+ [ListRefEdgeRow[] | null, (QueryMetaRow | null)[] | null]
1073
1131
  >(`${buildListRefBatchSelect(listRefTbl)};\n${buildQueryRowCountBatchSelect()}`, { ins: ids });
1074
1132
  if (!Array.isArray(edges)) return out;
1075
1133
  for (const hash of hashById.values()) {
1076
- out.set(hash, { primary: [], subquery: [], rowCount: null });
1134
+ out.set(hash, {
1135
+ primary: [],
1136
+ subquery: [],
1137
+ rowCount: null,
1138
+ meta: { present: false, rowCount: null, state: null },
1139
+ });
1077
1140
  }
1078
1141
  for (const row of edges) {
1079
1142
  const hash = hashById.get(encodeRecordId(row.in));
@@ -1083,22 +1146,35 @@ export class Sp00kySync<S extends SchemaStructure> {
1083
1146
  if (row.parent == null) snapshot.primary.push(pair);
1084
1147
  else snapshot.subquery.push(pair);
1085
1148
  }
1149
+ for (const snapshot of out.values()) {
1150
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
1151
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
1152
+ }
1086
1153
  for (const count of Array.isArray(counts) ? counts : []) {
1087
1154
  if (!count || !count.id) continue;
1088
1155
  const hash = hashById.get(encodeRecordId(count.id));
1089
1156
  if (!hash) continue;
1090
- out.get(hash)!.rowCount = typeof count.rowCount === 'number' ? count.rowCount : null;
1157
+ const snapshot = out.get(hash)!;
1158
+ snapshot.meta = metaFromRow(count);
1159
+ snapshot.rowCount = snapshot.meta.rowCount;
1091
1160
  }
1092
- const suspect =
1093
- edges.length === 0 &&
1094
- Array.from(out.entries()).some(([hash, snapshot]) => {
1095
- const held = this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0;
1096
- return held > 0 && (snapshot.rowCount ?? 0) > 0;
1097
- });
1161
+ // Two shapes of a batch that must not be believed at face value: no edge
1162
+ // at all while the server still reports rows for a query we hold (a
1163
+ // silently empty batch), and a query we hold whose `_00_query` row did
1164
+ // not come back (a lost view, or a batch that missed it). Both are
1165
+ // re-read one query at a time before anything is applied, because an
1166
+ // empty set taken at face value is the removal of every row, and a
1167
+ // missing row taken at face value would re-register a healthy view.
1168
+ const suspect = Array.from(out.entries()).some(([hash, snapshot]) => {
1169
+ const held = this.dataModule.getQueryByHash(hash)?.config.remoteArray?.length ?? 0;
1170
+ if (held === 0) return false;
1171
+ if (!snapshot.meta.present) return true;
1172
+ return edges.length === 0 && (snapshot.rowCount ?? 0) > 0;
1173
+ });
1098
1174
  if (suspect) {
1099
1175
  this.logger.warn(
1100
1176
  { hashes, Category: 'sp00ky-client::Sp00kySync::fetchListRefSnapshots' },
1101
- 'Batched list_ref select returned no edges for queries that hold rows; re-reading one query at a time'
1177
+ 'Batched list_ref select is not trusted for queries that hold rows (no edges, or a missing _00_query row); re-reading one query at a time'
1102
1178
  );
1103
1179
  for (const hash of Array.from(out.keys())) {
1104
1180
  const queryState = this.dataModule.getQueryByHash(hash);
@@ -1121,10 +1197,10 @@ export class Sp00kySync<S extends SchemaStructure> {
1121
1197
  */
1122
1198
  private async fetchListRefSnapshot(id: RecordId<string>): Promise<ListRefSnapshot | null> {
1123
1199
  const listRefTbl = this.listRefTable();
1124
- const [items, serverRowCount, children] = await this.remote.query<
1200
+ const [items, metaRow, children] = await this.remote.query<
1125
1201
  [
1126
1202
  { out: RecordId<string>; version: number }[] | null,
1127
- number | null,
1203
+ QueryMetaRow | null,
1128
1204
  { out: RecordId<string>; version: number }[] | null,
1129
1205
  ]
1130
1206
  >(
@@ -1133,11 +1209,15 @@ export class Sp00kySync<S extends SchemaStructure> {
1133
1209
  );
1134
1210
  if (!Array.isArray(items)) return null;
1135
1211
  const toPairs = (rows: { out: RecordId<string>; version: number }[] | null): RecordVersionArray =>
1136
- Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : [];
1212
+ dedupeRecordVersions(
1213
+ Array.isArray(rows) ? rows.map((item) => [encodeRecordId(item.out), item.version]) : []
1214
+ );
1215
+ const meta = metaFromRow(metaRow);
1137
1216
  return {
1138
1217
  primary: toPairs(items),
1139
1218
  subquery: toPairs(children),
1140
- rowCount: typeof serverRowCount === 'number' ? serverRowCount : null,
1219
+ rowCount: meta.rowCount,
1220
+ meta,
1141
1221
  };
1142
1222
  }
1143
1223
 
@@ -1153,26 +1233,24 @@ export class Sp00kySync<S extends SchemaStructure> {
1153
1233
  private async applyListRefSnapshot(
1154
1234
  queryHash: string,
1155
1235
  snapshot: ListRefSnapshot
1156
- ): Promise<boolean> {
1236
+ ): Promise<{ changed: boolean; outcome: MembershipOutcome | 'unchanged' }> {
1157
1237
  const queryState = this.dataModule.getQueryByHash(queryHash);
1158
- if (!queryState) return false;
1238
+ if (!queryState) return { changed: false, outcome: 'unchanged' };
1159
1239
  const fresh = snapshot.primary;
1160
- const serverRowCount = snapshot.rowCount;
1161
1240
  // Capture which ids LEFT the query's window (present in the cached
1162
1241
  // remoteArray, absent from `fresh`) BEFORE we overwrite remoteArray — these
1163
1242
  // are cross-window deletes (or rows that scrolled out). They drive the
1164
- // forced re-render below.
1243
+ // forced re-render below. Only meaningful once the set is actually applied.
1165
1244
  const prevRemote = queryState.config.remoteArray ?? [];
1166
- const freshIds = new Set(fresh.map(([id]) => id));
1167
- const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
1168
1245
  // Idempotent poll: only persist the remoteArray when it actually changed.
1169
1246
  // The poll runs continuously as a LIVE fallback, so on a quiet page `fresh`
1170
1247
  // equals the cached array every tick — re-writing it (an `UPDATE _00_query`
1171
1248
  // each cycle, per active query) was pure churn and the bulk of the idle
1172
1249
  // traffic. `recordVersionArraysEqual` is order-insensitive because the
1173
1250
  // list_ref SELECT has no `ORDER BY`.
1174
- const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
1175
- if (changed) {
1251
+ const differs = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
1252
+ let outcome: MembershipOutcome | 'unchanged' = 'unchanged';
1253
+ if (differs) {
1176
1254
  // Update the cached remoteArray so the next diff/sync sees the new state.
1177
1255
  // `syncQuery` (below) then writes through `cache.saveBatch`, which UPSERTs
1178
1256
  // the local DB row and ingests it into the in-browser SSP — the SSP's
@@ -1180,14 +1258,27 @@ export class Sp00kySync<S extends SchemaStructure> {
1180
1258
  // and notifies subscribers. We skip an explicit `notifyQuerySynced`
1181
1259
  // because that path races the stream-update path (can notify with stale
1182
1260
  // records).
1183
- await this.dataModule.updateQueryRemoteArray(queryHash, fresh, { serverRowCount });
1261
+ //
1262
+ // The DataModule arbitrates an EMPTY set against the `_00_query` row it
1263
+ // was read with (`snapshot.meta`): a missing row is a lost view (kept +
1264
+ // re-registered), a `materializing` row is a publish in flight, and only
1265
+ // a `ready` row with `rowCount 0` empties the query.
1266
+ outcome = await this.dataModule.updateQueryRemoteArray(queryHash, fresh, {
1267
+ meta: snapshot.meta,
1268
+ });
1184
1269
  }
1270
+ const applied = differs && outcome === 'applied';
1271
+ const freshIds = new Set(fresh.map(([id]) => id));
1272
+ const removedIds = applied
1273
+ ? prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id)
1274
+ : [];
1185
1275
  // Run `syncQuery` every tick regardless: it's a no-op when localArray has
1186
1276
  // caught up to remoteArray (`if (!diff) return`, issues no query), but it
1187
1277
  // covers the rare case where remoteArray is stable yet localArray is behind
1188
1278
  // (a prior record fetch failed) — so a missed row still gets retried.
1189
1279
  // For REMOVALS it runs the ids through `handleRemovedRecords`, which deletes
1190
- // confirmed-gone records from the local DB.
1280
+ // confirmed-gone records from the local DB. A rejected snapshot left
1281
+ // `remoteArray` alone, so this cannot act on it.
1191
1282
  try {
1192
1283
  await this.syncQuery(queryHash);
1193
1284
  } catch (err) {
@@ -1204,20 +1295,23 @@ export class Sp00kySync<S extends SchemaStructure> {
1204
1295
  // it, so the engine already holds it at the published version): nothing
1205
1296
  // then re-materializes the query. Ask for one; it is a no-op behind a
1206
1297
  // stream update that a fetch above already queued.
1207
- if (changed) this.dataModule.scheduleRematerialize(queryHash);
1298
+ if (applied) this.dataModule.scheduleRematerialize(queryHash);
1208
1299
  // Cross-session fallback for `.related()` child rows: the LIVE-permission
1209
1300
  // gap can drop child-edge notifications, so converge their bodies on the
1210
- // poll too (idempotent — no-op when nothing changed).
1211
- await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
1212
- this.logger.info(
1213
- {
1214
- err: (err as Error)?.message ?? err,
1215
- queryHash,
1216
- Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
1217
- },
1218
- 'Subquery child sync failed during poll'
1219
- );
1220
- });
1301
+ // poll too (idempotent — no-op when nothing changed). Not for a snapshot
1302
+ // that was rejected: its child set is as untrustworthy as its primary set.
1303
+ if (outcome === 'applied' || outcome === 'unchanged') {
1304
+ await this.applySubqueryChildren(queryHash, snapshot.subquery).catch((err) => {
1305
+ this.logger.info(
1306
+ {
1307
+ err: (err as Error)?.message ?? err,
1308
+ queryHash,
1309
+ Category: 'sp00ky-client::Sp00kySync::applyListRefSnapshot',
1310
+ },
1311
+ 'Subquery child sync failed during poll'
1312
+ );
1313
+ });
1314
+ }
1221
1315
  // A REMOVAL needs no record fetch, so unlike the added-row path it doesn't
1222
1316
  // get a re-render from the SSP stream on this code path reliably (and the
1223
1317
  // non-windowed window-0 query re-queries the local DB rather than the id-set).
@@ -1237,7 +1331,76 @@ export class Sp00kySync<S extends SchemaStructure> {
1237
1331
  );
1238
1332
  }
1239
1333
  }
1240
- return changed;
1334
+ return { changed: applied, outcome };
1335
+ }
1336
+
1337
+ /**
1338
+ * Re-read ONE query's server state now (edges + `_00_query` row) and land
1339
+ * it through {@link applyListRefSnapshot}. The targeted form of the poll,
1340
+ * for the moments that cannot wait for the next tick: a registration that
1341
+ * answered `materializing`, and a LIVE removal that has to be checked
1342
+ * against the row before it may empty a painted list.
1343
+ */
1344
+ private async refetchListRefForQuery(
1345
+ queryHash: string
1346
+ ): Promise<{ changed: boolean; outcome: MembershipOutcome | 'unchanged' } | null> {
1347
+ const queryState = this.dataModule.getQueryByHash(queryHash);
1348
+ if (!queryState) return null;
1349
+ const snapshot = await this.fetchListRefSnapshot(queryState.config.id);
1350
+ if (!snapshot) return null;
1351
+ return this.applyListRefSnapshot(queryHash, snapshot);
1352
+ }
1353
+
1354
+ /** Per-query pending targeted re-reads: one timer and the ladder still to walk. */
1355
+ private listRefRefetches = new Map<string, { timer: ReturnType<typeof setTimeout>; ladder: number[] }>();
1356
+
1357
+ /**
1358
+ * Schedule a targeted re-read of `queryHash` after `delays[0]` ms. If the
1359
+ * read still says the server is publishing (`materializing`), the next rung
1360
+ * of the ladder is armed; any other outcome ends it. Re-scheduling a hash
1361
+ * that already has a timer replaces it (latest wins), so bursts coalesce.
1362
+ */
1363
+ private scheduleListRefRefetch(queryHash: string, delays: number | readonly number[]): void {
1364
+ const ladder = typeof delays === 'number' ? [delays] : [...delays];
1365
+ if (ladder.length === 0) return;
1366
+ this.clearListRefRefetch(queryHash);
1367
+ const [first, ...rest] = ladder;
1368
+ const timer = setTimeout(() => {
1369
+ this.listRefRefetches.delete(queryHash);
1370
+ void this.refetchListRefForQuery(queryHash)
1371
+ .then((result) => {
1372
+ const queryState = this.dataModule.getQueryByHash(queryHash);
1373
+ const stillMaterializing =
1374
+ result?.outcome === 'ignored' && queryState?.serverState === 'materializing';
1375
+ if (stillMaterializing && rest.length > 0) {
1376
+ this.scheduleListRefRefetch(queryHash, rest);
1377
+ }
1378
+ })
1379
+ .catch((err) => {
1380
+ this.logger.info(
1381
+ {
1382
+ err: (err as Error)?.message ?? err,
1383
+ queryHash,
1384
+ Category: 'sp00ky-client::Sp00kySync::scheduleListRefRefetch',
1385
+ },
1386
+ 'Targeted list_ref re-read failed; the poll will retry'
1387
+ );
1388
+ });
1389
+ }, first);
1390
+ this.listRefRefetches.set(queryHash, { timer, ladder: rest });
1391
+ }
1392
+
1393
+ private clearListRefRefetch(queryHash: string): void {
1394
+ const pending = this.listRefRefetches.get(queryHash);
1395
+ if (pending) {
1396
+ clearTimeout(pending.timer);
1397
+ this.listRefRefetches.delete(queryHash);
1398
+ }
1399
+ }
1400
+
1401
+ private clearListRefRefetches(): void {
1402
+ for (const { timer } of this.listRefRefetches.values()) clearTimeout(timer);
1403
+ this.listRefRefetches.clear();
1241
1404
  }
1242
1405
 
1243
1406
  /**
@@ -1580,11 +1743,25 @@ export class Sp00kySync<S extends SchemaStructure> {
1580
1743
  // relayed from another tab). The fetch is rightly skipped then, but the
1581
1744
  // membership still has to be recorded, or the row lives on the
1582
1745
  // settled-write grace alone until the poll catches it.
1746
+ // A DELETE is not applied on arrival. Every teardown of a view - the TTL
1747
+ // sweep, an SSP reset, a scheduler wipe, a full republish - reaches the
1748
+ // client as one DELETE per edge, indistinguishable from a peer deleting
1749
+ // rows, and obeying them blanked painted lists n-1 rows at a time. Removals
1750
+ // are coalesced briefly and VERIFIED upstream before membership moves: an
1751
+ // id that no longer exists is dropped (and its body deleted); anything
1752
+ // else is settled by re-reading the server's whole set for the query, where
1753
+ // a missing `_00_query` row means "view lost, keep everything".
1754
+ if (action === 'DELETE') {
1755
+ this.queueLiveRemoval(hash, recordId);
1756
+ return;
1757
+ }
1758
+
1583
1759
  if (existing.config.membershipKnown) {
1584
- const membershipDiff: RecordVersionDiff =
1585
- action === 'DELETE'
1586
- ? { added: [], updated: [], removed: [recordId] }
1587
- : { added: [{ id: recordId, version }], updated: [], removed: [] };
1760
+ const membershipDiff: RecordVersionDiff = {
1761
+ added: [{ id: recordId, version }],
1762
+ updated: [],
1763
+ removed: [],
1764
+ };
1588
1765
  const next = applyRecordVersionDiff(existing.config.remoteArray ?? [], membershipDiff);
1589
1766
  if (!recordVersionArraysEqual(next, existing.config.remoteArray ?? [])) {
1590
1767
  await this.dataModule.updateQueryRemoteArray(hash, next);
@@ -1593,13 +1770,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1593
1770
 
1594
1771
  await this.runSyncForQuery(hash, diff);
1595
1772
 
1596
- // A removal-only diff sets `fetching` false in `runSyncForQuery`, so it gets
1597
- // no `flushPendingStreamUpdate`/`endFetching` re-render — and a removal needs
1598
- // no record fetch to trigger one either. Force it, mirroring what the poll
1599
- // path already does for its own removals (`refetchListRefForQuery`).
1600
- if (diff.removed.length > 0 && diff.added.length === 0 && diff.updated.length === 0) {
1601
- await this.dataModule.notifyQuerySynced(hash);
1602
- } else if (diff.added.length === 0 && diff.updated.length === 0) {
1773
+ if (diff.added.length === 0 && diff.updated.length === 0) {
1603
1774
  // Empty diff: the circuit already holds the row at this version (this
1604
1775
  // tab wrote it), so no fetch and no stream update - but the membership
1605
1776
  // recorded above is new, and the subscribers have to hear about it.
@@ -1607,6 +1778,79 @@ export class Sp00kySync<S extends SchemaStructure> {
1607
1778
  }
1608
1779
  }
1609
1780
 
1781
+ /** LIVE removals awaiting verification, per query, with their coalescing timer. */
1782
+ private pendingLiveRemovals = new Map<string, { ids: Map<string, RecordId>; timer: ReturnType<typeof setTimeout> }>();
1783
+
1784
+ /**
1785
+ * Hold a LIVE `_00_list_ref` DELETE for {@link LIVE_REMOVAL_COALESCE_MS},
1786
+ * then verify the batch (see {@link flushLiveRemovals}). A sweep or a
1787
+ * republish delivers a view's whole set as one burst, so one verification
1788
+ * round covers it.
1789
+ */
1790
+ private queueLiveRemoval(hash: string, recordId: RecordId): void {
1791
+ const key = encodeRecordId(recordId);
1792
+ const pending = this.pendingLiveRemovals.get(hash);
1793
+ if (pending) {
1794
+ pending.ids.set(key, recordId);
1795
+ return;
1796
+ }
1797
+ const ids = new Map<string, RecordId>([[key, recordId]]);
1798
+ const timer = setTimeout(() => {
1799
+ this.pendingLiveRemovals.delete(hash);
1800
+ void this.flushLiveRemovals(hash, Array.from(ids.values())).catch((err) => {
1801
+ this.logger.info(
1802
+ {
1803
+ err: (err as Error)?.message ?? err,
1804
+ hash,
1805
+ Category: 'sp00ky-client::Sp00kySync::flushLiveRemovals',
1806
+ },
1807
+ 'LIVE removal verification failed; the poll will converge'
1808
+ );
1809
+ });
1810
+ }, LIVE_REMOVAL_COALESCE_MS);
1811
+ this.pendingLiveRemovals.set(hash, { ids, timer });
1812
+ }
1813
+
1814
+ /**
1815
+ * Verify a coalesced batch of LIVE removals against the upstream records.
1816
+ * Ids that no longer exist upstream are dropped from membership (the body
1817
+ * delete already happened inside `syncRecords`) and the query re-renders.
1818
+ * Anything still present upstream - or a verification that could not run -
1819
+ * is settled by a targeted re-read of the server's set for this query,
1820
+ * where the `_00_query` row decides: gone = view lost (keep rows,
1821
+ * re-register), `materializing` = wait, `ready` = adopt the smaller set.
1822
+ */
1823
+ private async flushLiveRemovals(hash: string, ids: RecordId[]): Promise<void> {
1824
+ const existing = this.dataModule.getQueryByHash(hash);
1825
+ if (!existing || ids.length === 0) return;
1826
+ const { confirmedRemovedIds, stillRemoteIds } = await this.runSyncForQuery(hash, {
1827
+ added: [],
1828
+ updated: [],
1829
+ removed: ids,
1830
+ });
1831
+ if (confirmedRemovedIds.length > 0 && existing.config.membershipKnown) {
1832
+ const gone = new Set(confirmedRemovedIds);
1833
+ const held = existing.config.remoteArray ?? [];
1834
+ const next = held.filter(([id]) => !gone.has(id));
1835
+ if (next.length !== held.length) {
1836
+ await this.dataModule.updateQueryRemoteArray(hash, next, { verifiedRemoval: true });
1837
+ }
1838
+ // A removal-only diff sets `fetching` false in `runSyncForQuery`, so it
1839
+ // gets no `flushPendingStreamUpdate`/`endFetching` re-render - and a
1840
+ // removal needs no record fetch to trigger one either. Force it.
1841
+ await this.dataModule.notifyQuerySynced(hash);
1842
+ }
1843
+ const unverified = confirmedRemovedIds.length + stillRemoteIds.length < ids.length;
1844
+ if (stillRemoteIds.length > 0 || unverified) {
1845
+ this.scheduleListRefRefetch(hash, 0);
1846
+ }
1847
+ }
1848
+
1849
+ private clearLiveRemovals(): void {
1850
+ for (const { timer } of this.pendingLiveRemovals.values()) clearTimeout(timer);
1851
+ this.pendingLiveRemovals.clear();
1852
+ }
1853
+
1610
1854
  /**
1611
1855
  * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
1612
1856
  * `parent` set) for a `.related()` query. Unlike primary rows, child rows
@@ -1882,7 +2126,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1882
2126
  if (!diff) {
1883
2127
  return;
1884
2128
  }
1885
- return this.runSyncForQuery(hash, diff);
2129
+ await this.runSyncForQuery(hash, diff);
1886
2130
  }
1887
2131
 
1888
2132
  /**
@@ -1892,7 +2136,11 @@ export class Sp00kySync<S extends SchemaStructure> {
1892
2136
  * never leaves a query stuck `fetching`. Part A's notification coalescing
1893
2137
  * means the single resulting UI update lands after this completes.
1894
2138
  */
1895
- private async runSyncForQuery(hash: string, diff: RecordVersionDiff): Promise<void> {
2139
+ private async runSyncForQuery(
2140
+ hash: string,
2141
+ diff: RecordVersionDiff
2142
+ ): Promise<{ confirmedRemovedIds: string[]; stillRemoteIds: string[] }> {
2143
+ const nothing = { confirmedRemovedIds: [] as string[], stillRemoteIds: [] as string[] };
1896
2144
  // The diff was computed against whatever the circuit held at call time; if
1897
2145
  // the boot prime is still filling it, wait and recompute from the primed
1898
2146
  // `localArray` so the delta is real rather than "everything".
@@ -1901,9 +2149,9 @@ export class Sp00kySync<S extends SchemaStructure> {
1901
2149
  await prime;
1902
2150
  this.settledPrime = prime;
1903
2151
  const fresh = this.dataModule.getQueryByHash(hash);
1904
- if (!fresh) return;
2152
+ if (!fresh) return nothing;
1905
2153
  const recomputed = new ArraySyncer(fresh.config.localArray, fresh.config.remoteArray).nextSet();
1906
- if (!recomputed) return;
2154
+ if (!recomputed) return nothing;
1907
2155
  diff = recomputed;
1908
2156
  }
1909
2157
  // Don't let sync re-add a record the user just deleted locally. The remote
@@ -1929,8 +2177,11 @@ export class Sp00kySync<S extends SchemaStructure> {
1929
2177
  if (fetching) {
1930
2178
  this.dataModule.beginFetching(hash);
1931
2179
  }
2180
+ let summary = nothing;
1932
2181
  try {
1933
- const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
2182
+ const { remoteFetchMs, stillRemoteIds, confirmedRemovedIds } =
2183
+ await this.syncEngine.syncRecords(diff);
2184
+ summary = { confirmedRemovedIds, stillRemoteIds };
1934
2185
  if (fetching) {
1935
2186
  this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
1936
2187
  }
@@ -1996,6 +2247,7 @@ export class Sp00kySync<S extends SchemaStructure> {
1996
2247
  this.dataModule.endFetching(hash);
1997
2248
  }
1998
2249
  }
2250
+ return summary;
1999
2251
  }
2000
2252
 
2001
2253
  /**
@@ -2144,11 +2396,12 @@ export class Sp00kySync<S extends SchemaStructure> {
2144
2396
  // `rowCount` rides along: it is written by the SSP in the same statement
2145
2397
  // that registers the view, BEFORE the edges are flushed, so it is the only
2146
2398
  // way to tell "this query is empty" from "its edges have not landed yet".
2147
- const [items, serverRowCount] = await this.remote.query<
2148
- [{ out: RecordId<string>; version: number }[], number | null]
2399
+ const [items, metaRow] = await this.remote.query<
2400
+ [{ out: RecordId<string>; version: number }[], QueryMetaRow | null]
2149
2401
  >(`${buildListRefSelect(listRefTbl)};\n${buildQueryRowCountSelect()}`, {
2150
2402
  in: queryState.config.id,
2151
2403
  });
2404
+ const meta = metaFromRow(metaRow);
2152
2405
 
2153
2406
  this.logger.trace(
2154
2407
  {
@@ -2159,7 +2412,9 @@ export class Sp00kySync<S extends SchemaStructure> {
2159
2412
  'Got query record version array from remote'
2160
2413
  );
2161
2414
 
2162
- const array: RecordVersionArray = items.map((item) => [encodeRecordId(item.out), item.version]);
2415
+ const array: RecordVersionArray = dedupeRecordVersions(
2416
+ items.map((item) => [encodeRecordId(item.out), item.version])
2417
+ );
2163
2418
 
2164
2419
  this.logger.debug(
2165
2420
  {
@@ -2170,9 +2425,14 @@ export class Sp00kySync<S extends SchemaStructure> {
2170
2425
  'createdRemoteQuery'
2171
2426
  );
2172
2427
 
2173
- if (array) {
2174
- /// Incantation existed already
2175
- await this.dataModule.updateQueryRemoteArray(queryHash, array, { serverRowCount });
2428
+ const outcome = await this.dataModule.updateQueryRemoteArray(queryHash, array, { meta });
2429
+ // A registration that answered before its edges landed: the SSP wrote
2430
+ // `materializing` and handed the publish to its flusher (100ms window).
2431
+ // Do not leave the fresh query waiting for a backed-off poll tick; re-read
2432
+ // it a few times on a short ladder. LIVE CREATEs are ignored for a query
2433
+ // whose membership is still unknown, so this is the only fast path.
2434
+ if (outcome === 'ignored' && meta.present && meta.state === 'materializing') {
2435
+ this.scheduleListRefRefetch(queryHash, MATERIALIZING_REFETCH_LADDER_MS);
2176
2436
  }
2177
2437
 
2178
2438
  // Pull the bodies of any `.related()` subquery children into the local
@@ -2355,6 +2615,12 @@ export class Sp00kySync<S extends SchemaStructure> {
2355
2615
  }
2356
2616
 
2357
2617
  // No subscribers throughout → safe to free the local view + state.
2618
+ this.clearListRefRefetch(queryHash);
2619
+ const pendingRemoval = this.pendingLiveRemovals.get(queryHash);
2620
+ if (pendingRemoval) {
2621
+ clearTimeout(pendingRemoval.timer);
2622
+ this.pendingLiveRemovals.delete(queryHash);
2623
+ }
2358
2624
  this.dataModule.finalizeDeregister(queryHash);
2359
2625
  }
2360
2626
  }
@@ -586,7 +586,7 @@ describe('list_ref poll batching', () => {
586
586
  'SELECT in, out, version, parent FROM _00_list_ref_user_abc WHERE in IN $ins'
587
587
  );
588
588
  expect(buildQueryRowCountBatchSelect()).toBe(
589
- 'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins'
589
+ 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins'
590
590
  );
591
591
  });
592
592
 
@@ -200,11 +200,19 @@ export function buildListRefSelect(table: string): string {
200
200
  * no matter how long it waits, which is why this is read alongside them rather
201
201
  * than a retry counter.
202
202
  *
203
+ * `state` rides along: the SSP writes `materializing` when it hands a full
204
+ * publish to its edge flusher and flips it to `ready` INSIDE the transaction
205
+ * that writes the edges. So `ready` + no edges + `rowCount 0` is a real empty
206
+ * result, `materializing` + no edges is "still publishing", and a missing row
207
+ * (the whole object is `NONE`) is "the server no longer has this view". A
208
+ * server that predates the marker returns `state: NONE` and callers fall back
209
+ * to `rowCount` alone.
210
+ *
203
211
  * Returns `NONE` (→ null) when the row is not readable or does not exist yet;
204
212
  * callers must treat that as "unknown", not as zero.
205
213
  */
206
214
  export function buildQueryRowCountSelect(): string {
207
- return 'SELECT VALUE rowCount FROM ONLY $in';
215
+ return 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
208
216
  }
209
217
 
210
218
  /**
@@ -240,7 +248,7 @@ export function buildListRefBatchSelect(table: string): string {
240
248
  * without an id and must be treated as "unknown".
241
249
  */
242
250
  export function buildQueryRowCountBatchSelect(): string {
243
- return 'SELECT VALUE { id: id, rowCount: rowCount } FROM $ins';
251
+ return 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
244
252
  }
245
253
 
246
254
  /** Known edges one poll round trip may carry before the cycle is split. */