@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,206 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } 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 write is visible before the server confirms it because it sits in the
|
|
9
|
+
* outbox: the render set is
|
|
10
|
+
* `(membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes`.
|
|
11
|
+
*
|
|
12
|
+
* The outbox row is deleted the instant the push succeeds, but the row does not
|
|
13
|
+
* enter membership until the SSP has ingested it, materialized the view, written
|
|
14
|
+
* the `_00_list_ref` edge, and this client has read that back. In between it is
|
|
15
|
+
* in NEITHER term, so it renders, disappears, and returns.
|
|
16
|
+
*
|
|
17
|
+
* Reported from production as "the comment shows up on every other client in
|
|
18
|
+
* realtime but not on the one that wrote it" — other clients don't blink because
|
|
19
|
+
* a client that never established membership renders from the predicate scan
|
|
20
|
+
* instead. The gap is invisible when the round trip is fast and glaring when the
|
|
21
|
+
* edge path is backed up.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
|
|
26
|
+
function makeLogger(): any {
|
|
27
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
28
|
+
logger.child = () => logger;
|
|
29
|
+
return logger;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const schema = { tables: [{ name: 'comment', columns: {} }] } as any;
|
|
33
|
+
const plan: QueryPlan = { table: 'comment', where: [['game', '=', 'game:g1']] } as any;
|
|
34
|
+
|
|
35
|
+
function makeQueryState(
|
|
36
|
+
hash: string,
|
|
37
|
+
opts: { remoteArray?: RecordVersionArray; localArray?: RecordVersionArray } = {}
|
|
38
|
+
): QueryState {
|
|
39
|
+
return {
|
|
40
|
+
config: {
|
|
41
|
+
id: new RecordId('_00_query', hash),
|
|
42
|
+
surql: 'SELECT * FROM comment WHERE game = $game;',
|
|
43
|
+
plan,
|
|
44
|
+
params: { game: 'game:g1' },
|
|
45
|
+
localArray: opts.localArray ?? [],
|
|
46
|
+
remoteArray: opts.remoteArray ?? [],
|
|
47
|
+
membershipKnown: true,
|
|
48
|
+
ttl: '10m',
|
|
49
|
+
lastActiveAt: new Date(),
|
|
50
|
+
tableName: 'comment',
|
|
51
|
+
},
|
|
52
|
+
records: [],
|
|
53
|
+
ttlTimer: null,
|
|
54
|
+
ttlDurationMs: 0,
|
|
55
|
+
updateCount: 0,
|
|
56
|
+
lastUpdatedAt: null,
|
|
57
|
+
materializationSamples: [],
|
|
58
|
+
lastIngestLatencyMs: null,
|
|
59
|
+
errorCount: 0,
|
|
60
|
+
status: 'idle',
|
|
61
|
+
phaseSamples: {},
|
|
62
|
+
phaseLast: {},
|
|
63
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
64
|
+
} as QueryState;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setup(
|
|
68
|
+
stateOpts: Parameters<typeof makeQueryState>[1] = {},
|
|
69
|
+
pendingRows: Array<{ recordId: RecordId; mutationType: string }> = []
|
|
70
|
+
) {
|
|
71
|
+
const bodies = new Map(
|
|
72
|
+
['old', 'new'].map((k) => [`comment:${k}`, { id: new RecordId('comment', k), body: k }])
|
|
73
|
+
);
|
|
74
|
+
const local: any = {
|
|
75
|
+
epoch: 1,
|
|
76
|
+
select: vi.fn(async (p: QueryPlan) => {
|
|
77
|
+
if ((p as any).ids) {
|
|
78
|
+
return ((p as any).ids as RecordId[])
|
|
79
|
+
.map((id) => bodies.get(id.toString()))
|
|
80
|
+
.filter((r): r is NonNullable<typeof r> => r !== undefined);
|
|
81
|
+
}
|
|
82
|
+
return Array.from(bodies.values());
|
|
83
|
+
}),
|
|
84
|
+
query: vi.fn(async (sql: string) => {
|
|
85
|
+
if (sql.includes('_00_pending_mutations')) return [pendingRows];
|
|
86
|
+
if (sql.includes('FROM ONLY')) return [null];
|
|
87
|
+
return [Array.from(bodies.values())];
|
|
88
|
+
}),
|
|
89
|
+
getById: vi.fn(async () => null),
|
|
90
|
+
upsert: vi.fn(async () => {}),
|
|
91
|
+
};
|
|
92
|
+
const dm = new DataModule({ saveBatch: async () => {} } as any, local, schema, makeLogger(), 100);
|
|
93
|
+
const state = makeQueryState('h1', stateOpts);
|
|
94
|
+
(dm as any).activeQueries.set('h1', state);
|
|
95
|
+
return { dm, state };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ids = (rows: Array<Record<string, any>>) => rows.map((r) => String(r.id));
|
|
99
|
+
const materialize = (dm: DataModule<any>, state: QueryState, ssp?: RecordVersionArray) =>
|
|
100
|
+
(dm as any).materializeRecords(state, ssp) as Promise<Record<string, any>[]>;
|
|
101
|
+
|
|
102
|
+
describe('settled-write grace window', () => {
|
|
103
|
+
afterEach(() => vi.useRealTimers());
|
|
104
|
+
|
|
105
|
+
it('keeps an accepted write rendered while membership has not caught up', async () => {
|
|
106
|
+
// Membership still only knows the old comment; the outbox row for the new
|
|
107
|
+
// one is already gone because the push succeeded.
|
|
108
|
+
const { dm, state } = setup({
|
|
109
|
+
remoteArray: [['comment:old', 1]],
|
|
110
|
+
localArray: [
|
|
111
|
+
['comment:old', 1],
|
|
112
|
+
['comment:new', 1],
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
dm.noteWriteSettled('comment:new', 'create');
|
|
117
|
+
|
|
118
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:new', 'comment:old']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('renders nothing extra for a write the server never accepted', async () => {
|
|
122
|
+
// The rollback path never reports a mutation settled, so a rejected write
|
|
123
|
+
// has no grace at all. This is what keeps the window from showing rows the
|
|
124
|
+
// server refused.
|
|
125
|
+
const { dm, state } = setup({
|
|
126
|
+
remoteArray: [['comment:old', 1]],
|
|
127
|
+
localArray: [
|
|
128
|
+
['comment:old', 1],
|
|
129
|
+
['comment:new', 1],
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('stops granting grace once membership names the row', async () => {
|
|
137
|
+
const { dm, state } = setup({
|
|
138
|
+
remoteArray: [['comment:old', 1]],
|
|
139
|
+
localArray: [
|
|
140
|
+
['comment:old', 1],
|
|
141
|
+
['comment:new', 1],
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
dm.noteWriteSettled('comment:new', 'create');
|
|
145
|
+
await materialize(dm, state);
|
|
146
|
+
|
|
147
|
+
// Membership catches up; the id must not be double-counted…
|
|
148
|
+
state.config.remoteArray = [
|
|
149
|
+
['comment:old', 1],
|
|
150
|
+
['comment:new', 1],
|
|
151
|
+
];
|
|
152
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:new', 'comment:old']);
|
|
153
|
+
expect((dm as any).settledWrites.size).toBe(0);
|
|
154
|
+
|
|
155
|
+
// …and once the server later drops it from the window, it goes away rather
|
|
156
|
+
// than being resurrected by a stale grace entry.
|
|
157
|
+
state.config.remoteArray = [['comment:old', 1]];
|
|
158
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('expires the grace so a write whose membership never arrives cannot linger', async () => {
|
|
162
|
+
vi.useFakeTimers();
|
|
163
|
+
const { dm, state } = setup({
|
|
164
|
+
remoteArray: [['comment:old', 1]],
|
|
165
|
+
localArray: [
|
|
166
|
+
['comment:old', 1],
|
|
167
|
+
['comment:new', 1],
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
dm.noteWriteSettled('comment:new', 'create');
|
|
171
|
+
expect(ids(await materialize(dm, state))).toContain('comment:new');
|
|
172
|
+
|
|
173
|
+
vi.advanceTimersByTime(11_000);
|
|
174
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps an accepted delete subtracted until membership drops the row', async () => {
|
|
178
|
+
// The mirror case: membership still lists the row until the SSP publishes
|
|
179
|
+
// its removal, so without this the deleted row flashes back.
|
|
180
|
+
const { dm, state } = setup({
|
|
181
|
+
remoteArray: [
|
|
182
|
+
['comment:old', 1],
|
|
183
|
+
['comment:new', 1],
|
|
184
|
+
],
|
|
185
|
+
localArray: [['comment:old', 1]],
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
dm.noteWriteSettled('comment:new', 'delete');
|
|
189
|
+
|
|
190
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('only grants grace to rows the local view still says match', async () => {
|
|
194
|
+
// `localArray` is the local SSP's answer to "does this row match the
|
|
195
|
+
// predicate". A settled write that does NOT match must not be forced into
|
|
196
|
+
// the render set — same rule the pending-write union already follows.
|
|
197
|
+
const { dm, state } = setup({
|
|
198
|
+
remoteArray: [['comment:old', 1]],
|
|
199
|
+
localArray: [['comment:old', 1]],
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
dm.noteWriteSettled('comment:new', 'create');
|
|
203
|
+
|
|
204
|
+
expect(ids(await materialize(dm, state))).toEqual(['comment:old']);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { DataModule } from './index';
|
|
4
|
+
import type { QueryState, QueryStatus } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for the per-query fetch status (idle/fetching) added to DataModule:
|
|
8
|
+
* setQueryStatus updates the query object and notifies both the DevTools
|
|
9
|
+
* observer hook and subscribeStatus listeners; beginFetching/endFetching
|
|
10
|
+
* refcount overlapping fetch cycles; flushPendingStreamUpdate lands the
|
|
11
|
+
* debounced result before a query flips back to idle.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function makeLogger(): any {
|
|
15
|
+
const noop = () => {};
|
|
16
|
+
const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
17
|
+
logger.child = () => logger;
|
|
18
|
+
return logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeDataModule(): DataModule<any> {
|
|
22
|
+
return new DataModule(
|
|
23
|
+
{} as any, // cache — unused by status methods
|
|
24
|
+
{} as any, // local — unused by status methods
|
|
25
|
+
{ tables: [] } as any, // schema — unused by status methods
|
|
26
|
+
makeLogger(),
|
|
27
|
+
100
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeQueryState(hash: string): QueryState {
|
|
32
|
+
return {
|
|
33
|
+
config: {
|
|
34
|
+
id: new RecordId('_00_query', hash),
|
|
35
|
+
surql: 'SELECT * FROM user',
|
|
36
|
+
params: {},
|
|
37
|
+
localArray: [],
|
|
38
|
+
remoteArray: [],
|
|
39
|
+
ttl: '10m',
|
|
40
|
+
lastActiveAt: new Date(),
|
|
41
|
+
tableName: 'user',
|
|
42
|
+
},
|
|
43
|
+
records: [],
|
|
44
|
+
ttlTimer: null,
|
|
45
|
+
ttlDurationMs: 0,
|
|
46
|
+
updateCount: 0,
|
|
47
|
+
lastUpdatedAt: null,
|
|
48
|
+
materializationSamples: [],
|
|
49
|
+
lastIngestLatencyMs: null,
|
|
50
|
+
errorCount: 0,
|
|
51
|
+
status: 'idle',
|
|
52
|
+
phaseSamples: {},
|
|
53
|
+
phaseLast: {},
|
|
54
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('DataModule query fetch status', () => {
|
|
59
|
+
let dm: DataModule<any>;
|
|
60
|
+
const hash = 'h1';
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
dm = makeDataModule();
|
|
64
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('subscribeStatus with immediate reports the current status', () => {
|
|
68
|
+
const seen: QueryStatus[] = [];
|
|
69
|
+
dm.subscribeStatus(hash, (s) => seen.push(s), { immediate: true });
|
|
70
|
+
expect(seen).toEqual(['idle']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('setQueryStatus updates the query object and notifies subscribers + observer', () => {
|
|
74
|
+
const observed: Array<[string, QueryStatus]> = [];
|
|
75
|
+
dm.onQueryStatusChange = (h, s) => observed.push([h, s]);
|
|
76
|
+
|
|
77
|
+
const seen: QueryStatus[] = [];
|
|
78
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
79
|
+
|
|
80
|
+
dm.setQueryStatus(hash, 'fetching');
|
|
81
|
+
expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
|
|
82
|
+
expect(seen).toEqual(['fetching']);
|
|
83
|
+
expect(observed).toEqual([[hash, 'fetching']]);
|
|
84
|
+
|
|
85
|
+
dm.setQueryStatus(hash, 'idle');
|
|
86
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
87
|
+
expect(observed).toEqual([[hash, 'fetching'], [hash, 'idle']]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('setQueryStatus is a no-op when the status is unchanged', () => {
|
|
91
|
+
const seen: QueryStatus[] = [];
|
|
92
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
93
|
+
dm.setQueryStatus(hash, 'idle'); // already idle
|
|
94
|
+
expect(seen).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('setQueryStatus is a no-op for an unknown query', () => {
|
|
98
|
+
let called = false;
|
|
99
|
+
dm.onQueryStatusChange = () => {
|
|
100
|
+
called = true;
|
|
101
|
+
};
|
|
102
|
+
dm.setQueryStatus('does-not-exist', 'fetching');
|
|
103
|
+
expect(called).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('unsubscribe stops further status notifications', () => {
|
|
107
|
+
const seen: QueryStatus[] = [];
|
|
108
|
+
const unsub = dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
109
|
+
dm.setQueryStatus(hash, 'fetching');
|
|
110
|
+
unsub();
|
|
111
|
+
dm.setQueryStatus(hash, 'idle');
|
|
112
|
+
expect(seen).toEqual(['fetching']);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('DataModule beginFetching/endFetching refcount', () => {
|
|
117
|
+
let dm: DataModule<any>;
|
|
118
|
+
const hash = 'h1';
|
|
119
|
+
let seen: QueryStatus[];
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
dm = makeDataModule();
|
|
123
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
124
|
+
seen = [];
|
|
125
|
+
dm.subscribeStatus(hash, (s) => seen.push(s));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('emits fetching on 0→1 and idle only on the last exit', () => {
|
|
129
|
+
dm.beginFetching(hash); // registration
|
|
130
|
+
dm.beginFetching(hash); // overlapping poll round
|
|
131
|
+
expect(seen).toEqual(['fetching']);
|
|
132
|
+
|
|
133
|
+
dm.endFetching(hash); // inner cycle finishes — must NOT emit idle
|
|
134
|
+
expect(seen).toEqual(['fetching']);
|
|
135
|
+
expect((dm as any).activeQueries.get(hash).status).toBe('fetching');
|
|
136
|
+
|
|
137
|
+
dm.endFetching(hash);
|
|
138
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('unbalanced endFetching settles to idle without going negative', () => {
|
|
142
|
+
dm.endFetching(hash); // already idle → no-op notification
|
|
143
|
+
expect(seen).toEqual([]);
|
|
144
|
+
dm.beginFetching(hash);
|
|
145
|
+
dm.endFetching(hash);
|
|
146
|
+
expect(seen).toEqual(['fetching', 'idle']);
|
|
147
|
+
expect((dm as any).fetchDepth.has(hash)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('DataModule pending stream-update flush', () => {
|
|
152
|
+
let dm: DataModule<any>;
|
|
153
|
+
const hash = 'h1';
|
|
154
|
+
let processed: any[];
|
|
155
|
+
|
|
156
|
+
const makeUpdate = (op: 'CREATE' | 'UPDATE' | 'DELETE') =>
|
|
157
|
+
({ queryHash: hash, op, localArray: [] }) as any;
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
dm = makeDataModule();
|
|
162
|
+
(dm as any).activeQueries.set(hash, makeQueryState(hash));
|
|
163
|
+
processed = [];
|
|
164
|
+
(dm as any).processStreamUpdate = async (u: any) => {
|
|
165
|
+
processed.push(u);
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
vi.useRealTimers();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('flushPendingStreamUpdate processes the debounced update exactly once', async () => {
|
|
174
|
+
const update = makeUpdate('CREATE');
|
|
175
|
+
await dm.onStreamUpdate(update);
|
|
176
|
+
expect(processed).toEqual([]); // still debounced
|
|
177
|
+
|
|
178
|
+
await dm.flushPendingStreamUpdate(hash);
|
|
179
|
+
expect(processed).toEqual([update]);
|
|
180
|
+
expect((dm as any).debounceTimers.has(hash)).toBe(false);
|
|
181
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
182
|
+
|
|
183
|
+
// The cancelled timer must not process it a second time.
|
|
184
|
+
await vi.runAllTimersAsync();
|
|
185
|
+
expect(processed).toEqual([update]);
|
|
186
|
+
|
|
187
|
+
// Nothing pending → flush is a no-op.
|
|
188
|
+
await dm.flushPendingStreamUpdate(hash);
|
|
189
|
+
expect(processed).toEqual([update]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('the trailing-edge timer clears the pending entry itself', async () => {
|
|
193
|
+
const update = makeUpdate('UPDATE');
|
|
194
|
+
await dm.onStreamUpdate(update);
|
|
195
|
+
await vi.runAllTimersAsync();
|
|
196
|
+
expect(processed).toEqual([update]);
|
|
197
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('a DELETE supersedes and drops the pending coalesced update', async () => {
|
|
201
|
+
const create = makeUpdate('CREATE');
|
|
202
|
+
const del = makeUpdate('DELETE');
|
|
203
|
+
await dm.onStreamUpdate(create);
|
|
204
|
+
await dm.onStreamUpdate(del); // immediate, drops the pending CREATE
|
|
205
|
+
expect(processed).toEqual([del]);
|
|
206
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
207
|
+
await vi.runAllTimersAsync();
|
|
208
|
+
expect(processed).toEqual([del]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('finalizeDeregister clears pending update and fetch depth', async () => {
|
|
212
|
+
(dm as any).cache = { unregisterQuery: () => {} };
|
|
213
|
+
await dm.onStreamUpdate(makeUpdate('CREATE'));
|
|
214
|
+
dm.beginFetching(hash);
|
|
215
|
+
dm.finalizeDeregister(hash);
|
|
216
|
+
expect((dm as any).pendingStreamUpdates.has(hash)).toBe(false);
|
|
217
|
+
expect((dm as any).debounceTimers.has(hash)).toBe(false);
|
|
218
|
+
expect((dm as any).fetchDepth.has(hash)).toBe(false);
|
|
219
|
+
await vi.runAllTimersAsync();
|
|
220
|
+
expect(processed).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('DataModule notifyQuerySynced', () => {
|
|
225
|
+
const hash = 'h1';
|
|
226
|
+
|
|
227
|
+
function makeDmWithLocal(rows: any[]): DataModule<any> {
|
|
228
|
+
const local: any = { query: async () => [rows] };
|
|
229
|
+
return new DataModule({} as any, local, { tables: [] } as any, makeLogger(), 100);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
it('notifies once per registration lifetime even when records are unchanged and updateCount > 0', async () => {
|
|
233
|
+
const dm = makeDmWithLocal([]);
|
|
234
|
+
const state = makeQueryState(hash);
|
|
235
|
+
state.updateCount = 7; // persisted from a previous registration
|
|
236
|
+
(dm as any).activeQueries.set(hash, state);
|
|
237
|
+
|
|
238
|
+
let notified = 0;
|
|
239
|
+
dm.subscribe(hash, () => {
|
|
240
|
+
notified++;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await dm.notifyQuerySynced(hash); // empty result, unchanged — must still emit
|
|
244
|
+
expect(notified).toBe(1);
|
|
245
|
+
|
|
246
|
+
await dm.notifyQuerySynced(hash); // already notified this lifetime → silent
|
|
247
|
+
expect(notified).toBe(1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
3
|
+
import { buildIdSetPlan, buildIdSetSurql, buildWindowMaterializationPlan } from './window-query';
|
|
4
|
+
import { executeSelect, type SelectDb } from '../../services/database/sqlite-select';
|
|
5
|
+
import type { Row } from '../../services/database/cache-engine';
|
|
6
|
+
|
|
7
|
+
// `buildIdSetPlan` is what makes membership-authoritative rendering possible on
|
|
8
|
+
// any local engine: it turns a query's plan into "these exact ids, ordered,
|
|
9
|
+
// relations intact". Dropping `where` is the point — re-applying the predicate
|
|
10
|
+
// against stale local bodies is what let removed rows keep rendering.
|
|
11
|
+
|
|
12
|
+
const BASE: QueryPlan = {
|
|
13
|
+
table: 'thread',
|
|
14
|
+
where: [{ field: 'done', op: '=', value: false }],
|
|
15
|
+
limit: 50,
|
|
16
|
+
offset: 0,
|
|
17
|
+
orderBy: [['title', 'asc']],
|
|
18
|
+
relations: [
|
|
19
|
+
{ alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'thread' },
|
|
20
|
+
],
|
|
21
|
+
} as any;
|
|
22
|
+
|
|
23
|
+
describe('buildIdSetPlan', () => {
|
|
24
|
+
it('binds the id-set and drops where/limit/offset, keeping orderBy + relations', () => {
|
|
25
|
+
const out = buildIdSetPlan(BASE, ['thread:A']);
|
|
26
|
+
expect(out.ids).toEqual(['thread:A']);
|
|
27
|
+
expect(out.where).toBeUndefined();
|
|
28
|
+
expect(out.limit).toBeUndefined();
|
|
29
|
+
expect(out.offset).toBeUndefined();
|
|
30
|
+
expect(out.orderBy).toEqual([['title', 'asc']]);
|
|
31
|
+
expect(out.relations).toEqual(BASE.relations);
|
|
32
|
+
expect(out.table).toBe('thread');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('applies to non-offset plans, unlike the windowed-only helper', () => {
|
|
36
|
+
// The windowed helper deliberately bails so the old caller kept its scan;
|
|
37
|
+
// membership rendering needs the same rewrite for every query.
|
|
38
|
+
expect(buildWindowMaterializationPlan(BASE, ['thread:A'])).toBeNull();
|
|
39
|
+
expect(buildIdSetPlan(BASE, ['thread:A']).ids).toEqual(['thread:A']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not mutate the input plan', () => {
|
|
43
|
+
buildIdSetPlan(BASE, ['thread:A']);
|
|
44
|
+
expect(BASE.where).toEqual([{ field: 'done', op: '=', value: false }]);
|
|
45
|
+
expect(BASE.limit).toBe(50);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('buildIdSetSurql', () => {
|
|
50
|
+
it('rewrites a non-offset query to the id-set source, keeping ORDER BY', () => {
|
|
51
|
+
const out = buildIdSetSurql('SELECT * FROM thread WHERE done = false ORDER BY title ASC LIMIT 50;');
|
|
52
|
+
expect(out?.query).toBe('SELECT * FROM $__win ORDER BY title ASC');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('preserves the projection', () => {
|
|
56
|
+
const out = buildIdSetSurql('SELECT id, title FROM thread WHERE done = false;');
|
|
57
|
+
expect(out?.query).toBe('SELECT id, title FROM $__win');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns null when there is no top-level FROM to replace', () => {
|
|
61
|
+
expect(buildIdSetSurql('RETURN true;')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---- Engine parity -------------------------------------------------------
|
|
66
|
+
//
|
|
67
|
+
// Both local engines short-circuit to `selectByIds` when `plan.ids` is set. This
|
|
68
|
+
// pins that the id-set plan really does filter AND still resolves `.related()`
|
|
69
|
+
// on the SQLite engine (the `where`-dropping path the SurrealDB engine shares).
|
|
70
|
+
|
|
71
|
+
const FIXTURE: Record<string, Row[]> = {
|
|
72
|
+
thread: [
|
|
73
|
+
{ id: 'thread:A', title: 'a thread', done: false },
|
|
74
|
+
{ id: 'thread:B', title: 'b thread', done: false },
|
|
75
|
+
],
|
|
76
|
+
comment: [
|
|
77
|
+
{ id: 'comment:1', thread: 'thread:A', body: 'on A' },
|
|
78
|
+
{ id: 'comment:2', thread: 'thread:B', body: 'on B' },
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function makeSelectDb(): SelectDb {
|
|
83
|
+
const respond = (sql: string, bind: unknown[] = []): { data: string }[] => {
|
|
84
|
+
const table = /FROM "([^"]+)"/.exec(sql)?.[1];
|
|
85
|
+
let rows = table ? (FIXTURE[table] ?? []) : [];
|
|
86
|
+
const rel = /WHERE json_extract\(data, '\$\.([A-Za-z0-9_.]+)'\) IN/.exec(sql);
|
|
87
|
+
if (rel) rows = rows.filter((r) => bind.includes(r[rel[1]]));
|
|
88
|
+
else if (/WHERE id IN/.test(sql)) rows = rows.filter((r) => bind.includes(r.id));
|
|
89
|
+
return rows.map((r) => ({ data: JSON.stringify(r) }));
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
exec: (sql, bind) => respond(sql, bind ?? []),
|
|
93
|
+
run: () => {},
|
|
94
|
+
knownTables: new Set<string>(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('id-set plan on the SQLite engine', () => {
|
|
99
|
+
it('returns only the id-set even though both bodies match the predicate', async () => {
|
|
100
|
+
const { rows } = await executeSelect(
|
|
101
|
+
buildIdSetPlan(BASE, ['thread:A']),
|
|
102
|
+
{},
|
|
103
|
+
makeSelectDb()
|
|
104
|
+
);
|
|
105
|
+
expect(rows.map((r) => r.id)).toEqual(['thread:A']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('still resolves .related() children for the id-set rows', async () => {
|
|
109
|
+
const { rows, relationFetches } = await executeSelect(
|
|
110
|
+
buildIdSetPlan(BASE, ['thread:A']),
|
|
111
|
+
{},
|
|
112
|
+
makeSelectDb()
|
|
113
|
+
);
|
|
114
|
+
expect(relationFetches).toBe(1);
|
|
115
|
+
expect((rows[0] as any).comments.map((c: Row) => c.id)).toEqual(['comment:1']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders nothing for an empty id-set (known-and-empty membership)', async () => {
|
|
119
|
+
const { rows } = await executeSelect(buildIdSetPlan(BASE, []), {}, makeSelectDb());
|
|
120
|
+
expect(rows).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
});
|