@spooky-sync/core 0.0.1-canary.205 → 0.0.1-canary.207

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.
@@ -33,8 +33,11 @@ export class CacheModule implements StreamUpdateReceiver {
33
33
  private versionLookups: Record<string, number> = {};
34
34
  /** Shared-tabs leader: fan every committed ingest out to follower circuits.
35
35
  * Fired AFTER the local tx (the rows are already in the shared store, so a
36
- * follower only needs the circuit feed). Never set on followers. */
36
+ * follower only needs the circuit feed). A follower relays its own
37
+ * mutations to the leader the same way, see {@link setIngestRelay}. */
37
38
  private ingestRelay: ((tuples: CacheIngestTuple[]) => void) | null = null;
39
+ /** See {@link setIngestRelay}. */
40
+ private relayLocalWritesOnly = false;
38
41
 
39
42
  constructor(
40
43
  private local: LocalStore,
@@ -64,8 +67,21 @@ export class CacheModule implements StreamUpdateReceiver {
64
67
  this.streamUpdateCallback(update);
65
68
  }
66
69
 
67
- setIngestRelay(cb: ((tuples: CacheIngestTuple[]) => void) | null): void {
70
+ /**
71
+ * Fan every committed ingest out to the other tabs. The leader relays
72
+ * everything (its sync fetches are the only copy the followers get). A
73
+ * follower relays with `localWritesOnly`: just the mutation path, which is
74
+ * the only thing it knows that the leader does not. Its sync-fetched
75
+ * batches are the leader's data coming back and must not be re-broadcast,
76
+ * or every follower registration would fan its whole working set to
77
+ * every tab.
78
+ */
79
+ setIngestRelay(
80
+ cb: ((tuples: CacheIngestTuple[]) => void) | null,
81
+ opts: { localWritesOnly?: boolean } = {}
82
+ ): void {
68
83
  this.ingestRelay = cb;
84
+ this.relayLocalWritesOnly = opts.localWritesOnly === true;
69
85
  }
70
86
 
71
87
  /**
@@ -197,7 +213,11 @@ export class CacheModule implements StreamUpdateReceiver {
197
213
  // failed mid-batch is neither "known" here nor fanned out to followers.
198
214
  const ingested = this.streamProcessor.ingestMany(bulk);
199
215
  for (const t of ingested) this.versionLookups[t.id] = versionOf.get(t.id) ?? 0;
200
- if (ingested.length > 0) this.ingestRelay?.(ingested as CacheIngestTuple[]);
216
+ // `skipDbInsert` is exactly the mutation path (the tx already wrote the
217
+ // row); sync-fetched batches pass false.
218
+ if (ingested.length > 0 && (!this.relayLocalWritesOnly || skipDbInsert)) {
219
+ this.ingestRelay?.(ingested as CacheIngestTuple[]);
220
+ }
201
221
 
202
222
  this.logger.debug(
203
223
  { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
@@ -239,7 +259,9 @@ export class CacheModule implements StreamUpdateReceiver {
239
259
  // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
240
260
  delete this.versionLookups[id];
241
261
  this.streamProcessor.ingestMany([{ table, op: 'DELETE', id, record: recordData }]);
242
- this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
262
+ if (!this.relayLocalWritesOnly || skipDbDelete) {
263
+ this.ingestRelay?.([{ table, op: 'DELETE', id, record: recordData }]);
264
+ }
243
265
 
244
266
  this.logger.debug(
245
267
  { table, id, Category: 'sp00ky-client::CacheModule::delete' },
@@ -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
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+
5
+ // A DELETE that landed in the local store (this tab's own, or one relayed
6
+ // from another tab) needs a forced re-materialize of the table's queries: the
7
+ // SSP may not emit a view update for it.
8
+
9
+ function makeLogger(): any {
10
+ const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
11
+ logger.child = () => logger;
12
+ return logger;
13
+ }
14
+
15
+ const schema = { tables: [{ name: 'comment', columns: {} }, { name: 'thread', columns: {} }] } as any;
16
+
17
+ function state(hash: string, tableName: string): any {
18
+ return {
19
+ config: { id: new RecordId('_00_query', hash), tableName, localArray: [], remoteArray: [] },
20
+ records: [],
21
+ };
22
+ }
23
+
24
+ describe('DataModule.notifyTableQueries', () => {
25
+ it('re-materializes only the queries on that table, isolating failures', async () => {
26
+ const local: any = { epoch: 1, query: vi.fn(async () => [[]]) };
27
+ const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
28
+ (dm as any).activeQueries.set('c1', state('c1', 'comment'));
29
+ (dm as any).activeQueries.set('c2', state('c2', 'comment'));
30
+ (dm as any).activeQueries.set('t1', state('t1', 'thread'));
31
+ const notified: string[] = [];
32
+ dm.notifyQuerySynced = vi.fn(async (hash: string) => {
33
+ notified.push(hash);
34
+ if (hash === 'c1') throw new Error('boom');
35
+ }) as any;
36
+
37
+ await dm.notifyTableQueries('comment');
38
+
39
+ expect(notified.sort()).toEqual(['c1', 'c2']);
40
+ });
41
+ });
@@ -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
  }
@@ -1893,20 +1929,8 @@ export class DataModule<S extends SchemaStructure> {
1893
1929
  }
1894
1930
 
1895
1931
  // DBSP may not emit view updates for DELETE ops — manually notify all queries
1896
- // that reference this table. Each is isolated so one failing re-materialize
1897
- // can't stop the others (or the sync emit below) from running.
1898
- for (const [queryHash, queryState] of this.activeQueries) {
1899
- if (queryState.config.tableName === tableName) {
1900
- try {
1901
- await this.notifyQuerySynced(queryHash);
1902
- } catch (err) {
1903
- this.logger.error(
1904
- { err, queryHash, Category: 'sp00ky-client::DataModule::delete' },
1905
- 'notifyQuerySynced failed after delete'
1906
- );
1907
- }
1908
- }
1909
- }
1932
+ // that reference this table.
1933
+ await this.notifyTableQueries(tableName);
1910
1934
 
1911
1935
  // Emit mutation event
1912
1936
  const mutationEvent: DeleteEvent = {
@@ -1998,6 +2022,29 @@ export class DataModule<S extends SchemaStructure> {
1998
2022
  }
1999
2023
  }
2000
2024
 
2025
+ /**
2026
+ * Force a re-materialize + notify of every active query on `tableName`.
2027
+ * Used after a DELETE landed in the local store (this tab's own, or one
2028
+ * relayed from another tab): the SSP may not emit a view update for a
2029
+ * DELETE ingest, and the re-materialize reads the store, which already
2030
+ * excludes the row. Each query is isolated so one failing re-materialize
2031
+ * can't stop the others.
2032
+ */
2033
+ async notifyTableQueries(tableName: string): Promise<void> {
2034
+ for (const [queryHash, queryState] of this.activeQueries) {
2035
+ if (queryState.config.tableName === tableName) {
2036
+ try {
2037
+ await this.notifyQuerySynced(queryHash);
2038
+ } catch (err) {
2039
+ this.logger.error(
2040
+ { err, queryHash, tableName, Category: 'sp00ky-client::DataModule::notifyTableQueries' },
2041
+ 'notifyQuerySynced failed after delete'
2042
+ );
2043
+ }
2044
+ }
2045
+ }
2046
+ }
2047
+
2001
2048
  /**
2002
2049
  * Remove a record from all active query states and notify subscribers
2003
2050
  */
@@ -2193,12 +2240,16 @@ export class DataModule<S extends SchemaStructure> {
2193
2240
  // removed row reappearing after a reload, and it works with no network.
2194
2241
  if (membershipKey && !config.remoteArray?.length) {
2195
2242
  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;
2243
+ // An empty durable row is trusted only when it carries the `confirmed`
2244
+ // marker, i.e. the server itself reported the query empty. Without it an
2245
+ // empty row cannot be told apart from one written before this device ever
2246
+ // saw a real id-set (or by the pre-`ea56f50e` client that mirrored
2247
+ // unflushed reads), and treating it as known would paint an empty list
2248
+ // with no scan fallback. A confirmed empty is the opposite case: the
2249
+ // server said "no rows", so a reload must stay empty rather than re-admit
2250
+ // every cached body until the next poll blanks it again.
2251
+ if (durable && (durable.ids.length > 0 || durable.confirmed)) {
2252
+ config.remoteArray = durable.ids;
2202
2253
  config.membershipKnown = true;
2203
2254
  }
2204
2255
  } 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).
@@ -110,6 +110,46 @@ describe('LIVE list_ref removal → membership', () => {
110
110
  ]);
111
111
  });
