@spooky-sync/core 0.0.1-canary.236 → 0.0.1-canary.238

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.
@@ -28,6 +28,9 @@ import type {
28
28
  PhaseStat,
29
29
  RegistrationTimings,
30
30
  RunOptions,
31
+ ServerViewMeta,
32
+ MembershipOutcome,
33
+ QueryAuthorityCallback,
31
34
  } from '../../types';
32
35
  import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
33
36
  import {
@@ -53,13 +56,6 @@ import {
53
56
  } from './window-query';
54
57
  import { mintMutationId } from './mutation-id';
55
58
 
56
- /**
57
- * How many consecutive empty `_00_list_ref` reads it takes to believe a query
58
- * really is empty, before any non-empty set has been seen this session. One
59
- * read is the registration race (the SSP flushes a view's initial edges
60
- * asynchronously); the second comes from the poll a cycle later.
61
- */
62
- const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
63
59
 
64
60
  /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
65
61
  function pushSample(samples: number[], ms: number): void {
@@ -98,6 +94,8 @@ export interface DurableMembership {
98
94
  * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
99
95
  */
100
96
  const TTL_HEARTBEAT_FRACTION = 0.5;
97
+ /** A query beaten this recently is skipped by `heartbeatOnWake`. */
98
+ const WAKE_HEARTBEAT_MIN_GAP_MS = 30_000;
101
99
 
102
100
  export class DataModule<S extends SchemaStructure> {
103
101
  /** Tab identity baked into mutation ids (shared-tabs rollback routing);
@@ -107,6 +105,8 @@ export class DataModule<S extends SchemaStructure> {
107
105
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
108
106
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
109
107
  private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
108
+ /** Observers of a query's authority (`membershipKnown`) flips, keyed like `statusSubscriptions`. */
109
+ private authoritySubscriptions: Map<QueryHash, Set<QueryAuthorityCallback>> = new Map();
110
110
  private mutationCallbacks: Set<MutationCallback> = new Set();
111
111
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
112
112
  // The update each debounce timer would process on its trailing edge. Kept in a
@@ -136,6 +136,15 @@ export class DataModule<S extends SchemaStructure> {
136
136
  * DataModule is constructed — mirrors `onQueryStatusChange`.
137
137
  */
138
138
  public onHeartbeat?: (hash: QueryHash) => void;
139
+ /**
140
+ * Fired once per episode when the server's `_00_query` row for a query we
141
+ * hold membership for is found missing (see `updateQueryRemoteArray`). Wired
142
+ * by Sp00kyClient to enqueue a `register` down-event so the SSP rebuilds the
143
+ * view; the client keeps rendering what it has meanwhile.
144
+ */
145
+ public onViewLost?: (hash: QueryHash) => void;
146
+ /** DevTools observer for authority flips; mirrors `onQueryStatusChange`. */
147
+ public onQueryAuthorityChange?: (hash: QueryHash, known: boolean) => void;
139
148
  /**
140
149
  * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
141
150
  * viewport-windowed list cancelling an off-screen window) loses its last
@@ -349,6 +358,61 @@ export class DataModule<S extends SchemaStructure> {
349
358
  };
350
359
  }
351
360
 
361
+ /**
362
+ * Subscribe to a query's authority flips: whether server membership is known
363
+ * for it. With `{ immediate: true }` the callback fires synchronously with
364
+ * the current value (`false` for an unknown query).
365
+ */
366
+ subscribeAuthority(
367
+ queryHash: string,
368
+ callback: QueryAuthorityCallback,
369
+ options: { immediate?: boolean } = {}
370
+ ): () => void {
371
+ if (!this.authoritySubscriptions.has(queryHash)) {
372
+ this.authoritySubscriptions.set(queryHash, new Set());
373
+ }
374
+ this.authoritySubscriptions.get(queryHash)?.add(callback);
375
+
376
+ if (options.immediate) {
377
+ callback(this.activeQueries.get(queryHash)?.config.membershipKnown === true);
378
+ }
379
+
380
+ return () => {
381
+ const subs = this.authoritySubscriptions.get(queryHash);
382
+ if (subs) {
383
+ subs.delete(callback);
384
+ if (subs.size === 0) {
385
+ this.authoritySubscriptions.delete(queryHash);
386
+ }
387
+ }
388
+ };
389
+ }
390
+
391
+ /** Whether server membership is known for `queryHash` (see `QueryConfig.membershipKnown`). */
392
+ isAuthoritative(queryHash: string): boolean {
393
+ return this.activeQueries.get(queryHash)?.config.membershipKnown === true;
394
+ }
395
+
396
+ /**
397
+ * The single writer of `config.membershipKnown` for a live query. Fans out
398
+ * to `subscribeAuthority` listeners and DevTools on a change; no-op when
399
+ * unchanged or the query is unknown.
400
+ */
401
+ private setMembershipKnown(queryHash: string, known: boolean): void {
402
+ const queryState = this.activeQueries.get(queryHash);
403
+ if (!queryState) return;
404
+ const was = queryState.config.membershipKnown === true;
405
+ queryState.config.membershipKnown = known;
406
+ if (was === known) return;
407
+ this.onQueryAuthorityChange?.(queryHash, known);
408
+ const subs = this.authoritySubscriptions.get(queryHash);
409
+ if (subs) {
410
+ for (const callback of subs) {
411
+ callback(known);
412
+ }
413
+ }
414
+ }
415
+
352
416
  /**
353
417
  * Set a query's fetch status and notify status observers (DevTools +
354
418
  * `subscribeStatus` listeners). No-op when the status is unchanged or the
@@ -1147,7 +1211,7 @@ export class DataModule<S extends SchemaStructure> {
1147
1211
  // known and rendering can stop falling back to a predicate scan. The durable
1148
1212
  // `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
1149
1213
  // single write point) — the registration that follows lands there anyway.
1150
- queryState.config.membershipKnown = true;
1214
+ this.setMembershipKnown(hash, true);
1151
1215
 
1152
1216
  queryState.records = await this.materializeRecords(queryState);
1153
1217
  const subscribers = this.subscriptions.get(hash);
@@ -1429,6 +1493,7 @@ export class DataModule<S extends SchemaStructure> {
1429
1493
  this.cache.unregisterQuery(hash);
1430
1494
  this.activeQueries.delete(hash);
1431
1495
  this.subscriptions.delete(hash);
1496
+ this.authoritySubscriptions.delete(hash);
1432
1497
  }
1433
1498
 
1434
1499
  /**
@@ -1479,91 +1544,97 @@ export class DataModule<S extends SchemaStructure> {
1479
1544
  hash: string,
1480
1545
  remoteArray: RecordVersionArray,
1481
1546
  opts?: {
1482
- /** `_00_query.rowCount` read in the same round trip; `null` = unknown. */
1483
- serverRowCount?: number | null;
1484
- }
1485
- ): Promise<void> {
1547
+ /** The `_00_query` row as read in the same round trip as the edges. */
1548
+ meta?: ServerViewMeta;
1549
+ /**
1550
+ * The caller verified upstream that the ids missing from `remoteArray`
1551
+ * no longer exist (see `Sp00kySync` LIVE removal handling); an empty set
1552
+ * is then a real answer even without a readable row.
1553
+ */
1554
+ verifiedRemoval?: boolean;
1555
+ }
1556
+ ): Promise<MembershipOutcome> {
1486
1557
  const queryState = this.getQueryByHash(hash);
1487
1558
  if (!queryState) {
1488
1559
  this.logger.warn(
1489
1560
  { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1490
1561
  'Query to update remote array not found'
1491
1562
  );
1492
- return;
1563
+ return 'ignored';
1493
1564
  }
1494
- // An empty id-set is only believable once a real one has arrived in this
1495
- // session. `_00_list_ref` is published asynchronously — the SSP queues a
1496
- // view's initial edges to a coalescing flusher and returns from
1497
- // `fn::query::register` before they land — so an empty read right after
1498
- // registration is routinely just "not flushed yet", for a query that has
1499
- // rows. Taking it at face value latched `membershipKnown` on an empty set,
1500
- // which renders nothing (no scan fallback), and mirrored `[]` into the
1501
- // durable `_00_window` row, which kept the list blank across reloads.
1565
+ const meta = opts?.meta;
1566
+ if (meta) queryState.serverState = meta.present ? meta.state : null;
1567
+ const held = queryState.config.remoteArray ?? [];
1568
+
1569
+ // A NON-EMPTY set is always the server's answer. An EMPTY set is only
1570
+ // believed when the server stands behind it, because "no edges" has four
1571
+ // causes and only one of them is "no rows":
1502
1572
  //
1503
- // Ignoring it entirely (rather than storing `[]` with the latch withheld)
1504
- // is deliberate: on a cold start `remoteArray` is seeded from the durable
1505
- // row, and overwriting that with `[]` would blank the very rows the seed
1506
- // exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
1507
- // delivers the real set.
1508
- // `serverRowCount` is what makes the two cases separable. The SSP writes it
1509
- // onto the `_00_query` row in the same statement that registers the view,
1510
- // before it queues the view's initial edges — so `> 0` with no edges is the
1511
- // flush window and `=== 0` is a genuinely empty query. `null`/undefined
1512
- // means we could not read it (older server, row not visible yet).
1573
+ // - the `_00_query` row is gone (`meta.present === false`): a TTL sweep,
1574
+ // an SSP reset or a scheduler wipe took the view. The client keeps what
1575
+ // it renders and asks for a re-registration ("view lost"). Believing
1576
+ // the empty here is what blanked lists after every sweep and, via the
1577
+ // durable `_00_window` mirror, kept them blank across reloads.
1578
+ // - `state === 'materializing'`: the SSP has computed the set and its
1579
+ // edges are in flight (the flusher window). Not an answer yet.
1580
+ // - `state === 'ready'` with `rowCount > 0` (or unreadable): the row and
1581
+ // the edges disagree (metrics lag, a stranded view). Not trusted.
1582
+ // - `state === 'ready'` with `rowCount === 0`: a real empty result. The
1583
+ // SSP flips `ready` inside the transaction that writes the edges, so
1584
+ // this cannot be observed mid-publish.
1513
1585
  //
1514
- // Retry counting cannot substitute for this: the poll runs 500ms after
1515
- // registration, well inside the flush window for a real collection, so
1516
- // "believe it the second time" blanked exactly the lists this guard exists
1517
- // to protect — reported as rows vanishing ~2s after a page load.
1518
- // Whether this write may be trusted by the NEXT session. Non-empty sets
1519
- // always; an empty set only when the server stood behind it (a zero row
1520
- // count, or a real set was seen this session and it is now gone). The
1521
- // retry-budget path below accepts an empty without that backing and must
1522
- // stay non-durable, or the poisoned-device self-heal is lost.
1523
- let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
1524
- if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
1525
- const serverRowCount = opts?.serverRowCount;
1526
- const knownEmpty = serverRowCount === 0;
1527
- confirmed = knownEmpty;
1528
- if (!knownEmpty) {
1529
- // Unknown row count still gets a bounded escape hatch, so a server that
1530
- // cannot report one never strands a device on a durable seed forever.
1531
- const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
1532
- queryState.config.emptyReads = emptyReads;
1533
- const exhausted =
1534
- serverRowCount === null || serverRowCount === undefined
1535
- ? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS
1536
- : false; // a positive row count is never "confirmed empty"
1537
- if (!exhausted) {
1538
- this.logger.debug(
1539
- {
1540
- hash,
1541
- emptyReads,
1542
- serverRowCount,
1543
- Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
1544
- },
1545
- 'Ignoring empty membership: the server still reports rows for this query'
1546
- );
1547
- return;
1586
+ // A server that predates `state` (`state === null`) can only be judged on
1587
+ // `rowCount`: `0` is empty, anything else is the flush-window ambiguity and
1588
+ // is left alone. There is deliberately NO retry budget that eventually
1589
+ // accepts an unexplained empty: the register lifecycle, the poll and the
1590
+ // heartbeat all retry, and a server that cannot explain an empty must not
1591
+ // be able to erase a device's data.
1592
+ if (remoteArray.length === 0 && !opts?.verifiedRemoval) {
1593
+ if (!meta || !meta.present) {
1594
+ if (held.length > 0 || queryState.config.membershipKnown) {
1595
+ if (!queryState.viewLost) {
1596
+ queryState.viewLost = true;
1597
+ this.logger.warn(
1598
+ { hash, held: held.length, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1599
+ 'Server has no _00_query row for a query we hold membership for; keeping local state and re-registering'
1600
+ );
1601
+ this.onViewLost?.(hash);
1602
+ }
1603
+ return 'view-lost';
1548
1604
  }
1605
+ this.logger.debug(
1606
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1607
+ 'Ignoring empty membership: the _00_query row is not readable yet'
1608
+ );
1609
+ return 'ignored';
1610
+ }
1611
+ const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
1612
+ if (!knownEmpty) {
1613
+ this.logger.debug(
1614
+ {
1615
+ hash,
1616
+ state: meta.state,
1617
+ rowCount: meta.rowCount,
1618
+ Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
1619
+ },
1620
+ 'Ignoring empty membership: the server has not confirmed this query empty'
1621
+ );
1622
+ return 'ignored';
1549
1623
  }
1550
1624
  }
1625
+ if (meta?.present) queryState.viewLost = false;
1551
1626
 
1552
1627
  const epoch = this.local.epoch;
1553
1628
  queryState.config.remoteArray = remoteArray;
1554
- // The single point where authoritative membership arrives (registration and
1555
- // the `_00_list_ref` poll both land here), so it is where "we now know the
1556
- // membership" is latched and where the durable mirror is written.
1557
- queryState.config.membershipKnown = true;
1629
+ // The single point where authoritative membership arrives (registration,
1630
+ // the `_00_list_ref` poll and verified LIVE removals all land here), so it
1631
+ // is where "we now know the membership" is latched and where the durable
1632
+ // mirror is written. Every set that reaches this line is server-backed, so
1633
+ // the mirror is always `confirmed`: a reload may trust it even when empty.
1634
+ this.setMembershipKnown(hash, true);
1558
1635
  queryState.serverMembership = true;
1559
- if (remoteArray.length > 0) {
1560
- // Earns the right to believe a later empty set — that transition is a
1561
- // genuine removal and must be honoured, or removed rows resurrect.
1562
- queryState.config.remoteSeen = true;
1563
- queryState.config.emptyReads = 0;
1564
- }
1565
1636
  if (queryState.config.membershipKey) {
1566
- await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
1637
+ await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, true);
1567
1638
  }
1568
1639
  try {
1569
1640
  await this.local.query(
@@ -1575,9 +1646,10 @@ export class DataModule<S extends SchemaStructure> {
1575
1646
  { epoch }
1576
1647
  );
1577
1648
  } catch (err) {
1578
- if (err instanceof StaleEpochError) return;
1649
+ if (err instanceof StaleEpochError) return 'applied';
1579
1650
  throw err;
1580
1651
  }
1652
+ return 'applied';
1581
1653
  }
1582
1654
 
1583
1655
  /**
@@ -1626,18 +1698,18 @@ export class DataModule<S extends SchemaStructure> {
1626
1698
  // bucket's durable row if it has one (a returning user), otherwise mark it
1627
1699
  // unknown so the first paint falls back to a scan instead of rendering an
1628
1700
  // empty list until the server answers.
1629
- config.membershipKnown = false;
1630
- // The new bucket's server sets have not been seen yet — an empty read
1631
- // must be re-confirmed there too.
1632
- config.remoteSeen = false;
1633
- config.emptyReads = 0;
1701
+ // Via the single writer so `subscribeAuthority` observers see the reset
1702
+ // BEFORE the `[]` records notify below, and the re-latch after it.
1703
+ this.setMembershipKnown(hash, false);
1704
+ queryState.viewLost = false;
1705
+ queryState.serverState = null;
1634
1706
  if (config.membershipKey) {
1635
1707
  const durable = await this.getWindowMembership(config.membershipKey);
1636
1708
  // Same rule as the cold-start read: a non-empty row, or an empty one
1637
1709
  // the server confirmed, is membership; an unmarked empty row is not.
1638
1710
  if (durable && (durable.ids.length > 0 || durable.confirmed)) {
1639
1711
  config.remoteArray = durable.ids;
1640
- config.membershipKnown = true;
1712
+ this.setMembershipKnown(hash, true);
1641
1713
  }
1642
1714
  }
1643
1715
  queryState.hydrated = false;
@@ -2522,6 +2594,7 @@ export class DataModule<S extends SchemaStructure> {
2522
2594
  );
2523
2595
  return;
2524
2596
  }
2597
+ queryState.lastHeartbeatAt = Date.now();
2525
2598
  this.onHeartbeat?.(hash);
2526
2599
  this.logger.debug(
2527
2600
  {
@@ -2535,6 +2608,51 @@ export class DataModule<S extends SchemaStructure> {
2535
2608
  }, heartbeatTime);
2536
2609
  }
2537
2610
 
2611
+ /**
2612
+ * Heartbeat every watched query NOW and re-anchor its timer.
2613
+ *
2614
+ * The TTL timer is the only thing keeping a view alive on the server, and
2615
+ * browsers throttle timers in hidden tabs (Chrome aligns them to one-minute
2616
+ * ticks after five minutes hidden, and suspends them outright on some
2617
+ * mobile platforms). A tab that comes back after a long stretch may have
2618
+ * missed its beat by more than the server's slack, and the sweep will have
2619
+ * taken the row and its edges. The client does not blank on that any more
2620
+ * (`updateQueryRemoteArray` treats a missing row as "view lost"), but the
2621
+ * sooner it finds out, the sooner the view is rebuilt: `heartbeatQuery`
2622
+ * re-registers a query whose row is gone. Called on `visibilitychange` (to
2623
+ * visible) and `online`.
2624
+ *
2625
+ * Queries beaten within the last `minGapMs` are skipped, so a flurry of
2626
+ * wake events costs one beat per query. There is deliberately no local
2627
+ * expiry anywhere in this module: the TTL is the server's, and the client's
2628
+ * only duty is to keep asserting liveness while something is watching.
2629
+ */
2630
+ heartbeatOnWake(minGapMs: number = WAKE_HEARTBEAT_MIN_GAP_MS): number {
2631
+ const now = Date.now();
2632
+ let beaten = 0;
2633
+ for (const [hash, queryState] of this.activeQueries.entries()) {
2634
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) continue;
2635
+ if (queryState.lastHeartbeatAt !== undefined && now - queryState.lastHeartbeatAt < minGapMs) {
2636
+ continue;
2637
+ }
2638
+ if (queryState.ttlTimer) {
2639
+ clearTimeout(queryState.ttlTimer);
2640
+ queryState.ttlTimer = null;
2641
+ }
2642
+ queryState.lastHeartbeatAt = now;
2643
+ this.onHeartbeat?.(hash);
2644
+ this.startTTLHeartbeat(queryState, hash);
2645
+ beaten++;
2646
+ }
2647
+ if (beaten > 0) {
2648
+ this.logger.debug(
2649
+ { beaten, Category: 'sp00ky-client::DataModule::heartbeatOnWake' },
2650
+ 'Wake heartbeat sent for watched queries'
2651
+ );
2652
+ }
2653
+ return beaten;
2654
+ }
2655
+
2538
2656
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
2539
2657
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
2540
2658
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -229,8 +229,9 @@ export class DevToolsService implements StreamUpdateReceiver {
229
229
  // the panel: is the server's set known, has a non-empty one been seen
230
230
  // this session, how many empty reads were ignored.
231
231
  membershipKnown: q.config.membershipKnown === true,
232
- remoteSeen: q.config.remoteSeen === true,
233
- emptyReads: q.config.emptyReads ?? 0,
232
+ authoritative: q.config.membershipKnown === true,
233
+ viewLost: q.viewLost === true,
234
+ serverState: q.serverState ?? null,
234
235
  // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
235
236
  // remote record fetch, frontend reconcile, registration). Flows to both
236
237
  // the DevTools panel and the MCP (which returns activeQueries verbatim).
@@ -37,7 +37,7 @@ export class SyncEngine {
37
37
  */
38
38
  async syncRecords(
39
39
  diff: RecordVersionDiff
40
- ): Promise<{ remoteFetchMs: number; stillRemoteIds: string[] }> {
40
+ ): Promise<{ remoteFetchMs: number; stillRemoteIds: string[]; confirmedRemovedIds: string[] }> {
41
41
  const { added, updated, removed } = diff;
42
42
 
43
43
  this.logger.debug(
@@ -54,8 +54,9 @@ export class SyncEngine {
54
54
  // locally. Returns ids that LEFT the view's list_ref but still exist upstream
55
55
  // (so they weren't deleted) — the caller converges localArray to drop them.
56
56
  let stillRemoteIds: string[] = [];
57
+ let confirmedRemovedIds: string[] = [];
57
58
  if (removed.length > 0) {
58
- stillRemoteIds = await this.handleRemovedRecords(removed);
59
+ ({ stillRemoteIds, confirmedRemovedIds } = await this.handleRemovedRecords(removed));
59
60
  }
60
61
 
61
62
  // Fetch added/updated records from remote. Skip ids whose body the local
@@ -68,7 +69,7 @@ export class SyncEngine {
68
69
  return !(localVersion && item.version <= localVersion);
69
70
  });
70
71
  if (toFetch.length === 0) {
71
- return { remoteFetchMs: 0, stillRemoteIds };
72
+ return { remoteFetchMs: 0, stillRemoteIds, confirmedRemovedIds };
72
73
  }
73
74
 
74
75
  // Build a version map from the diff (versions come from _00_list_ref)
@@ -151,7 +152,7 @@ export class SyncEngine {
151
152
  records: remoteResults,
152
153
  });
153
154
 
154
- return { remoteFetchMs, stillRemoteIds };
155
+ return { remoteFetchMs, stillRemoteIds, confirmedRemovedIds };
155
156
  }
156
157
 
157
158
  /**
@@ -168,7 +169,9 @@ export class SyncEngine {
168
169
  * row to a later sync round is recoverable; deleting a fresh row that
169
170
  * upstream still has is not.
170
171
  */
171
- private async handleRemovedRecords(removed: RecordId[]): Promise<string[]> {
172
+ private async handleRemovedRecords(
173
+ removed: RecordId[]
174
+ ): Promise<{ stillRemoteIds: string[]; confirmedRemovedIds: string[] }> {
172
175
  this.logger.debug(
173
176
  {
174
177
  removed: removed.map((r) => r.toString()),
@@ -216,7 +219,7 @@ export class SyncEngine {
216
219
  },
217
220
  'Remote existence check failed, skipping deletion to avoid clobbering fresh data'
218
221
  );
219
- return [];
222
+ return { stillRemoteIds: [], confirmedRemovedIds: [] };
220
223
  }
221
224
 
222
225
  // Ids that left the view's list_ref but STILL exist upstream — not deletions,
@@ -224,6 +227,7 @@ export class SyncEngine {
224
227
  // longer matches the query). The caller drops these from `localArray` so the
225
228
  // poll's diff stops re-flagging them every tick (the `job:` churn).
226
229
  const stillRemoteIds: string[] = [];
230
+ const confirmedRemovedIds: string[] = [];
227
231
  for (const recordId of removed) {
228
232
  const recordIdStr = encodeRecordId(recordId);
229
233
  if (!existingRemoteIds.has(recordIdStr)) {
@@ -236,10 +240,11 @@ export class SyncEngine {
236
240
  );
237
241
  // Use CacheModule to handle both local DB and DBSP deletion
238
242
  await this.cache.delete(recordId.table.name, recordIdStr);
243
+ confirmedRemovedIds.push(recordIdStr);
239
244
  } else {
240
245
  stillRemoteIds.push(recordIdStr);
241
246
  }
242
247
  }
243
- return stillRemoteIds;
248
+ return { stillRemoteIds, confirmedRemovedIds };
244
249
  }
245
250
  }
@@ -38,11 +38,21 @@ function makeSync(hashes: string[]) {
38
38
  // (`applyListRefSnapshot`). Stub both so the test controls per-cycle
39
39
  // reachability. `poll` invokes the private method.
40
40
  const fetch = vi.fn();
41
- const apply = vi.fn().mockResolvedValue(false);
41
+ const apply = vi.fn().mockResolvedValue({ changed: false, outcome: 'unchanged' });
42
42
  (sync as any).fetchListRefSnapshots = fetch;
43
43
  (sync as any).applyListRefSnapshot = apply;
44
44
  const snapshotsFor = (hs: string[]) =>
45
- new Map(hs.map((h) => [h, { primary: [], subquery: [], rowCount: null }]));
45
+ new Map(
46
+ hs.map((h) => [
47
+ h,
48
+ {
49
+ primary: [],
50
+ subquery: [],
51
+ rowCount: null,
52
+ meta: { present: true, rowCount: null, state: null },
53
+ },
54
+ ])
55
+ );
46
56
  fetch.mockImplementation(async (hs: string[]) => snapshotsFor(hs));
47
57
  const poll = () => (sync as any).pollListRefForActiveQueries() as Promise<boolean>;
48
58
 
@@ -102,7 +112,7 @@ describe('sync health via idle poll', () => {
102
112
  // snapshots then fails on its own network error → still a reached cycle.
103
113
  fetch.mockImplementation(async (hs: string[]) => snapshotsFor(hs));
104
114
  apply
105
- .mockResolvedValueOnce(true)
115
+ .mockResolvedValueOnce({ changed: true, outcome: 'applied' })
106
116
  .mockRejectedValueOnce(new Error(CONNECTION_UNAVAILABLE));
107
117
  await expect(poll()).resolves.toBe(true);
108
118
  expect(sync.syncHealth.status).toBe('healthy');