@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.211

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 (163) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2514 -58
  3. package/dist/index.js +12561 -2449
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +303 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +439 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +472 -0
  11. package/dist/types.d.ts +751 -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 +36 -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/auth.local-first.test.ts +101 -0
  25. package/src/modules/auth/events/index.ts +2 -1
  26. package/src/modules/auth/index.ts +127 -24
  27. package/src/modules/cache/cache.relay.test.ts +95 -0
  28. package/src/modules/cache/index.ts +163 -43
  29. package/src/modules/cache/types.ts +2 -2
  30. package/src/modules/crdt/crdt-field.ts +294 -0
  31. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  32. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  33. package/src/modules/crdt/index.ts +463 -0
  34. package/src/modules/crdt/loro-loader.ts +25 -0
  35. package/src/modules/data/data.hydration.test.ts +142 -0
  36. package/src/modules/data/data.membership.test.ts +523 -0
  37. package/src/modules/data/data.notify-table.test.ts +41 -0
  38. package/src/modules/data/data.pending-ids.test.ts +199 -0
  39. package/src/modules/data/data.rebind.test.ts +170 -0
  40. package/src/modules/data/data.rematerialize.test.ts +114 -0
  41. package/src/modules/data/data.run.test.ts +113 -0
  42. package/src/modules/data/data.settled-writes.test.ts +206 -0
  43. package/src/modules/data/data.status.test.ts +249 -0
  44. package/src/modules/data/id-set-plan.test.ts +122 -0
  45. package/src/modules/data/index.ts +1815 -151
  46. package/src/modules/data/mutation-id.test.ts +25 -0
  47. package/src/modules/data/mutation-id.ts +35 -0
  48. package/src/modules/data/window-query.test.ts +52 -0
  49. package/src/modules/data/window-query.ts +194 -0
  50. package/src/modules/devtools/flags.ts +349 -0
  51. package/src/modules/devtools/index.ts +450 -46
  52. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  53. package/src/modules/devtools/state-shape.test.ts +146 -0
  54. package/src/modules/devtools/storage-info.test.ts +79 -0
  55. package/src/modules/devtools/storage-info.ts +168 -0
  56. package/src/modules/devtools/versions.test.ts +74 -0
  57. package/src/modules/devtools/versions.ts +110 -0
  58. package/src/modules/feature-flag/index.test.ts +251 -0
  59. package/src/modules/feature-flag/index.ts +308 -0
  60. package/src/modules/ref-tables.test.ts +91 -0
  61. package/src/modules/ref-tables.ts +88 -0
  62. package/src/modules/sync/engine.ts +164 -82
  63. package/src/modules/sync/events/index.ts +9 -2
  64. package/src/modules/sync/queue/queue-down.test.ts +180 -0
  65. package/src/modules/sync/queue/queue-down.ts +80 -13
  66. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  67. package/src/modules/sync/queue/queue-up.ts +241 -57
  68. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  69. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  70. package/src/modules/sync/scheduler.ts +215 -13
  71. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  72. package/src/modules/sync/sync.health.test.ts +149 -0
  73. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  74. package/src/modules/sync/sync.live-removal.test.ts +175 -0
  75. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  76. package/src/modules/sync/sync.subquery.test.ts +82 -0
  77. package/src/modules/sync/sync.tabs.test.ts +249 -0
  78. package/src/modules/sync/sync.ts +1726 -99
  79. package/src/modules/sync/utils.test.ts +269 -2
  80. package/src/modules/sync/utils.ts +201 -17
  81. package/src/otel/index.ts +13 -10
  82. package/src/services/blobs/blob-cache.test.ts +359 -0
  83. package/src/services/blobs/blob-cache.ts +603 -0
  84. package/src/services/blobs/blob-manifest.ts +227 -0
  85. package/src/services/blobs/blob-store.test.ts +77 -0
  86. package/src/services/blobs/blob-store.ts +359 -0
  87. package/src/services/blobs/blob.fixture.ts +90 -0
  88. package/src/services/blobs/index.ts +70 -0
  89. package/src/services/database/cache-engine.ts +193 -0
  90. package/src/services/database/connection-supervisor.test.ts +289 -0
  91. package/src/services/database/connection-supervisor.ts +415 -0
  92. package/src/services/database/database.query-timeout.test.ts +83 -0
  93. package/src/services/database/database.ts +41 -12
  94. package/src/services/database/engine-factory.ts +33 -0
  95. package/src/services/database/errors.ts +34 -0
  96. package/src/services/database/events/index.ts +2 -1
  97. package/src/services/database/index.ts +7 -0
  98. package/src/services/database/local-migrator.ts +30 -27
  99. package/src/services/database/local.test.ts +64 -0
  100. package/src/services/database/local.ts +484 -67
  101. package/src/services/database/plan-render.test.ts +159 -0
  102. package/src/services/database/plan-render.ts +108 -0
  103. package/src/services/database/relation-resolver.test.ts +413 -0
  104. package/src/services/database/relation-resolver.ts +0 -0
  105. package/src/services/database/remote.ts +110 -14
  106. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  107. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  108. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  109. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  110. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  111. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  112. package/src/services/database/sqlite-lock-verify.ts +45 -0
  113. package/src/services/database/sqlite-open.test.ts +150 -0
  114. package/src/services/database/sqlite-open.ts +164 -0
  115. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  116. package/src/services/database/sqlite-plan-sql.ts +138 -0
  117. package/src/services/database/sqlite-projection.test.ts +99 -0
  118. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  119. package/src/services/database/sqlite-select.test.ts +246 -0
  120. package/src/services/database/sqlite-select.ts +131 -0
  121. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  122. package/src/services/database/sqlite-transport.ts +224 -0
  123. package/src/services/database/sqlite-worker.ts +437 -0
  124. package/src/services/database/surql-translate.ts +416 -0
  125. package/src/services/database/surreal-cache-engine.ts +161 -0
  126. package/src/services/logger/index.ts +3 -2
  127. package/src/services/persistence/localstorage.ts +2 -2
  128. package/src/services/persistence/resilient.ts +11 -4
  129. package/src/services/persistence/surrealdb.ts +10 -10
  130. package/src/services/stream-processor/index.ts +796 -84
  131. package/src/services/stream-processor/permissions.test.ts +47 -0
  132. package/src/services/stream-processor/permissions.ts +53 -0
  133. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  134. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  135. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  136. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  137. package/src/services/stream-processor/wasm-types.ts +59 -3
  138. package/src/services/tabs/broker-client.ts +283 -0
  139. package/src/services/tabs/broker.test.ts +327 -0
  140. package/src/services/tabs/coordinator.test.ts +365 -0
  141. package/src/services/tabs/coordinator.ts +633 -0
  142. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  143. package/src/services/tabs/leader-locks.ts +75 -0
  144. package/src/services/tabs/protocol.ts +258 -0
  145. package/src/services/tabs/support.ts +36 -0
  146. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  147. package/src/sp00ky.auth-order.test.ts +92 -0
  148. package/src/sp00ky.init-query.test.ts +183 -0
  149. package/src/sp00ky.local-first.test.ts +60 -0
  150. package/src/sp00ky.ts +1693 -0
  151. package/src/types.ts +528 -13
  152. package/src/utils/blurhash.ts +90 -0
  153. package/src/utils/error-classification.test.ts +44 -0
  154. package/src/utils/error-classification.ts +7 -0
  155. package/src/utils/index.ts +79 -13
  156. package/src/utils/parser.test.ts +49 -120
  157. package/src/utils/parser.ts +32 -2
  158. package/src/utils/semver.test.ts +32 -0
  159. package/src/utils/semver.ts +30 -0
  160. package/src/utils/surql.ts +30 -18
  161. package/src/utils/withRetry.test.ts +1 -1
  162. package/tsdown.config.ts +86 -1
  163. package/src/spooky.ts +0 -395
