@spooky-sync/core 0.0.1-canary.161 → 0.0.1-canary.162

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,297 @@
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('durability across a reload', () => {
233
+ it('updateQueryRemoteArray latches membership and writes _00_window', async () => {
234
+ const { dm, state, local, hash } = setup({ membershipKey: 'stable-key' });
235
+
236
+ await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
237
+
238
+ expect(state.config.membershipKnown).toBe(true);
239
+ expect(local.windowRows.get('stable-key')).toMatchObject({ ids: [['thread:a', 1]] });
240
+ });
241
+
242
+ it('seeds membership from _00_window under a different session salt', async () => {
243
+ // The reload case: `_00_query` is keyed by a `session::id()`-salted hash, so
244
+ // a reload always lands on a fresh row with empty arrays. Only the durable
245
+ // row, keyed session-independently, can carry the removal across.
246
+ const { dm, local } = setup({ membershipKey: 'stable-key' });
247
+ local.windowRows.set('stable-key', { ids: [['thread:a', 1]] });
248
+
249
+ const fresh = await (dm as any).createNewQuery({
250
+ recordId: new RecordId('_00_query', 'a-totally-different-salted-hash'),
251
+ surql: 'SELECT * FROM thread WHERE done = false;',
252
+ params: {},
253
+ ttl: '10m',
254
+ tableName: 'thread',
255
+ plan,
256
+ membershipKey: 'stable-key',
257
+ });
258
+
259
+ expect(fresh.config.membershipKnown).toBe(true);
260
+ expect(fresh.config.remoteArray).toEqual([['thread:a', 1]]);
261
+ // `thread:b`/`thread:c` bodies are still cached and still match, but are
262
+ // not in the persisted list — the reported bug, offline.
263
+ expect(ids(fresh.records)).toEqual(['thread:a']);
264
+ });
265
+
266
+ it('stays scan-based when no durable row exists', async () => {
267
+ const { dm } = setup({ membershipKey: 'never-written' });
268
+
269
+ const fresh = await (dm as any).createNewQuery({
270
+ recordId: new RecordId('_00_query', 'h-cold'),
271
+ surql: 'SELECT * FROM thread WHERE done = false;',
272
+ params: {},
273
+ ttl: '10m',
274
+ tableName: 'thread',
275
+ plan,
276
+ membershipKey: 'never-written',
277
+ });
278
+
279
+ expect(fresh.config.membershipKnown).toBeFalsy();
280
+ expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
281
+ });
282
+
283
+ it('derives the membership key without the session salt', async () => {
284
+ const { dm } = setup();
285
+ const key = () => (dm as any).calculateMembershipKey({ surql: 'X', params: {} });
286
+
287
+ const before = await key();
288
+ dm.setSessionId('session-two');
289
+ expect(await key()).toBe(before);
290
+
291
+ // ...whereas the `_00_query` hash intentionally moves with the session.
292
+ const salted = await (dm as any).calculateHash({ surql: 'X', params: {} });
293
+ dm.setSessionId('session-three');
294
+ expect(await (dm as any).calculateHash({ surql: 'X', params: {} })).not.toBe(salted);
295
+ });
296
+ });
297
+ });
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { QueryPlan } from '@spooky-sync/query-builder';
3
+ import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterializationPlan } from './window-query';
4
+ import { executeSelect, type SelectDb } from '../../services/database/sqlite-select';
5
+ import type { Row } from '../../services/database/cache-engine';
6
+
7
+ // `buildIdSetPlan` is what makes membership-authoritative rendering possible on
8
+ // any local engine: it turns a query's plan into "these exact ids, ordered,
9
+ // relations intact". Dropping `where` is the point — re-applying the predicate
10
+ // against stale local bodies is what let removed rows keep rendering.
11
+
12
+ const BASE: QueryPlan = {
13
+ table: 'thread',
14
+ where: [{ field: 'done', op: '=', value: false }],
15
+ limit: 50,
16
+ offset: 0,
17
+ orderBy: [['title', 'asc']],
18
+ relations: [
19
+ { alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'thread' },
20
+ ],
21
+ } as any;
22
+
23
+ describe('buildIdSetPlan', () => {
24
+ it('binds the id-set and drops where/limit/offset, keeping orderBy + relations', () => {
25
+ const out = buildIdSetPlan(BASE, ['thread:A']);
26
+ expect(out.ids).toEqual(['thread:A']);
27
+ expect(out.where).toBeUndefined();
28
+ expect(out.limit).toBeUndefined();
29
+ expect(out.offset).toBeUndefined();
30
+ expect(out.orderBy).toEqual([['title', 'asc']]);
31
+ expect(out.relations).toEqual(BASE.relations);
32
+ expect(out.table).toBe('thread');
33
+ });
34
+
35
+ it('applies to non-offset plans, unlike the windowed-only helper', () => {
36
+ // The windowed helper deliberately bails so the old caller kept its scan;
37
+ // membership rendering needs the same rewrite for every query.
38
+ expect(buildWindowMaterializationPlan(BASE, ['thread:A'])).toBeNull();
39
+ expect(buildIdSetPlan(BASE, ['thread:A']).ids).toEqual(['thread:A']);
40
+ });
41
+
42
+ it('does not mutate the input plan', () => {
43
+ buildIdSetPlan(BASE, ['thread:A']);
44
+ expect(BASE.where).toEqual([{ field: 'done', op: '=', value: false }]);
45
+ expect(BASE.limit).toBe(50);
46
+ });
47
+ });
48
+
49
+ describe('buildIdSetSurql', () => {
50
+ it('rewrites a non-offset query to the id-set source, keeping ORDER BY', () => {
51
+ const out = buildIdSetSurql('SELECT * FROM thread WHERE done = false ORDER BY title ASC LIMIT 50;');
52
+ expect(out?.query).toBe('SELECT * FROM $__win ORDER BY title ASC');
53
+ });
54
+
55
+ it('preserves the projection', () => {
56
+ const out = buildIdSetSurql('SELECT id, title FROM thread WHERE done = false;');
57
+ expect(out?.query).toBe('SELECT id, title FROM $__win');
58
+ });
59
+
60
+ it('returns null when there is no top-level FROM to replace', () => {
61
+ expect(buildIdSetSurql('RETURN true;')).toBeNull();
62
+ });
63
+ });
64
+
65
+ // ---- Engine parity -------------------------------------------------------
66
+ //
67
+ // Both local engines short-circuit to `selectByIds` when `plan.ids` is set. This
68
+ // pins that the id-set plan really does filter AND still resolves `.related()`
69
+ // on the SQLite engine (the `where`-dropping path the SurrealDB engine shares).
70
+
71
+ const FIXTURE: Record<string, Row[]> = {
72
+ thread: [
73
+ { id: 'thread:A', title: 'a thread', done: false },
74
+ { id: 'thread:B', title: 'b thread', done: false },
75
+ ],
76
+ comment: [
77
+ { id: 'comment:1', thread: 'thread:A', body: 'on A' },
78
+ { id: 'comment:2', thread: 'thread:B', body: 'on B' },
79
+ ],
80
+ };
81
+
82
+ function makeSelectDb(): SelectDb {
83
+ const respond = (sql: string, bind: unknown[] = []): { data: string }[] => {
84
+ const table = /FROM "([^"]+)"/.exec(sql)?.[1];
85
+ let rows = table ? (FIXTURE[table] ?? []) : [];
86
+ const rel = /WHERE json_extract\(data, '\$\.([A-Za-z0-9_.]+)'\) IN/.exec(sql);
87
+ if (rel) rows = rows.filter((r) => bind.includes(r[rel[1]]));
88
+ else if (/WHERE id IN/.test(sql)) rows = rows.filter((r) => bind.includes(r.id));
89
+ return rows.map((r) => ({ data: JSON.stringify(r) }));
90
+ };
91
+ return {
92
+ exec: (sql, bind) => respond(sql, bind ?? []),
93
+ run: () => {},
94
+ knownTables: new Set<string>(),
95
+ };
96
+ }
97
+
98
+ describe('id-set plan on the SQLite engine', () => {
99
+ it('returns only the id-set even though both bodies match the predicate', async () => {
100
+ const { rows } = await executeSelect(
101
+ buildIdSetPlan(BASE, ['thread:A']),
102
+ {},
103
+ makeSelectDb()
104
+ );
105
+ expect(rows.map((r) => r.id)).toEqual(['thread:A']);
106
+ });
107
+
108
+ it('still resolves .related() children for the id-set rows', async () => {
109
+ const { rows, relationFetches } = await executeSelect(
110
+ buildIdSetPlan(BASE, ['thread:A']),
111
+ {},
112
+ makeSelectDb()
113
+ );
114
+ expect(relationFetches).toBe(1);
115
+ expect((rows[0] as any).comments.map((c: Row) => c.id)).toEqual(['comment:1']);
116
+ });
117
+
118
+ it('renders nothing for an empty id-set (known-and-empty membership)', async () => {
119
+ const { rows } = await executeSelect(buildIdSetPlan(BASE, []), {}, makeSelectDb());
120
+ expect(rows).toEqual([]);
121
+ });
122
+ });