@spooky-sync/core 0.0.1-canary.102 → 0.0.1-canary.104

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/types.d.ts CHANGED
@@ -403,10 +403,13 @@ type QueryConfigRecord = QueryConfig & {
403
403
  };
404
404
  /**
405
405
  * Runtime fetch status of a live query.
406
- * - `idle`: not currently fetching missing records.
407
- * - `fetching`: the sync engine is fetching/ingesting missing records for this
408
- * query. UI notifications are coalesced so the result lands as a single
409
- * update once fetching completes.
406
+ * - `idle`: registered, initial sync completed, and not currently fetching
407
+ * missing records the materialized rows are authoritative (a windowed
408
+ * query's short result really is the end of the list).
409
+ * - `fetching`: the query is registering (a query is born `fetching` until its
410
+ * initial remote sync completes) or the sync engine is fetching/ingesting
411
+ * missing records for it. Any pending debounced result is flushed BEFORE the
412
+ * flip back to `idle`, so idle status never races ahead of the rows.
410
413
  */
411
414
  type QueryStatus = 'idle' | 'fetching';
412
415
  /**
@@ -420,12 +423,20 @@ interface QueryState {
420
423
  /** Set once `applyHydration` has run for this query, so the cold instant-hydrate
421
424
  * path fires at most once per query (see DataModule.isCold/applyHydration). */
422
425
  hydrated?: boolean;
426
+ /** Set once `notifyQuerySynced` has emitted for this registration lifetime.
427
+ * Ephemeral (unlike the persisted `updateCount`), so a re-registered query
428
+ * always emits at least once even when its records are unchanged — otherwise
429
+ * an empty re-registered window would never notify and stay "loading". */
430
+ syncNotified?: boolean;
423
431
  /** Timer for TTL expiration. */
424
432
  ttlTimer: NodeJS.Timeout | null;
425
433
  /** TTL duration in milliseconds. */
426
434
  ttlDurationMs: number;
427
435
  /** Number of times the query has been updated. */
428
436
  updateCount: number;
