@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201
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/AGENTS.md +57 -0
- package/dist/index.d.ts +2184 -54
- package/dist/index.js +11515 -2399
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +276 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +421 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +434 -0
- package/dist/types.d.ts +688 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +35 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +112 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +462 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1580 -130
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +386 -37
- package/src/modules/devtools/notify-throttle.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +107 -0
- package/src/modules/sync/queue/queue-down.ts +35 -6
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +156 -0
- package/src/modules/sync/scheduler.ts +158 -11
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +134 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1558 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +160 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +32 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +558 -0
- package/src/services/database/sqlite-cache-engine.ts +1257 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +121 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +221 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +141 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +444 -52
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +23 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +278 -0
- package/src/services/tabs/coordinator.test.ts +244 -0
- package/src/services/tabs/coordinator.ts +576 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +242 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +586 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1543 -0
- package/src/types.ts +496 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +73 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
5
|
+
import type { QueryState, RecordVersionArray } from '../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A query's rows are its MEMBERSHIP — the id-set the server put in
|
|
9
|
+
* `_00_list_ref` — not "every locally cached body that matches the WHERE".
|
|
10
|
+
*
|
|
11
|
+
* Those two disagree whenever a row leaves a query's window while still existing
|
|
12
|
+
* upstream: `SyncEngine.handleRemovedRecords` keeps the local body and never
|
|
13
|
+
* re-fetches it, so a predicate re-scan finds that stale body still matching and
|
|
14
|
+
* keeps rendering the row. That is the "removed item comes back" bug, and it was
|
|
15
|
+
* permanent offline because membership used to live only on the session-salted
|
|
16
|
+
* `_00_query` row (wiped on reload) while bodies were durable.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const noop = () => {};
|
|
20
|
+
|
|
21
|
+
function makeLogger(): any {
|
|
22
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
23
|
+
logger.child = () => logger;
|
|
24
|
+
return logger;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const schema = { tables: [{ name: 'thread', columns: {} }] } as any;
|
|
28
|
+
|
|
29
|
+
const plan: QueryPlan = { table: 'thread', where: [['done', '=', false]] } as any;
|
|
30
|
+
|
|
31
|
+
function makeQueryState(
|
|
32
|
+
hash: string,
|
|
33
|
+
opts: {
|
|
34
|
+
remoteArray?: RecordVersionArray;
|
|
35
|
+
localArray?: RecordVersionArray;
|
|
36
|
+
membershipKnown?: boolean;
|
|
37
|
+
membershipKey?: string;
|
|
38
|
+
withPlan?: boolean;
|
|
39
|
+
} = {}
|
|
40
|
+
): QueryState {
|
|
41
|
+
return {
|
|
42
|
+
config: {
|
|
43
|
+
id: new RecordId('_00_query', hash),
|
|
44
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
45
|
+
plan: opts.withPlan === false ? undefined : plan,
|
|
46
|
+
params: {},
|
|
47
|
+
localArray: opts.localArray ?? [],
|
|
48
|
+
remoteArray: opts.remoteArray ?? [],
|
|
49
|
+
membershipKnown: opts.membershipKnown,
|
|
50
|
+
membershipKey: opts.membershipKey,
|
|
51
|
+
ttl: '10m',
|
|
52
|
+
lastActiveAt: new Date(),
|
|
53
|
+
tableName: 'thread',
|
|
54
|
+
},
|
|
55
|
+
records: [],
|
|
56
|
+
ttlTimer: null,
|
|
57
|
+
ttlDurationMs: 0,
|
|
58
|
+
updateCount: 0,
|
|
59
|
+
lastUpdatedAt: null,
|
|
60
|
+
materializationSamples: [],
|
|
61
|
+
lastIngestLatencyMs: null,
|
|
62
|
+
errorCount: 0,
|
|
63
|
+
status: 'idle',
|
|
64
|
+
phaseSamples: {},
|
|
65
|
+
phaseLast: {},
|
|
66
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Local store holding three thread bodies, ALL of which match the query's
|
|
72
|
+
* predicate. `select` mimics the real engines: when `plan.ids` is set it returns
|
|
73
|
+
* exactly those ids (the membership path); otherwise it returns everything (the
|
|
74
|
+
* predicate-scan path).
|
|
75
|
+
*/
|
|
76
|
+
function makeLocal(pendingRows: Array<{ recordId: RecordId; mutationType: string }> = []) {
|
|
77
|
+
const bodies = new Map(
|
|
78
|
+
['a', 'b', 'c'].map((k) => [`thread:${k}`, { id: new RecordId('thread', k), title: k }])
|
|
79
|
+
);
|
|
80
|
+
const windowRows = new Map<string, { ids: RecordVersionArray }>();
|
|
81
|
+
const local: any = {
|
|
82
|
+
epoch: 1,
|
|
83
|
+
bodies,
|
|
84
|
+
windowRows,
|
|
85
|
+
select: vi.fn(async (p: QueryPlan) => {
|
|
86
|
+
if (p.ids) {
|
|
87
|
+
return (p.ids as RecordId[])
|
|
88
|
+
.map((id) => bodies.get(id.toString()))
|
|
89
|
+
.filter((r): r is NonNullable<typeof r> => r !== undefined);
|
|
90
|
+
}
|
|
91
|
+
return Array.from(bodies.values());
|
|
92
|
+
}),
|
|
93
|
+
query: vi.fn(async (sql: string, vars?: any) => {
|
|
94
|
+
if (sql.includes('_00_pending_mutations')) return [pendingRows];
|
|
95
|
+
// `createNewQuery` reads the `_00_query` row, then CREATEs it when absent.
|
|
96
|
+
// A reload always takes the absent path: the id is session-salted.
|
|
97
|
+
if (sql.includes('FROM ONLY')) return [null];
|
|
98
|
+
if (sql.startsWith('CREATE')) return [{ id: vars.id, ...vars.data }];
|
|
99
|
+
if (sql.startsWith('UPDATE')) return [null];
|
|
100
|
+
return [Array.from(bodies.values())];
|
|
101
|
+
}),
|
|
102
|
+
getById: vi.fn(async (_table: string, id: RecordId) => windowRows.get(String(id.id)) ?? null),
|
|
103
|
+
upsert: vi.fn(async (_t: string, id: RecordId, data: any) => {
|
|
104
|
+
windowRows.set(String(id.id), data);
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
return local;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setup(
|
|
111
|
+
stateOpts: Parameters<typeof makeQueryState>[1] = {},
|
|
112
|
+
pendingRows: Array<{ recordId: RecordId; mutationType: string }> = []
|
|
113
|
+
) {
|
|
114
|
+
const hash = 'h1';
|
|
115
|
+
const local = makeLocal(pendingRows);
|
|
116
|
+
const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
|
|
117
|
+
const state = makeQueryState(hash, stateOpts);
|
|
118
|
+
(dm as any).activeQueries.set(hash, state);
|
|
119
|
+
return { dm, state, local, hash };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ids = (rows: Array<Record<string, any>>) => rows.map((r) => String(r.id));
|
|
123
|
+
const materialize = (dm: DataModule<any>, state: QueryState, ssp?: RecordVersionArray) =>
|
|
124
|
+
(dm as any).materializeRecords(state, ssp) as Promise<Record<string, any>[]>;
|
|
125
|
+
|
|
126
|
+
describe('membership-authoritative rendering', () => {
|
|
127
|
+
it('hides a locally cached body that is absent from membership', async () => {
|
|
128
|
+
// `thread:c` still matches the predicate and its body is still cached, but
|
|
129
|
+
// the server dropped it from the window. It must not render.
|
|
130
|
+
const { dm, state } = setup({
|
|
131
|
+
membershipKnown: true,
|
|
132
|
+
remoteArray: [
|
|
133
|
+
['thread:a', 1],
|
|
134
|
+
['thread:b', 1],
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(ids(await materialize(dm, state))).toEqual(['thread:a', 'thread:b']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('renders an empty list when membership is known and empty', async () => {
|
|
142
|
+
const { dm, state } = setup({ membershipKnown: true, remoteArray: [] });
|
|
143
|
+
expect(await materialize(dm, state)).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('falls back to the predicate scan when membership was never established', async () => {
|
|
147
|
+
// A query first run on this device: there is nothing authoritative to render
|
|
148
|
+
// from, so an offline first paint must still show the cached bodies.
|
|
149
|
+
const { dm, state, local } = setup({ membershipKnown: false });
|
|
150
|
+
expect(ids(await materialize(dm, state))).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
151
|
+
expect(local.select).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({ where: plan.where }),
|
|
153
|
+
expect.anything()
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('drops where/limit/offset and keeps orderBy on the membership path', async () => {
|
|
158
|
+
const { dm, state, local } = setup({ membershipKnown: true, remoteArray: [['thread:a', 1]] });
|
|
159
|
+
state.config.plan = { ...plan, limit: 50, offset: 0, orderBy: [['title', 'asc']] } as any;
|
|
160
|
+
|
|
161
|
+
await materialize(dm, state);
|
|
162
|
+
|
|
163
|
+
const passed = local.select.mock.calls.at(-1)![0] as QueryPlan;
|
|
164
|
+
expect(passed.where).toBeUndefined();
|
|
165
|
+
expect(passed.limit).toBeUndefined();
|
|
166
|
+
expect(passed.offset).toBeUndefined();
|
|
167
|
+
expect(passed.orderBy).toEqual([['title', 'asc']]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('optimistic writes', () => {
|
|
171
|
+
it('renders a pending create the server has not acknowledged yet', async () => {
|
|
172
|
+
// Not in remoteArray, but the SSP (fed every local write) says it matches.
|
|
173
|
+
const { dm, state } = setup(
|
|
174
|
+
{
|
|
175
|
+
membershipKnown: true,
|
|
176
|
+
remoteArray: [['thread:a', 1]],
|
|
177
|
+
localArray: [
|
|
178
|
+
['thread:a', 1],
|
|
179
|
+
['thread:c', 1],
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
[{ recordId: new RecordId('thread', 'c'), mutationType: 'create' }]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(ids(await materialize(dm, state))).toEqual(['thread:a', 'thread:c']);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('hides a pending update that moved a row OUT of the window', async () => {
|
|
189
|
+
// Pending write, but the SSP no longer lists it: the local body stopped
|
|
190
|
+
// matching. Unioning pending ids blindly would wrongly re-admit it.
|
|
191
|
+
const { dm, state } = setup(
|
|
192
|
+
{
|
|
193
|
+
membershipKnown: true,
|
|
194
|
+
remoteArray: [['thread:a', 1]],
|
|
195
|
+
localArray: [['thread:a', 1]],
|
|
196
|
+
},
|
|
197
|
+
[{ recordId: new RecordId('thread', 'c'), mutationType: 'update' }]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(ids(await materialize(dm, state))).toEqual(['thread:a']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('hides a row the server still lists while our DELETE is queued', async () => {
|
|
204
|
+
const { dm, state } = setup(
|
|
205
|
+
{
|
|
206
|
+
membershipKnown: true,
|
|
207
|
+
remoteArray: [
|
|
208
|
+
['thread:a', 1],
|
|
209
|
+
['thread:b', 1],
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
[{ recordId: new RecordId('thread', 'b'), mutationType: 'delete' }]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(ids(await materialize(dm, state))).toEqual(['thread:a']);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('prefers the fresher sspArray over the persisted localArray', async () => {
|
|
219
|
+
const { dm, state } = setup(
|
|
220
|
+
{ membershipKnown: true, remoteArray: [['thread:a', 1]], localArray: [] },
|
|
221
|
+
[{ recordId: new RecordId('thread', 'c'), mutationType: 'create' }]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const rows = await materialize(dm, state, [
|
|
225
|
+
['thread:a', 1],
|
|
226
|
+
['thread:c', 1],
|
|
227
|
+
]);
|
|
228
|
+
expect(ids(rows)).toEqual(['thread:a', 'thread:c']);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('render order', () => {
|
|
233
|
+
it('sorts an unordered query so both paints agree', async () => {
|
|
234
|
+
// `_00_list_ref` is selected without an ORDER BY, so membership arrives
|
|
235
|
+
// shuffled. The first paint came from the local scan in id order, so
|
|
236
|
+
// rendering server order here is what made lists visibly reorder about a
|
|
237
|
+
// second after load.
|
|
238
|
+
const { dm, hash, state } = setup();
|
|
239
|
+
state.config.plan = { table: 'thread', where: [['done', '=', false]] } as any;
|
|
240
|
+
|
|
241
|
+
const ids = await (dm as any).buildRenderIds(state.config, [
|
|
242
|
+
['thread:c', 1],
|
|
243
|
+
['thread:a', 1],
|
|
244
|
+
['thread:b', 1],
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
expect(ids.map(String)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('leaves an explicitly ordered query to the engine', async () => {
|
|
251
|
+
const { dm, state } = setup();
|
|
252
|
+
state.config.plan = { table: 'thread', orderBy: [['created', 'desc']] } as any;
|
|
253
|
+
|
|
254
|
+
const ids = await (dm as any).buildRenderIds(state.config, [
|
|
255
|
+
['thread:c', 1],
|
|
256
|
+
['thread:a', 1],
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
// Untouched: the engine applies the ORDER BY over the id set.
|
|
260
|
+
expect(ids.map(String)).toEqual(['thread:c', 'thread:a']);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('preserves the slice order of a windowed query', async () => {
|
|
264
|
+
// For a window the id-set order IS the window: sorting it would reorder
|
|
265
|
+
// rows within the page.
|
|
266
|
+
const { dm, state } = setup();
|
|
267
|
+
state.config.surql = 'SELECT * FROM thread LIMIT 50 START 100;';
|
|
268
|
+
state.config.plan = { table: 'thread' } as any;
|
|
269
|
+
|
|
270
|
+
const ids = await (dm as any).buildRenderIds(state.config, [
|
|
271
|
+
['thread:c', 1],
|
|
272
|
+
['thread:a', 1],
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
expect(ids.map(String)).toEqual(['thread:c', 'thread:a']);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('durability across a reload', () => {
|
|
280
|
+
it('updateQueryRemoteArray latches membership and writes _00_window', async () => {
|
|
281
|
+
const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
282
|
+
|
|
283
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
284
|
+
|
|
285
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
286
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [['thread:a', 1]] });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('seeds membership from _00_window under a different session salt', async () => {
|
|
290
|
+
// The reload case: `_00_query` is keyed by a `session::id()`-salted hash, so
|
|
291
|
+
// a reload always lands on a fresh row with empty arrays. Only the durable
|
|
292
|
+
// row, keyed session-independently, can carry the removal across.
|
|
293
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
294
|
+
local.windowRows.set('stable-key', { ids: [['thread:a', 1]] });
|
|
295
|
+
|
|
296
|
+
const fresh = await (dm as any).createNewQuery({
|
|
297
|
+
recordId: new RecordId('_00_query', 'a-totally-different-salted-hash'),
|
|
298
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
299
|
+
params: {},
|
|
300
|
+
ttl: '10m',
|
|
301
|
+
tableName: 'thread',
|
|
302
|
+
plan,
|
|
303
|
+
membershipKey: 'stable-key',
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(fresh.config.membershipKnown).toBe(true);
|
|
307
|
+
expect(fresh.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
308
|
+
// `thread:b`/`thread:c` bodies are still cached and still match, but are
|
|
309
|
+
// not in the persisted list — the reported bug, offline.
|
|
310
|
+
expect(ids(fresh.records)).toEqual(['thread:a']);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('stays scan-based when no durable row exists', async () => {
|
|
314
|
+
const { dm } = setup({ membershipKey: 'never-written' });
|
|
315
|
+
|
|
316
|
+
const fresh = await (dm as any).createNewQuery({
|
|
317
|
+
recordId: new RecordId('_00_query', 'h-cold'),
|
|
318
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
319
|
+
params: {},
|
|
320
|
+
ttl: '10m',
|
|
321
|
+
tableName: 'thread',
|
|
322
|
+
plan,
|
|
323
|
+
membershipKey: 'never-written',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(fresh.config.membershipKnown).toBeFalsy();
|
|
327
|
+
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('ignores an empty id-set until a real one has been seen', async () => {
|
|
331
|
+
// The registration race: the SSP queues a view's initial edges to a
|
|
332
|
+
// coalescing flusher and returns from `fn::query::register` before they
|
|
333
|
+
// land, so this read says nothing about the query being empty. Believing
|
|
334
|
+
// it rendered a blank list AND persisted the blankness.
|
|
335
|
+
const { dm, state, local, hash } = setup({
|
|
336
|
+
membershipKey: 'stable-key',
|
|
337
|
+
remoteArray: [['thread:a', 1]],
|
|
338
|
+
membershipKnown: true,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
342
|
+
|
|
343
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
344
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('believes an empty id-set once a non-empty one has arrived', async () => {
|
|
348
|
+
// The genuine transition — the last row left the window. Honouring it is
|
|
349
|
+
// what stops a removed row resurrecting from the local body cache.
|
|
350
|
+
const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
|
|
351
|
+
|
|
352
|
+
await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
|
|
353
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
354
|
+
|
|
355
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
356
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
357
|
+
expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [] });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('believes a repeated empty id-set even with nothing seen this session', async () => {
|
|
361
|
+
// A window that really did empty while this device was away: the durable
|
|
362
|
+
// seed must not render forever, so the second read is taken at face value.
|
|
363
|
+
const { dm, state, hash } = setup({
|
|
364
|
+
membershipKey: 'stable-key',
|
|
365
|
+
remoteArray: [['thread:a', 1]],
|
|
366
|
+
membershipKnown: true,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
370
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
371
|
+
|
|
372
|
+
await dm.updateQueryRemoteArray(hash, []);
|
|
373
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('ignores an empty id-set while the server still reports rows', async () => {
|
|
377
|
+
// The reported failure: registration returns before the SSP flushes the
|
|
378
|
+
// view's edges, and the poll 500ms later is still inside that window. A
|
|
379
|
+
// retry counter believes the second read and blanks the list ~2s after
|
|
380
|
+
// load; `rowCount` says the query has 26 rows, so no number of empty
|
|
381
|
+
// reads should ever be taken as "empty".
|
|
382
|
+
const { dm, state, local, hash } = setup({
|
|
383
|
+
membershipKey: 'stable-key',
|
|
384
|
+
remoteArray: [['thread:a', 1]],
|
|
385
|
+
membershipKnown: true,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
for (let i = 0; i < 5; i++) {
|
|
389
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 26 });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
393
|
+
expect(local.windowRows.has('stable-key')).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('believes an empty id-set the moment the server reports zero rows', async () => {
|
|
397
|
+
// The other half: a genuinely empty query must not sit on a stale seed
|
|
398
|
+
// waiting for a retry budget to run out.
|
|
399
|
+
const { dm, state, hash } = setup({
|
|
400
|
+
membershipKey: 'stable-key',
|
|
401
|
+
remoteArray: [['thread:a', 1]],
|
|
402
|
+
membershipKnown: true,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
|
|
406
|
+
|
|
407
|
+
expect(state.config.membershipKnown).toBe(true);
|
|
408
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('falls back to the retry budget when the row count is unreadable', async () => {
|
|
412
|
+
// Older servers, or a row the client cannot select: unknown must not
|
|
413
|
+
// strand the device on its durable seed forever.
|
|
414
|
+
const { dm, state, hash } = setup({
|
|
415
|
+
membershipKey: 'stable-key',
|
|
416
|
+
remoteArray: [['thread:a', 1]],
|
|
417
|
+
membershipKnown: true,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
421
|
+
expect(state.config.remoteArray).toEqual([['thread:a', 1]]);
|
|
422
|
+
|
|
423
|
+
await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
|
|
424
|
+
expect(state.config.remoteArray).toEqual([]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('does not seed membership from an empty durable row', async () => {
|
|
428
|
+
// Self-heals devices poisoned before the guard existed: an empty durable
|
|
429
|
+
// row is indistinguishable from "never had membership", so it must fall
|
|
430
|
+
// back to the scan rather than paint an empty list.
|
|
431
|
+
const { dm, local } = setup({ membershipKey: 'stable-key' });
|
|
432
|
+
local.windowRows.set('stable-key', { ids: [] });
|
|
433
|
+
|
|
434
|
+
const fresh = await (dm as any).createNewQuery({
|
|
435
|
+
recordId: new RecordId('_00_query', 'h-poisoned'),
|
|
436
|
+
surql: 'SELECT * FROM thread WHERE done = false;',
|
|
437
|
+
params: {},
|
|
438
|
+
ttl: '10m',
|
|
439
|
+
tableName: 'thread',
|
|
440
|
+
plan,
|
|
441
|
+
membershipKey: 'stable-key',
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(fresh.config.membershipKnown).toBeFalsy();
|
|
445
|
+
expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('derives the membership key without the session salt', async () => {
|
|
449
|
+
const { dm } = setup();
|
|
450
|
+
const key = () => (dm as any).calculateMembershipKey({ surql: 'X', params: {} });
|
|
451
|
+
|
|
452
|
+
const before = await key();
|
|
453
|
+
dm.setSessionId('session-two');
|
|
454
|
+
expect(await key()).toBe(before);
|
|
455
|
+
|
|
456
|
+
// ...whereas the `_00_query` hash intentionally moves with the session.
|
|
457
|
+
const salted = await (dm as any).calculateHash({ surql: 'X', params: {} });
|
|
458
|
+
dm.setSessionId('session-three');
|
|
459
|
+
expect(await (dm as any).calculateHash({ surql: 'X', params: {} })).not.toBe(salted);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
@@ -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
|
+
});
|