@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.
- package/AGENTS.md +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- 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 +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -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 +1815 -151
- 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 +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- 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 +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- 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 +175 -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.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -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 +193 -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 +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -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 +484 -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 +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -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 +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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 +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -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 +161 -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 +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -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 +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -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,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
|
+
});
|