437
+ /** Timestamp (ms) of the last user-visible update, or null before the first
438
+ * one. Surfaced to DevTools as `lastUpdate` — must NOT be stamped on read. */
439
+ lastUpdatedAt: number | null;
429
440
  /**
430
441
  * Rolling window of the most recent materialization-step latencies (ms).
431
442
  * Capped at MATERIALIZATION_SAMPLE_WINDOW; used to recompute p55/p90/p99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.102",
3
+ "version": "0.0.1-canary.104",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.102",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.102",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.104",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.104",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
@@ -1,4 +1,5 @@
1
1
  import type { LocalDatabaseService } from '../../services/database/index';
2
+ import { StaleEpochError } from '../../services/database/index';
2
3
  import type {
3
4
  StreamProcessorService,
4
5
  StreamUpdate,
@@ -54,6 +55,12 @@ export class CacheModule implements StreamUpdateReceiver {
54
55
  return this.versionLookups[recordId] ?? 0;
55
56
  }
56
57
 
58
+ /** Drop the version cache on a bucket switch — a stale version would make
59
+ * the sync diff skip fetching a body the new bucket legitimately needs. */
60
+ public clearVersionLookups(): void {
61
+ this.versionLookups = {};
62
+ }
63
+
57
64
  /**
58
65
  * Save a single record to local DB and ingest into DBSP
59
66
  * Used by mutations (create/update)
@@ -70,6 +77,13 @@ export class CacheModule implements StreamUpdateReceiver {
70
77
  async saveBatch(records: CacheRecord[], skipDbInsert: boolean = false): Promise<void> {
71
78
  if (records.length === 0) return;
72
79
 
80
+ // Fence against bucket switches: this batch's records were derived from
81
+ // reads against the CURRENT store/user. If a switch lands while we await
82
+ // the (gated) local write, the write throws StaleEpochError and the whole
83
+ // batch — including the SSP ingest — is dropped: the new bucket re-syncs
84
+ // its own data from the server.
85
+ const epoch = this.local.epoch;
86
+
73
87
  this.logger.debug(
74
88
  {
75
89
  count: records.length,
@@ -116,9 +130,12 @@ export class CacheModule implements StreamUpdateReceiver {
116
130
  {} as Record<string, any>
117
131
  );
118
132
 
119
- await this.local.execute(query, params);
133
+ await this.local.execute(query, params, { epoch });
120
134
  }
121
135
 
136
+ // Late fence for the skipDbInsert path (no gated write above to trip on).
137
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
138
+
122
139
  // 2. Bulk ingest into DBSP (use populatedRecords which has _00_rv set).
123
140
  // ingestMany coalesces the per-record stream updates into a single
124
141
  // notification per affected query — the UI then updates once, after the
@@ -135,6 +152,13 @@ export class CacheModule implements StreamUpdateReceiver {
135
152
  'Batch saved successfully'
136
153
  );
137
154
  } catch (err) {
155
+ if (err instanceof StaleEpochError) {
156
+ this.logger.debug(
157
+ { count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
158
+ 'Dropped batch from before a bucket switch'
159
+ );
160
+ return;
161
+ }
138
162
  this.logger.error(
139
163
  { err, count: records.length, Category: 'sp00ky-client::CacheModule::saveBatch' },
140
164
  'Failed to save batch'
@@ -152,11 +176,13 @@ export class CacheModule implements StreamUpdateReceiver {
152
176
  'Deleting record'
153
177
  );
154
178
 
179
+ const epoch = this.local.epoch;
155
180
  try {
156
181
  // 1. Delete from local database
157
182
  if (!skipDbDelete) {
158
- await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
183
+ await this.local.query('DELETE $id', { id: parseRecordIdString(id) }, { epoch });
159
184
  }
185
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
160
186
 
161
187
  // 2. Ingest deletion into DBSP (pass record data so predicates can be matched)
162
188
  delete this.versionLookups[id];
@@ -167,6 +193,13 @@ export class CacheModule implements StreamUpdateReceiver {
167
193
  'Record deleted successfully'
168
194
  );
169
195
  } catch (err) {
196
+ if (err instanceof StaleEpochError) {
197
+ this.logger.debug(
198
+ { table, id, Category: 'sp00ky-client::CacheModule::delete' },
199
+ 'Dropped delete from before a bucket switch'
200
+ );
201
+ return;
202
+ }
170
203
  this.logger.error(
171
204
  { err, table, id, Category: 'sp00ky-client::CacheModule::delete' },
172
205
  'Failed to delete record'
@@ -124,10 +124,17 @@ export class CrdtField {
124
124
  });
125
125
  }
126
126
 
127
- stopSync(): void {
127
+ /**
128
+ * Stop syncing this field. Flushes one final remote push by default so the
129
+ * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
130
+ * the remote session already belongs to the NEXT user, and pushing this
131
+ * (previous user's) snapshot under it would clobber the record remotely.
132
+ */
133
+ stopSync(options: { flush?: boolean } = {}): void {
134
+ const flush = options.flush ?? true;
128
135
  if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }
129
136
  if (this.pushTimer) { clearTimeout(this.pushTimer); this.pushTimer = null; }
130
- if (this.remote && this.recordId) { void this.pushToRemote(); }
137
+ if (flush && this.remote && this.recordId) { void this.pushToRemote(); }
131
138
  }
132
139
 
133
140
  importRemote(state: Uint8Array): void {
@@ -147,9 +147,14 @@ export class CrdtManager {
147
147
  );
148
148
  }
149
149
 