@@ -0,0 +1,523 @@
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; confirmed?: boolean }>();
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('marks a non-empty set and a server-confirmed empty set as confirmed', async () => {
428
+ const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
429
+
430
+ await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
431
+ expect(local.windowRows.get('stable-key')).toMatchObject({ confirmed: true });
432
+
433
+ // The server reports zero rows: a real answer, durable.
434
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: 0 });
435
+ expect(local.windowRows.get('stable-key')).toEqual(
436
+ expect.objectContaining({ ids: [], confirmed: true })
437
+ );
438
+ });
439
+
440
+ it('marks an empty set that follows a seen set as confirmed', async () => {
441
+ // Everything the query matched was deleted while this session watched: the
442
+ // transition is genuine, so the next boot must not resurrect the rows.
443
+ const { dm, local, hash } = setup({ membershipKey: 'stable-key' });
444
+ await dm.updateQueryRemoteArray(hash, [['thread:a', 1]]);
445
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
446
+ expect(local.windowRows.get('stable-key')).toEqual(
447
+ expect.objectContaining({ ids: [], confirmed: true })
448
+ );
449
+ });
450
+
451
+ it('leaves the retry-budget empty unconfirmed', async () => {
452
+ // Two unreadable-row-count empties are believed for this session, but they
453
+ // are a guess: the durable row must not turn that guess into a permanent
454
+ // empty list on the next boot.
455
+ const { dm, local, hash } = setup({
456
+ membershipKey: 'stable-key',
457
+ remoteArray: [['thread:a', 1]],
458
+ membershipKnown: true,
459
+ });
460
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
461
+ await dm.updateQueryRemoteArray(hash, [], { serverRowCount: null });
462
+ expect(local.windowRows.get('stable-key')).toEqual(
463
+ expect.objectContaining({ ids: [], confirmed: false })
464
+ );
465
+ });
466
+
467
+ it('seeds a known-empty membership from a confirmed empty durable row', async () => {
468
+ // The reload after a server-confirmed empty: the list must stay empty
469
+ // instead of re-admitting every cached body until the next poll blanks it.
470
+ const { dm, local } = setup({ membershipKey: 'stable-key' });
471
+ local.windowRows.set('stable-key', { ids: [], confirmed: true });
472
+
473
+ const fresh = await (dm as any).createNewQuery({
474
+ recordId: new RecordId('_00_query', 'h-confirmed-empty'),
475
+ surql: 'SELECT * FROM thread WHERE done = false;',
476
+ params: {},
477
+ ttl: '10m',
478
+ tableName: 'thread',
479
+ plan,
480
+ membershipKey: 'stable-key',
481
+ });
482
+
483
+ expect(fresh.config.membershipKnown).toBe(true);
484
+ expect(fresh.config.remoteArray).toEqual([]);
485
+ expect(fresh.records).toEqual([]);
486
+ });
487
+
488
+ it('does not seed membership from an empty durable row', async () => {
489
+ // Self-heals devices poisoned before the guard existed: an empty durable
490
+ // row is indistinguishable from "never had membership", so it must fall
491
+ // back to the scan rather than paint an empty list.
492
+ const { dm, local } = setup({ membershipKey: 'stable-key' });
493
+ local.windowRows.set('stable-key', { ids: [] });
494
+
495
+ const fresh = await (dm as any).createNewQuery({
496
+ recordId: new RecordId('_00_query', 'h-poisoned'),
497
+ surql: 'SELECT * FROM thread WHERE done = false;',
498
+ params: {},
499
+ ttl: '10m',
500
+ tableName: 'thread',
501
+ plan,
502
+ membershipKey: 'stable-key',
503
+ });
504
+
505
+ expect(fresh.config.membershipKnown).toBeFalsy();
506
+ expect(ids(fresh.records)).toEqual(['thread:a', 'thread:b', 'thread:c']);
507
+ });
508
+
509
+ it('derives the membership key without the session salt', async () => {
510
+ const { dm } = setup();
511
+ const key = () => (dm as any).calculateMembershipKey({ surql: 'X', params: {} });
512
+
513
+ const before = await key();
514
+ dm.setSessionId('session-two');
515
+ expect(await key()).toBe(before);
516
+
517
+ // ...whereas the `_00_query` hash intentionally moves with the session.
518
+ const salted = await (dm as any).calculateHash({ surql: 'X', params: {} });
519
+ dm.setSessionId('session-three');
520
+ expect(await (dm as any).calculateHash({ surql: 'X', params: {} })).not.toBe(salted);
521
+ });
522
+ });
523
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { DataModule } from './index';
4
+
5
+ // A DELETE that landed in the local store (this tab's own, or one relayed
6
+ // from another tab) needs a forced re-materialize of the table's queries: the
7
+ // SSP may not emit a view update for it.
8
+
9
+ function makeLogger(): any {
10
+ const logger: any = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {} };
11
+ logger.child = () => logger;
12
+ return logger;
13
+ }
14
+
15
+ const schema = { tables: [{ name: 'comment', columns: {} }, { name: 'thread', columns: {} }] } as any;
16
+
17
+ function state(hash: string, tableName: string): any {
18
+ return {
19
+ config: { id: new RecordId('_00_query', hash), tableName, localArray: [], remoteArray: [] },
20
+ records: [],
21
+ };
22
+ }
23
+
24
+ describe('DataModule.notifyTableQueries', () => {
25
+ it('re-materializes only the queries on that table, isolating failures', async () => {
26
+ const local: any = { epoch: 1, query: vi.fn(async () => [[]]) };
27
+ const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
28
+ (dm as any).activeQueries.set('c1', state('c1', 'comment'));
29
+ (dm as any).activeQueries.set('c2', state('c2', 'comment'));
30
+ (dm as any).activeQueries.set('t1', state('t1', 'thread'));
31
+ const notified: string[] = [];
32
+ dm.notifyQuerySynced = vi.fn(async (hash: string) => {
33
+ notified.push(hash);
34
+ if (hash === 'c1') throw new Error('boom');
35
+ }) as any;
36
+
37
+ await dm.notifyTableQueries('comment');
38
+
39
+ expect(notified.sort()).toEqual(['c1', 'c2']);
40
+ });
41
+ });