112
112
 
113
+ // Every tab that ingested a write optimistically (its own, or one relayed
114
+ // from another tab) already holds the row at the server's version, so the
115
+ // fetch diff is empty. Membership still has to be recorded, or the row lives
116
+ // on the settled-write grace alone until the poll catches it.
117
+ it('adds membership for a row the circuit already holds at the server version', async () => {
118
+ const { queryState, updateQueryRemoteArray, sync, live } = makeSync();
119
+ queryState.config.localArray = [
120
+ ['thread:a', 1],
121
+ ['thread:b', 1],
122
+ ['thread:c', 1],
123
+ ];
124
+
125
+ await live('CREATE', 'c', 1);
126
+
127
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
128
+ ['thread:a', 1],
129
+ ['thread:b', 1],
130
+ ['thread:c', 1],
131
+ ]);
132
+ // …without a refetch: the diff handed to the sync is empty.
133
+ expect((sync as any).runSyncForQuery).toHaveBeenCalledWith('h1', {
134
+ added: [],
135
+ updated: [],
136
+ removed: [],
137
+ });
138
+ });
139
+
140
+ it('records a bumped version on UPDATE without rewriting an unchanged list', async () => {
141
+ const { updateQueryRemoteArray, live } = makeSync();
142
+
143
+ await live('UPDATE', 'b', 1);
144
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
145
+
146
+ await live('UPDATE', 'b', 2);
147
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [
148
+ ['thread:a', 1],
149
+ ['thread:b', 2],
150
+ ]);
151
+ });
152
+
113
153
  it('leaves membership alone while it is still unknown', async () => {
114
154
  // Nothing authoritative has arrived yet, so there is no list to amend —
115
155
  // registration will supply the whole thing shortly.