@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.
Files changed (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +35 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. 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
+ });