150
- closeAll(): void {
150
+ /**
151
+ * Close every open field + table LIVE. Fields flush a final remote push by
152
+ * default; pass `{ flush: false }` on a bucket switch, where that flush
153
+ * would push the previous user's snapshot under the next user's session.
154
+ */
155
+ closeAll(options: { flush?: boolean } = {}): void {
151
156
  for (const [_, field] of this.fields) {
152
- field.stopSync();
157
+ field.stopSync(options);
153
158
  }
154
159
  this.fields.clear();
155
160
  for (const table of Array.from(this.liveByTable.keys())) {
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+ import type { QueryState } from '../../types';
5
+
6
+ /**
7
+ * Tests for the local-bucket-switch surface of DataModule:
8
+ * - `quiesce()` disarms every debounce + TTL timer;
9
+ * - `rebindAfterBucketSwitch()` keeps query hashes (subscriptions stay
10
+ * attached), resets sync state, notifies subscribers with the emptied
11
+ * records so the previous user's rows leave the UI, re-registers the SSP
12
+ * view, and returns the hashes for remote re-registration;
13
+ * - stale-epoch stream updates are dropped instead of applied.
14
+ */
15
+
16
+ function makeLogger(): any {
17
+ const noop = () => {};
18
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
19
+ logger.child = () => logger;
20
+ return logger;
21
+ }
22
+
23
+ function makeQueryState(hash: string, records: Record<string, any>[]): QueryState {
24
+ return {
25
+ config: {
26
+ id: new RecordId('_00_query', hash),
27
+ surql: 'SELECT * FROM user',
28
+ params: {},
29
+ localArray: [['user:a', 1]],
30
+ remoteArray: [['user:a', 1]],
31
+ ttl: '10m',
32
+ lastActiveAt: new Date(),
33
+ tableName: 'user',
34
+ },
35
+ records,
36
+ hydrated: true,
37
+ ttlTimer: null,
38
+ ttlDurationMs: 600_000,
39
+ updateCount: 3,
40
+ lastUpdatedAt: null,
41
+ materializationSamples: [],
42
+ lastIngestLatencyMs: null,
43
+ errorCount: 0,
44
+ status: 'idle',
45
+ phaseSamples: {},
46
+ phaseLast: {},
47
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
48
+ };
49
+ }
50
+
51
+ function makeHarness(epoch = 0) {
52
+ const localQueries: Array<{ query: unknown; vars: unknown; opts: unknown }> = [];
53
+ const local: any = {
54
+ epoch,
55
+ query: vi.fn(async (query: unknown, vars?: unknown, opts?: unknown) => {
56
+ localQueries.push({ query, vars, opts });
57
+ return [[]];
58
+ }),
59
+ };
60
+ const cache: any = {
61
+ registerQuery: vi.fn(() => ({ localArray: [] })),
62
+ };
63
+ const dm = new DataModule(cache, local, { tables: [] } as any, makeLogger(), 100);
64
+ return { dm, local, cache, localQueries };
65
+ }
66
+
67
+ describe('DataModule.quiesce', () => {
68
+ it('clears debounce and TTL timers', () => {
69
+ vi.useFakeTimers();
70
+ try {
71
+ const { dm } = makeHarness();
72
+ const qs = makeQueryState('h1', []);
73
+ qs.ttlTimer = setTimeout(() => {}, 60_000);
74
+ (dm as any).activeQueries.set('h1', qs);
75
+ (dm as any).debounceTimers.set('h1', setTimeout(() => {}, 60_000));
76
+
77
+ dm.quiesce();
78
+
79
+ expect(qs.ttlTimer).toBeNull();
80
+ expect((dm as any).debounceTimers.size).toBe(0);
81
+ expect(vi.getTimerCount()).toBe(0);
82
+ } finally {
83
+ vi.useRealTimers();
84
+ }
85
+ });
86
+ });
87
+
88
+ describe('DataModule.rebindAfterBucketSwitch', () => {
89
+ let harness: ReturnType<typeof makeHarness>;
90
+
91
+ beforeEach(() => {
92
+ harness = makeHarness();
93
+ (harness.dm as any).activeQueries.set(
94
+ 'h1',
95
+ makeQueryState('h1', [{ id: 'user:a', name: 'Previous User Row' }])
96
+ );
97
+ });
98
+
99
+ it('keeps the hash, resets sync state, and notifies subscribers with empty records', async () => {
100
+ const { dm, cache } = harness;
101
+ const emissions: unknown[][] = [];
102
+ dm.subscribe('h1', (records) => emissions.push(records as unknown[]));
103
+
104
+ const hashes = await dm.rebindAfterBucketSwitch();
105
+
106
+ expect(hashes).toEqual(['h1']);
107
+ const qs = (dm as any).activeQueries.get('h1') as QueryState;
108
+ expect(qs.records).toEqual([]);
109
+ expect(qs.config.localArray).toEqual([]);
110
+ expect(qs.config.remoteArray).toEqual([]);
111
+ expect(qs.hydrated).toBe(false);
112
+ expect(qs.status).toBe('fetching');
113
+ // Subscribers saw the previous user's rows drop out.
114
+ expect(emissions).toEqual([[]]);
115
+ // The SSP view was re-registered (on the fresh, post-reset processor).
116
+ expect(cache.registerQuery).toHaveBeenCalledWith(
117
+ expect.objectContaining({ queryHash: 'h1', surql: 'SELECT * FROM user' })
118
+ );
119
+ // The TTL heartbeat is re-armed.
120
+ expect(qs.ttlTimer).not.toBeNull();
121
+ if (qs.ttlTimer) clearTimeout(qs.ttlTimer);
122
+ });
123
+ });
124
+
125
+ describe('stale-epoch stream updates', () => {
126
+ it('drops an update whose chain started before a bucket switch', async () => {
127
+ const { dm, local } = makeHarness();
128
+ const qs = makeQueryState('h1', [{ id: 'user:a' }]);
129
+ (dm as any).activeQueries.set('h1', qs);
130
+
131
+ // The materialize read happens, then the epoch moves (bucket switched).
132
+ local.query.mockImplementation(async () => {
133
+ local.epoch = 1;
134
+ return [[{ id: 'user:b', name: 'other user row' }]];
135
+ });
136
+
137
+ await (dm as any).processStreamUpdate({
138
+ queryHash: 'h1',
139
+ localArray: [['user:b', 1]],
140
+ op: 'CREATE',
141
+ });
142
+
143
+ // Neither the records nor the persisted arrays moved.
144
+ expect(qs.records).toEqual([{ id: 'user:a' }]);
145
+ expect(qs.config.localArray).toEqual([['user:a', 1]]);
146
+ });
147
+ });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { DataModule } from './index';
4
4
  import type { QueryState, QueryStatus } from '../../types';
@@ -6,7 +6,9 @@ import type { QueryState, QueryStatus } from '../../types';
6
6
  /**
7
7
  * Tests for the per-query fetch status (idle/fetching) added to DataModule:
8
8
  * setQueryStatus updates the query object and notifies both the DevTools
9
- * observer hook and subscribeStatus listeners.
9
+ * observer hook and subscribeStatus listeners; beginFetching/endFetching
10
+ * refcount overlapping fetch cycles; flushPendingStreamUpdate lands the
11
+ * debounced result before a query flips back to idle.
10
12
  */
11
13
 
12
14
  function makeLogger(): any {
@@ -42,10 +44,14 @@ function makeQueryState(hash: string): QueryState {
42
44
  ttlTimer: null,
43
45
  ttlDurationMs: 0,
44
46
  updateCount: 0,
47
+ lastUpdatedAt: null,
45
48
  materializationSamples: [],
46
49
  lastIngestLatencyMs: null,
47
50
  errorCount: 0,
48
51
  status: 'idle',
52
+ phaseSamples: {},
53
+ phaseLast: {},
54
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
49
55
  };
50
56
  }
51
57
 
@@ -106,3 +112,138 @@ describe('DataModule query fetch status', () => {
106
112
  expect(seen).toEqual(['fetching']);
107
113
  });
108
114
  });
115
+
116
+ describe('DataModule beginFetching/endFetching refcount', () => {
117
+ let dm: DataModule<any>;
118
+ const hash = 'h1';
119
+ let seen: QueryStatus[];
120
+
121
+ beforeEach(() => {
122
+ dm = makeDataModule();
123
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
124
+ seen = [];
125
+ dm.subscribeStatus(hash, (s) => seen.push(s));
126
+ });
127
+
128
+ it('emits fetching on 0→1 and idle only on the last exit', () => {
129
+ dm.beginFetching(hash); // registration
130
+ dm.beginFetching(hash); // overlapping poll round
131
+ expect(seen).toEqual(['fetching']);
132
+
133
+ dm.endFetching(hash); // inner cycle finishes — must NOT emit idle
134
+ expect(seen).toEqual(['fetching']);
135
+ expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
136
+
137
+ dm.endFetching(hash);
138
+ expect(seen).toEqual(['fetching', 'idle']);
139
+ });
140
+
141
+ it('unbalanced endFetching settles to idle without going negative', () => {
142
+ dm.endFetching(hash); // already idle → no-op notification
143
+ expect(seen).toEqual([]);
144
+ dm.beginFetching(hash);
145
+ dm.endFetching(hash);
146
+ expect(seen).toEqual(['fetching', 'idle']);
147
+ expect((dm as any).fetchDepth.has(hash)).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe('DataModule pending stream-update flush', () => {
152
+ let dm: DataModule<any>;
153
+ const hash = 'h1';
154
+ let processed: any[];
155
+
156
+ const makeUpdate = (op: 'CREATE' | 'UPDATE' | 'DELETE') =>
157
+ ({ queryHash: hash, op, localArray: [] }) as any;
158
+
159
+ beforeEach(() => {
160
+ vi.useFakeTimers();
161
+ dm = makeDataModule();
162
+ (dm as any).activeQueries.set(hash, makeQueryState(hash));
163
+ processed = [];
164
+ (dm as any).processStreamUpdate = async (u: any) => {
165
+ processed.push(u);
166
+ };
167
+ });
168
+
169
+ afterEach(() => {
170
+ vi.useRealTimers();
171
+ });
172
+
173
+ it('flushPendingStreamUpdate processes the debounced update exactly once', async () => {
174
+ const update = makeUpdate('CREATE');
175
+ await dm.onStreamUpdate(update);
176
+ expect(processed).toEqual([]); // still debounced
177
+
178
+ await dm.flushPendingStreamUpdate(hash);
179
+ expect(processed).toEqual([update]);
180
+ expect((dm as any).debounceTimers.has(hash)).toBe(false);
181
+ expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
182
+
183
+ // The cancelled timer must not process it a second time.
184
+ await vi.runAllTimersAsync();
185
+ expect(processed).toEqual([update]);
186
+
187
+ // Nothing pending → flush is a no-op.
188
+ await dm.flushPendingStreamUpdate(hash);
189
+ expect(processed).toEqual([update]);
190
+ });
191
+
192
+ it('the trailing-edge timer clears the pending entry itself', async () => {
193
+ const update = makeUpdate('UPDATE');
194
+ await dm.onStreamUpdate(update);
195
+ await vi.runAllTimersAsync();
196
+ expect(processed).toEqual([update]);
197
+ expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
198
+ });
199
+
200
+ it('a DELETE supersedes and drops the pending coalesced update', async () => {
201
+ const create = makeUpdate('CREATE');
202
+ const del = makeUpdate('DELETE');
203
+ await dm.onStreamUpdate(create);
204
+ await dm.onStreamUpdate(del); // immediate, drops the pending CREATE
205
+ expect(processed).toEqual([del]);
206
+ expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
207
+ await vi.runAllTimersAsync();
208
+ expect(processed).toEqual([del]);
209
+ });
210
+
211
+ it('finalizeDeregister clears pending update and fetch depth', async () => {
212
+ (dm as any).cache = { unregisterQuery: () => {} };
213
+ await dm.onStreamUpdate(makeUpdate('CREATE'));
214
+ dm.beginFetching(hash);
215
+ dm.finalizeDeregister(hash);
216
+ expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
217
+ expect((dm as any).debounceTimers.has(hash)).toBe(false);
218
+ expect((dm as any).fetchDepth.has(hash)).toBe(false);
219
+ await vi.runAllTimersAsync();
220
+ expect(processed).toEqual([]);
221
+ });
222
+ });
223
+
224
+ describe('DataModule notifyQuerySynced', () => {
225
+ const hash = 'h1';
226
+
227
+ function makeDmWithLocal(rows: any[]): DataModule<any> {
228
+ const local: any = { query: async () => [rows] };
229
+ return new DataModule({} as any, local, { tables: [] } as any, makeLogger(), 100);
230
+ }
231
+
232
+ it('notifies once per registration lifetime even when records are unchanged and updateCount > 0', async () => {
233
+ const dm = makeDmWithLocal([]);
234
+ const state = makeQueryState(hash);
235
+ state.updateCount = 7; // persisted from a previous registration
236
+ (dm as any).activeQueries.set(hash, state);
237
+
238
+ let notified = 0;
239
+ dm.subscribe(hash, () => {
240
+ notified++;
241
+ });
242
+
243
+ await dm.notifyQuerySynced(hash); // empty result, unchanged — must still emit
244
+ expect(notified).toBe(1);
245
+
246
+ await dm.notifyQuerySynced(hash); // already notified this lifetime → silent
247
+ expect(notified).toBe(1);
248
+ });
249
+ });