@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.
- package/dist/index.d.ts +107 -5
- package/dist/index.js +437 -81
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +40 -27
- package/package.json +3 -3
- package/src/modules/data/data.authority.test.ts +122 -0
- package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
- package/src/modules/data/data.membership.test.ts +168 -68
- package/src/modules/data/index.ts +201 -83
- package/src/modules/devtools/index.ts +3 -2
- package/src/modules/sync/engine.ts +12 -7
- package/src/modules/sync/sync.health.test.ts +13 -3
- package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
- package/src/modules/sync/sync.live-removal.test.ts +137 -23
- package/src/modules/sync/sync.ts +326 -60
- package/src/modules/sync/utils.test.ts +1 -1
- package/src/modules/sync/utils.ts +10 -2
- package/src/sp00ky.ts +49 -1
- package/src/types.ts +41 -26
|
@@ -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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(() =>
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => vi.useRealTimers());
|
|
80
114
|
|
|
81
|
-
it('
|
|
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).
|
|
87
|
-
expect(
|
|
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('
|
|
91
|
-
const { notifyQuerySynced, live } =
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
});
|