@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.
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { Sp00kySync } from './sync';
4
+
5
+ /**
6
+ * The list_ref readers (registration, poll, targeted re-read) read the
7
+ * `_00_query` row's `rowCount` and publish `state` next to the edges, dedupe
8
+ * duplicate edges, and hand the DataModule's verdict back to the poll so a
9
+ * rejected snapshot (SSP still publishing, view lost) leaves the query alone.
10
+ */
11
+
12
+ function makeSync(states: Record<string, { held: Array<[string, number]> }>) {
13
+ const logger: any = {
14
+ child: () => logger,
15
+ debug: () => {},
16
+ info: () => {},
17
+ warn: () => {},
18
+ error: () => {},
19
+ trace: () => {},
20
+ };
21
+ const remote: any = { query: vi.fn() };
22
+ const queryStates: Record<string, any> = {};
23
+ for (const [hash, { held }] of Object.entries(states)) {
24
+ queryStates[hash] = {
25
+ config: {
26
+ id: new RecordId('_00_query', hash),
27
+ localArray: [],
28
+ remoteArray: held,
29
+ subqueryRemoteArray: undefined,
30
+ membershipKnown: held.length > 0,
31
+ },
32
+ };
33
+ }
34
+ const updateQueryRemoteArray = vi.fn().mockResolvedValue('applied');
35
+ const dataModule: any = {
36
+ getCurrentUserId: () => null,
37
+ getQueryByHash: (h: string) => queryStates[h],
38
+ getQueryById: (id: RecordId) => queryStates[String(id.id)],
39
+ getActiveQueryHashes: () => Object.keys(queryStates),
40
+ updateQueryRemoteArray,
41
+ scheduleRematerialize: vi.fn(),
42
+ notifyQuerySynced: vi.fn().mockResolvedValue(undefined),
43
+ };
44
+ const sync = new Sp00kySync({} as any, remote, {} as any, dataModule, {} as any, logger);
45
+ (sync as any).syncQuery = vi.fn().mockResolvedValue(undefined);
46
+ (sync as any).applySubqueryChildren = vi.fn().mockResolvedValue(undefined);
47
+ return { sync, remote, dataModule, queryStates, updateQueryRemoteArray };
48
+ }
49
+
50
+ const rid = (s: string) => {
51
+ const [table, id] = s.split(':');
52
+ return new RecordId(table, id);
53
+ };
54
+
55
+ describe('list_ref snapshot meta', () => {
56
+ beforeEach(() => vi.clearAllMocks());
57
+
58
+ it('reads the row state next to the edges and dedupes duplicate edges (single form)', async () => {
59
+ const { sync, remote } = makeSync({ h1: { held: [] } });
60
+ remote.query.mockResolvedValue([
61
+ [
62
+ { out: rid('thread:a'), version: 1 },
63
+ { out: rid('thread:a'), version: 2 },
64
+ { out: rid('thread:b'), version: 1 },
65
+ ],
66
+ { rowCount: 2, state: 'ready' },
67
+ [],
68
+ ]);
69
+
70
+ const snapshot = await (sync as any).fetchListRefSnapshot(rid('_00_query:h1'));
71
+
72
+ expect(snapshot.primary.sort()).toEqual([
73
+ ['thread:a', 2],
74
+ ['thread:b', 1],
75
+ ]);
76
+ expect(snapshot.meta).toEqual({ present: true, rowCount: 2, state: 'ready' });
77
+ expect(snapshot.rowCount).toBe(2);
78
+ });
79
+
80
+ it('reports a missing row as present: false', async () => {
81
+ const { sync, remote } = makeSync({ h1: { held: [] } });
82
+ remote.query.mockResolvedValue([[], null, []]);
83
+ const snapshot = await (sync as any).fetchListRefSnapshot(rid('_00_query:h1'));
84
+ expect(snapshot.meta).toEqual({ present: false, rowCount: null, state: null });
85
+ });
86
+
87
+ it('treats an unknown state value as no state (legacy server)', async () => {
88
+ const { sync, remote } = makeSync({ h1: { held: [] } });
89
+ remote.query.mockResolvedValue([[], { rowCount: 0 }, []]);
90
+ const snapshot = await (sync as any).fetchListRefSnapshot(rid('_00_query:h1'));
91
+ expect(snapshot.meta).toEqual({ present: true, rowCount: 0, state: null });
92
+ });
93
+
94
+ it('fills meta per query in the batched form and dedupes', async () => {
95
+ const { sync, remote } = makeSync({ h1: { held: [] }, h2: { held: [] } });
96
+ remote.query.mockResolvedValue([
97
+ [
98
+ { in: rid('_00_query:h1'), out: rid('thread:a'), version: 1, parent: null },
99
+ { in: rid('_00_query:h1'), out: rid('thread:a'), version: 3, parent: null },
100
+ { in: rid('_00_query:h2'), out: rid('thread:z'), version: 1, parent: null },
101
+ ],
102
+ [
103
+ { id: rid('_00_query:h1'), rowCount: 1, state: 'materializing' },
104
+ { id: rid('_00_query:h2'), rowCount: 1, state: 'ready' },
105
+ ],
106
+ ]);
107
+
108
+ const out = await (sync as any).fetchListRefSnapshots(['h1', 'h2']);
109
+
110
+ expect(out.get('h1').primary).toEqual([['thread:a', 3]]);
111
+ expect(out.get('h1').meta).toEqual({ present: true, rowCount: 1, state: 'materializing' });
112
+ expect(out.get('h2').meta).toEqual({ present: true, rowCount: 1, state: 'ready' });
113
+ });
114
+
115
+ it('re-reads a held query one at a time when its row is missing from the batch', async () => {
116
+ const { sync, remote } = makeSync({ h1: { held: [['thread:a', 1]] }, h2: { held: [] } });
117
+ // Batch: no edges and no row for h1, which we hold rows for.
118
+ remote.query.mockResolvedValue([[], [{ id: rid('_00_query:h2'), rowCount: 0, state: 'ready' }]]);
119
+ const single = vi.fn().mockResolvedValue({
120
+ primary: [],
121
+ subquery: [],
122
+ rowCount: null,
123
+ meta: { present: false, rowCount: null, state: null },
124
+ });
125
+ (sync as any).fetchListRefSnapshot = single;
126
+
127
+ const out = await (sync as any).fetchListRefSnapshots(['h1', 'h2']);
128
+
129
+ expect(single).toHaveBeenCalled();
130
+ expect(out.get('h1').meta.present).toBe(false);
131
+ });
132
+
133
+ describe('applyListRefSnapshot', () => {
134
+ const snapshot = (primary: Array<[string, number]>, meta: any) => ({
135
+ primary,
136
+ subquery: [],
137
+ rowCount: meta.rowCount,
138
+ meta,
139
+ });
140
+
141
+ it('hands the verdict back and touches nothing when the set is rejected', async () => {
142
+ const { sync, dataModule, updateQueryRemoteArray } = makeSync({ h1: { held: [['thread:a', 1]] } });
143
+ updateQueryRemoteArray.mockResolvedValue('ignored');
144
+
145
+ const result = await (sync as any).applyListRefSnapshot(
146
+ 'h1',
147
+ snapshot([], { present: true, rowCount: 1, state: 'materializing' })
148
+ );
149
+
150
+ expect(result).toEqual({ changed: false, outcome: 'ignored' });
151
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [], {
152
+ meta: { present: true, rowCount: 1, state: 'materializing' },
153
+ });
154
+ expect(dataModule.scheduleRematerialize).not.toHaveBeenCalled();
155
+ expect(dataModule.notifyQuerySynced).not.toHaveBeenCalled();
156
+ expect((sync as any).applySubqueryChildren).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it('does the same for a lost view', async () => {
160
+ const { sync, dataModule, updateQueryRemoteArray } = makeSync({ h1: { held: [['thread:a', 1]] } });
161
+ updateQueryRemoteArray.mockResolvedValue('view-lost');
162
+ const result = await (sync as any).applyListRefSnapshot(
163
+ 'h1',
164
+ snapshot([], { present: false, rowCount: null, state: null })
165
+ );
166
+ expect(result).toEqual({ changed: false, outcome: 'view-lost' });
167
+ expect(dataModule.notifyQuerySynced).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('re-materializes and re-renders removals when the set is applied', async () => {
171
+ const { sync, dataModule } = makeSync({ h1: { held: [['thread:a', 1], ['thread:b', 1]] } });
172
+ const result = await (sync as any).applyListRefSnapshot(
173
+ 'h1',
174
+ snapshot([['thread:a', 1]], { present: true, rowCount: 1, state: 'ready' })
175
+ );
176
+ expect(result).toEqual({ changed: true, outcome: 'applied' });
177
+ expect(dataModule.scheduleRematerialize).toHaveBeenCalledWith('h1');
178
+ expect(dataModule.notifyQuerySynced).toHaveBeenCalledWith('h1');
179
+ expect((sync as any).applySubqueryChildren).toHaveBeenCalled();
180
+ });
181
+
182
+ it('is quiet when the server set equals what is held', async () => {
183
+ const { sync, dataModule, updateQueryRemoteArray } = makeSync({ h1: { held: [['thread:a', 1]] } });
184
+ const result = await (sync as any).applyListRefSnapshot(
185
+ 'h1',
186
+ snapshot([['thread:a', 1]], { present: true, rowCount: 1, state: 'ready' })
187
+ );
188
+ expect(result).toEqual({ changed: false, outcome: 'unchanged' });
189
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
190
+ expect(dataModule.scheduleRematerialize).not.toHaveBeenCalled();
191
+ });
192
+ });
193
+ });
@@ -1,19 +1,28 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { RecordId } from 'surrealdb';
3
3
  import { Sp00kySync } from './sync';
4
4
 
5
5
  // A LIVE `_00_list_ref` DELETE is how one window learns another deleted a record.
6
6
  //
7
- // `remoteArray` — the authoritative membership rows are now rendered FROM — used
8
- // to be written only by registration and the poll, so a LIVE removal left the
9
- // departed id in the list (in memory AND persisted) until the next poll tick: up
10
- // to 5s of showing a deleted row, and on a page that then went offline, forever.
7
+ // It is also how a view teardown arrives: the TTL sweep, an SSP reset, a
8
+ // scheduler wipe and a full republish all delete a view's edges, one DELETE per
9
+ // row, indistinguishable from a peer deleting rows. Obeying them on arrival
10
+ // blanked painted lists n-1 rows at a time. So removals are held briefly,
11
+ // coalesced per query, and VERIFIED upstream before membership moves: an id
12
+ // that no longer exists is dropped (its body is deleted inside the same sync
13
+ // round); anything else is settled by re-reading the server's whole set for the
14
+ // query, where a missing `_00_query` row means "view lost, keep everything".
11
15
  //
12
- // A removal-only diff also gets no re-render for free: `runSyncForQuery` leaves
13
- // `fetching` false, and a removal needs no record fetch to trigger a stream
14
- // update, so the notify has to be forced.
16
+ // CREATE/UPDATE are applied on arrival as before.
15
17
 
16
- function makeSync(opts: { membershipKnown?: boolean } = {}) {
18
+ const COALESCE_MS = 100;
19
+
20
+ function makeSync(
21
+ opts: {
22
+ membershipKnown?: boolean;
23
+ verify?: { confirmedRemovedIds?: string[]; stillRemoteIds?: string[] };
24
+ } = {}
25
+ ) {
17
26
  const logger: any = {
18
27
  child: () => logger,
19
28
  debug: () => {},
@@ -41,6 +50,7 @@ function makeSync(opts: { membershipKnown?: boolean } = {}) {
41
50
 
42
51
  const updateQueryRemoteArray = vi.fn(async (_h: string, next: any) => {
43
52
  queryState.config.remoteArray = next;
53
+ return 'applied';
44
54
  });
45
55
  const notifyQuerySynced = vi.fn().mockResolvedValue(undefined);
46
56
  const dataModule: any = {
@@ -62,7 +72,16 @@ function makeSync(opts: { membershipKnown?: boolean } = {}) {
62
72
  logger
63
73
  );
64
74
  // `runSyncForQuery` drives the engine + scheduler; both are out of scope here.
65
- (sync as any).runSyncForQuery = vi.fn().mockResolvedValue(undefined);
75
+ // It reports which removed ids were verified gone upstream and which still
76
+ // exist there.
77
+ const runSyncForQuery = vi.fn().mockResolvedValue({
78
+ confirmedRemovedIds: opts.verify?.confirmedRemovedIds ?? [],
79
+ stillRemoteIds: opts.verify?.stillRemoteIds ?? [],
80
+ });
81
+ (sync as any).runSyncForQuery = runSyncForQuery;
82
+ // The targeted server re-read a non-confirmed removal falls back to.
83
+ const refetch = vi.fn().mockResolvedValue({ changed: false, outcome: 'unchanged' });
84
+ (sync as any).refetchListRefForQuery = refetch;
66
85
 
67
86
  const live = (action: 'CREATE' | 'UPDATE' | 'DELETE', id: string, version: number) =>
68
87
  (sync as any).handleRemoteListRefChange(
@@ -71,26 +90,111 @@ function makeSync(opts: { membershipKnown?: boolean } = {}) {
71
90
  new RecordId('thread', id),
72
91
  version
73
92
  ) as Promise<void>;
93
+ // Let the coalescing window and the zero-delay re-read timer fire.
94
+ const settle = () => vi.advanceTimersByTimeAsync(COALESCE_MS + 10);
74
95
 
75
- return { sync, queryState, updateQueryRemoteArray, notifyQuerySynced, live };
96
+ return {
97
+ sync,
98
+ queryState,
99
+ updateQueryRemoteArray,
100
+ notifyQuerySynced,
101
+ runSyncForQuery,
102
+ refetch,
103
+ live,
104
+ settle,
105
+ };
76
106
  }
77
107
 
78
108
  describe('LIVE list_ref removal → membership', () => {
79
- beforeEach(() => vi.clearAllMocks());
109
+ beforeEach(() => {
110
+ vi.useFakeTimers();
111
+ vi.clearAllMocks();
112
+ });
113
+ afterEach(() => vi.useRealTimers());
80
114
 
81
- it('drops the removed id from remoteArray immediately', async () => {
82
- const { queryState, updateQueryRemoteArray, live } = makeSync();
115
+ it('does not move membership on arrival', async () => {
116
+ const { queryState, updateQueryRemoteArray, runSyncForQuery, live } = makeSync({
117
+ verify: { confirmedRemovedIds: ['thread:b'] },
118
+ });
83
119
 
84
120
  await live('DELETE', 'b', 2);
85
121
 
86
- expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [['thread:a', 1]]);
87
- expect(queryState.config.remoteArray).toEqual([['thread:a', 1]]);
122
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
123
+ expect(runSyncForQuery).not.toHaveBeenCalled();
124
+ expect(queryState.config.remoteArray).toEqual([
125
+ ['thread:a', 1],
126
+ ['thread:b', 1],
127
+ ]);
88
128
  });
89
129
 
90
- it('forces a re-render for a removal-only diff', async () => {
91
- const { notifyQuerySynced, live } = makeSync();
130
+ it('drops a removed id once it is verified gone upstream', async () => {
131
+ const { queryState, updateQueryRemoteArray, notifyQuerySynced, runSyncForQuery, refetch, live, settle } =
132
+ makeSync({ verify: { confirmedRemovedIds: ['thread:b'] } });
133
+
92
134
  await live('DELETE', 'b', 2);
135
+ await settle();
136
+
137
+ expect(runSyncForQuery).toHaveBeenCalledTimes(1);
138
+ const [, diff] = runSyncForQuery.mock.calls[0];
139
+ expect(diff.added).toEqual([]);
140
+ expect(diff.updated).toEqual([]);
141
+ expect(diff.removed.map(String)).toEqual(['thread:b']);
142
+ expect(updateQueryRemoteArray).toHaveBeenCalledWith('h1', [['thread:a', 1]], {
143
+ verifiedRemoval: true,
144
+ });
145
+ expect(queryState.config.remoteArray).toEqual([['thread:a', 1]]);
146
+ // A removal-only diff gets no re-render for free: it has to be forced.
93
147
  expect(notifyQuerySynced).toHaveBeenCalledWith('h1');
148
+ expect(refetch).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it('keeps a row that still exists upstream and re-reads the server set instead', async () => {
152
+ // The row left this view's edges but is still there: a membership change
153
+ // to be read back as a whole set, or a view teardown to be recognised by
154
+ // its missing `_00_query` row. Either way, not a blind removal.
155
+ const { queryState, updateQueryRemoteArray, notifyQuerySynced, refetch, live, settle } = makeSync({
156
+ verify: { stillRemoteIds: ['thread:b'] },
157
+ });
158
+
159
+ await live('DELETE', 'b', 2);
160
+ await settle();
161
+
162
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
163
+ expect(notifyQuerySynced).not.toHaveBeenCalled();
164
+ expect(queryState.config.remoteArray).toEqual([
165
+ ['thread:a', 1],
166
+ ['thread:b', 1],
167
+ ]);
168
+ expect(refetch).toHaveBeenCalledWith('h1');
169
+ });
170
+
171
+ it('coalesces a burst of removals into one verification', async () => {
172
+ // A sweep or a republish delivers a view's whole set within a few ms.
173
+ const { runSyncForQuery, live, settle } = makeSync({
174
+ verify: { confirmedRemovedIds: ['thread:a', 'thread:b'] },
175
+ });
176
+
177
+ await live('DELETE', 'a', 2);
178
+ await vi.advanceTimersByTimeAsync(30);
179
+ await live('DELETE', 'b', 2);
180
+ await live('DELETE', 'b', 2);
181
+ await settle();
182
+
183
+ expect(runSyncForQuery).toHaveBeenCalledTimes(1);
184
+ const [, diff] = runSyncForQuery.mock.calls[0];
185
+ expect(diff.removed.map(String).sort()).toEqual(['thread:a', 'thread:b']);
186
+ });
187
+
188
+ it('falls back to a server re-read when the verification could not run', async () => {
189
+ // `handleRemovedRecords` answers with nothing on a failed existence check:
190
+ // neither confirmed nor still-remote. Nothing may be dropped on that.
191
+ const { updateQueryRemoteArray, refetch, live, settle } = makeSync({ verify: {} });
192
+
193
+ await live('DELETE', 'b', 2);
194
+ await settle();
195
+
196
+ expect(updateQueryRemoteArray).not.toHaveBeenCalled();
197
+ expect(refetch).toHaveBeenCalledWith('h1');
94
198
  });
95
199
 
96
200
  it('does not force a re-render when rows were added (the stream update covers it)', async () => {
@@ -116,7 +220,7 @@ describe('LIVE list_ref removal → membership', () => {
116
220
  // fetch diff is empty. Membership still has to be recorded, or the row lives
117
221
  // on the settled-write grace alone until the poll catches it.
118
222
  it('adds membership for a row the circuit already holds at the server version', async () => {
119
- const { queryState, updateQueryRemoteArray, sync, live } = makeSync();
223
+ const { queryState, updateQueryRemoteArray, runSyncForQuery, live } = makeSync();
120
224
  queryState.config.localArray = [
121
225
  ['thread:a', 1],
122
226
  ['thread:b', 1],
@@ -131,7 +235,7 @@ describe('LIVE list_ref removal → membership', () => {
131
235
  ['thread:c', 1],
132
236
  ]);
133
237
  // …without a refetch: the diff handed to the sync is empty.
134
- expect((sync as any).runSyncForQuery).toHaveBeenCalledWith('h1', {
238
+ expect(runSyncForQuery).toHaveBeenCalledWith('h1', {
135
239
  added: [],
136
240
  updated: [],
137
241
  removed: [],
@@ -153,14 +257,22 @@ describe('LIVE list_ref removal → membership', () => {
153
257
 
154
258
  it('leaves membership alone while it is still unknown', async () => {
155
259
  // Nothing authoritative has arrived yet, so there is no list to amend —
156
- // registration will supply the whole thing shortly.
157
- const { updateQueryRemoteArray, live } = makeSync({ membershipKnown: false });
260
+ // registration will supply the whole thing shortly. The body delete of a
261
+ // verified-gone row still happens inside the sync round.
262
+ const { updateQueryRemoteArray, runSyncForQuery, live, settle } = makeSync({
263
+ membershipKnown: false,
264
+ verify: { confirmedRemovedIds: ['thread:b'] },
265
+ });
266
+
158
267
  await live('DELETE', 'b', 2);
268
+ await settle();
269
+
270
+ expect(runSyncForQuery).toHaveBeenCalledTimes(1);
159
271
  expect(updateQueryRemoteArray).not.toHaveBeenCalled();
160
272
  });
161
273
 
162
274
  it('is a no-op for an unknown query', async () => {
163
- const { sync, updateQueryRemoteArray } = makeSync();
275
+ const { sync, updateQueryRemoteArray, runSyncForQuery, settle } = makeSync();
164
276
  (sync as any).dataModule.getQueryById = vi.fn().mockReturnValue(undefined);
165
277
 
166
278
  await (sync as any).handleRemoteListRefChange(
@@ -169,7 +281,9 @@ describe('LIVE list_ref removal → membership', () => {
169
281
  new RecordId('thread', 'b'),
170
282
  2
171
283
  );
284
+ await settle();
172
285
 
173
286
  expect(updateQueryRemoteArray).not.toHaveBeenCalled();
287
+ expect(runSyncForQuery).not.toHaveBeenCalled();
174
288
  });
175
289
  });