@spooky-sync/core 0.0.1-canary.202 → 0.0.1-canary.204
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/dist/index.d.ts +89 -6
- package/dist/index.js +353 -65
- package/dist/sqlite-open.js +28 -1
- package/dist/sqlite-worker.js +11 -8
- package/dist/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/index.ts +78 -14
- package/src/modules/cache/index.ts +12 -11
- package/src/modules/data/data.pending-ids.test.ts +125 -0
- package/src/modules/data/index.ts +64 -0
- package/src/modules/sync/queue/queue-down.test.ts +73 -0
- package/src/modules/sync/queue/queue-down.ts +59 -21
- package/src/modules/sync/scheduler.retry.test.ts +82 -1
- package/src/modules/sync/scheduler.ts +59 -4
- package/src/modules/sync/sync.ts +36 -0
- package/src/services/database/sqlite-cache-engine.ts +13 -8
- package/src/services/database/sqlite-plan-sql.ts +32 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.ts +19 -9
- package/src/services/stream-processor/index.ts +66 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +60 -0
- package/src/services/stream-processor/wasm-types.ts +4 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +156 -50
- package/src/types.ts +6 -0
|
@@ -105,3 +105,76 @@ describe('DownQueue.next failure handling', () => {
|
|
|
105
105
|
expect(drained).toEqual(['b']);
|
|
106
106
|
});
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
describe('DownQueue.takeNext (per-hash ordering under concurrency)', () => {
|
|
110
|
+
it('hands out events for distinct hashes so they can run in parallel', () => {
|
|
111
|
+
const q = makeQueue();
|
|
112
|
+
q.push(register('a'));
|
|
113
|
+
q.push(register('b'));
|
|
114
|
+
q.push(register('c'));
|
|
115
|
+
const busy = new Set<string>();
|
|
116
|
+
|
|
117
|
+
const first = q.takeNext(busy)!;
|
|
118
|
+
busy.add(hashOf(first));
|
|
119
|
+
const second = q.takeNext(busy)!;
|
|
120
|
+
busy.add(hashOf(second));
|
|
121
|
+
|
|
122
|
+
expect([hashOf(first), hashOf(second)]).toEqual(['a', 'b']);
|
|
123
|
+
expect(q.size).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('SKIPS an event whose hash is busy without reordering it', () => {
|
|
127
|
+
// The whole correctness argument: a `cleanup` must never overtake the
|
|
128
|
+
// `register` for the same query. A busy hash's event keeps its place in the
|
|
129
|
+
// queue and is simply passed over.
|
|
130
|
+
const q = makeQueue();
|
|
131
|
+
q.push(register('a'));
|
|
132
|
+
q.push(register('a'));
|
|
133
|
+
q.push(register('b'));
|
|
134
|
+
|
|
135
|
+
const busy = new Set(['a']);
|
|
136
|
+
const taken = q.takeNext(busy)!;
|
|
137
|
+
expect(hashOf(taken)).toBe('b');
|
|
138
|
+
|
|
139
|
+
// Once 'a' frees up, its two events come back out in their original order.
|
|
140
|
+
const rest = [q.takeNext(new Set())!, q.takeNext(new Set())!];
|
|
141
|
+
expect(rest.map(hashOf)).toEqual(['a', 'a']);
|
|
142
|
+
expect(q.size).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns undefined when every remaining event is blocked', () => {
|
|
146
|
+
const q = makeQueue();
|
|
147
|
+
q.push(register('a'));
|
|
148
|
+
expect(q.takeNext(new Set(['a']))).toBeUndefined();
|
|
149
|
+
// Blocked, not consumed.
|
|
150
|
+
expect(q.size).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('DownQueue.run', () => {
|
|
155
|
+
it('returns the error instead of throwing, and re-heads the event', async () => {
|
|
156
|
+
// A concurrent drain has other work in flight when one event fails; a
|
|
157
|
+
// rejection here would take that work down with it.
|
|
158
|
+
const q = makeQueue();
|
|
159
|
+
const event = register('a');
|
|
160
|
+
q.push(event);
|
|
161
|
+
const taken = q.takeNext(new Set())!;
|
|
162
|
+
|
|
163
|
+
const boom = new Error('nope');
|
|
164
|
+
const err = await q.run(taken, async () => {
|
|
165
|
+
throw boom;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(err).toBe(boom);
|
|
169
|
+
expect(q.size).toBe(1);
|
|
170
|
+
expect(hashOf(q.takeNext(new Set())!)).toBe('a');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns undefined on success', async () => {
|
|
174
|
+
const q = makeQueue();
|
|
175
|
+
q.push(register('a'));
|
|
176
|
+
const taken = q.takeNext(new Set())!;
|
|
177
|
+
await expect(q.run(taken, async () => {})).resolves.toBeUndefined();
|
|
178
|
+
expect(q.size).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -91,28 +91,66 @@ export class DownQueue {
|
|
|
91
91
|
|
|
92
92
|
async next(fn: (event: DownEvent) => Promise<void>): Promise<void> {
|
|
93
93
|
const event = this.queue.shift();
|
|
94
|
-
if (event)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
94
|
+
if (!event) return;
|
|
95
|
+
const error = await this.run(event, fn);
|
|
96
|
+
if (error !== undefined) throw error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The next event whose hash is NOT already being processed, or `undefined`
|
|
101
|
+
* when every remaining event is blocked on a busy hash.
|
|
102
|
+
*
|
|
103
|
+
* Ordering only ever mattered PER HASH — a `cleanup` must not overtake the
|
|
104
|
+
* `register` for the same query — but the queue enforced it globally, so one
|
|
105
|
+
* registration RPC at a time was the ceiling for the whole client. An event
|
|
106
|
+
* for a busy hash keeps its place here (it is skipped, not reordered), so
|
|
107
|
+
* per-hash ordering is preserved exactly while independent hashes proceed
|
|
108
|
+
* concurrently.
|
|
109
|
+
*/
|
|
110
|
+
takeNext(busy: ReadonlySet<string>): DownEvent | undefined {
|
|
111
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
112
|
+
const event = this.queue[i]!;
|
|
113
|
+
if (busy.has(event.payload.hash)) continue;
|
|
114
|
+
this.queue.splice(i, 1);
|
|
115
|
+
return event;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Process one event, applying the re-head / rotate failure policy.
|
|
122
|
+
*
|
|
123
|
+
* NEVER rejects: it RETURNS the error instead (`undefined` on success). A
|
|
124
|
+
* concurrent drain has other work in flight when one event fails, and a
|
|
125
|
+
* rejection would either take that work down with it or have to be caught at
|
|
126
|
+
* every call site. `next` reinstates the throwing contract for the serial
|
|
127
|
+
* callers that still want it.
|
|
128
|
+
*/
|
|
129
|
+
async run(
|
|
130
|
+
event: DownEvent,
|
|
131
|
+
fn: (event: DownEvent) => Promise<void>
|
|
132
|
+
): Promise<unknown | undefined> {
|
|
133
|
+
try {
|
|
134
|
+
await fn(event);
|
|
135
|
+
this.failures.delete(event);
|
|
136
|
+
return undefined;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const attempts = (this.failures.get(event) ?? 0) + 1;
|
|
139
|
+
this.failures.set(event, attempts);
|
|
140
|
+
// Re-head so a transient failure keeps its ordering, but give up the
|
|
141
|
+
// head once it looks permanent and there is other work waiting — one
|
|
142
|
+
// unregisterable query must not stall every other query's registration.
|
|
143
|
+
const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
|
|
144
|
+
if (starvingOthers) {
|
|
145
|
+
this.queue.push(event);
|
|
146
|
+
} else {
|
|
147
|
+
this.queue.unshift(event);
|
|
115
148
|
}
|
|
149
|
+
this.logger.error(
|
|
150
|
+
{ error, event, attempts, rotated: starvingOthers, Category: 'sp00ky-client::DownQueue::next' },
|
|
151
|
+
'Failed to process query'
|
|
152
|
+
);
|
|
153
|
+
return error;
|
|
116
154
|
}
|
|
117
155
|
}
|
|
118
156
|
}
|
|
@@ -32,11 +32,28 @@ function makeQueue<E>(items: E[]) {
|
|
|
32
32
|
async next(fn: (event: E) => Promise<void>) {
|
|
33
33
|
const event = this.queue.shift();
|
|
34
34
|
if (!event) return;
|
|
35
|
+
const err = await this.run(event, fn);
|
|
36
|
+
if (err !== undefined) throw err;
|
|
37
|
+
},
|
|
38
|
+
// The concurrent down drain takes events itself and runs them, so the fake
|
|
39
|
+
// has to mirror that half of the real DownQueue too: skip events whose hash
|
|
40
|
+
// is already in flight, and hand the error back rather than throwing.
|
|
41
|
+
takeNext(busy: ReadonlySet<string>) {
|
|
42
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
43
|
+
const event = this.queue[i] as unknown as { payload?: { hash?: string } };
|
|
44
|
+
const hash = event?.payload?.hash;
|
|
45
|
+
if (hash !== undefined && busy.has(hash)) continue;
|
|
46
|
+
return this.queue.splice(i, 1)[0];
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
},
|
|
50
|
+
async run(event: E, fn: (event: E) => Promise<void>) {
|
|
35
51
|
try {
|
|
36
52
|
await fn(event);
|
|
53
|
+
return undefined;
|
|
37
54
|
} catch (err) {
|
|
38
55
|
this.queue.unshift(event);
|
|
39
|
-
|
|
56
|
+
return err;
|
|
40
57
|
}
|
|
41
58
|
},
|
|
42
59
|
};
|
|
@@ -154,3 +171,67 @@ describe('SyncScheduler retry', () => {
|
|
|
154
171
|
expect(attempts).toBe(1);
|
|
155
172
|
});
|
|
156
173
|
});
|
|
174
|
+
|
|
175
|
+
describe('SyncScheduler down concurrency', () => {
|
|
176
|
+
beforeEach(() => vi.useFakeTimers());
|
|
177
|
+
afterEach(() => vi.useRealTimers());
|
|
178
|
+
|
|
179
|
+
/** Scheduler over a down queue of `hashes`, whose handler blocks until the
|
|
180
|
+
* returned `release` is called for that hash. Lets a test observe exactly
|
|
181
|
+
* which events are in flight at the same moment. */
|
|
182
|
+
function makeConcurrent(hashes: string[]) {
|
|
183
|
+
const downQueue = makeQueue(hashes.map(downEvent));
|
|
184
|
+
const upQueue = makeQueue<UpEvent>([]);
|
|
185
|
+
const started: string[] = [];
|
|
186
|
+
const gates = new Map<string, () => void>();
|
|
187
|
+
const scheduler = new SyncScheduler(
|
|
188
|
+
upQueue as unknown as UpQueue,
|
|
189
|
+
downQueue as unknown as DownQueue,
|
|
190
|
+
async () => {},
|
|
191
|
+
async (event) => {
|
|
192
|
+
const hash = (event as { payload: { hash: string } }).payload.hash;
|
|
193
|
+
started.push(hash);
|
|
194
|
+
await new Promise<void>((resolve) => gates.set(hash, resolve));
|
|
195
|
+
},
|
|
196
|
+
silentLogger
|
|
197
|
+
);
|
|
198
|
+
return { scheduler, started, release: (h: string) => gates.get(h)?.() };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
it('runs several independent hashes at once instead of one at a time', async () => {
|
|
202
|
+
// The bug this whole change exists for: strictly serial draining meant one
|
|
203
|
+
// registration RPC at a time for the entire client.
|
|
204
|
+
const { scheduler, started, release } = makeConcurrent(['a', 'b', 'c', 'd', 'e']);
|
|
205
|
+
const pass = scheduler.syncDown();
|
|
206
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
207
|
+
|
|
208
|
+
// Bounded by MAX_CONCURRENT_DOWN (4), not 1.
|
|
209
|
+
expect(started).toEqual(['a', 'b', 'c', 'd']);
|
|
210
|
+
|
|
211
|
+
// Freeing one slot immediately admits the next event.
|
|
212
|
+
release('a');
|
|
213
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
214
|
+
expect(started).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
215
|
+
|
|
216
|
+
for (const h of ['b', 'c', 'd', 'e']) release(h);
|
|
217
|
+
await pass;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('never overlaps two events for the SAME hash', async () => {
|
|
221
|
+
// Per-hash ordering is the one invariant the serial queue was providing.
|
|
222
|
+
const { scheduler, started, release } = makeConcurrent(['a', 'a', 'b']);
|
|
223
|
+
const pass = scheduler.syncDown();
|
|
224
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
225
|
+
|
|
226
|
+
// The second 'a' is skipped while the first is in flight; 'b' goes ahead.
|
|
227
|
+
expect(started).toEqual(['a', 'b']);
|
|
228
|
+
|
|
229
|
+
release('a');
|
|
230
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
231
|
+
expect(started).toEqual(['a', 'b', 'a']);
|
|
232
|
+
|
|
233
|
+
release('b');
|
|
234
|
+
release('a');
|
|
235
|
+
await pass;
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -8,6 +8,17 @@ import { SyncQueueEventTypes } from './events/index';
|
|
|
8
8
|
*/
|
|
9
9
|
/** Backoff for re-draining a queue that halted on an error. */
|
|
10
10
|
const RETRY_BASE_MS = 500;
|
|
11
|
+
/**
|
|
12
|
+
* How many down events may be in flight at once.
|
|
13
|
+
*
|
|
14
|
+
* The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
|
|
15
|
+
* time for the WHOLE client. Measured in production that drained at roughly one
|
|
16
|
+
* event per 8.8s, so a list that registers a query per scrolled-to window took
|
|
17
|
+
* minutes to fill — the rows were already cached, only the registration lagged.
|
|
18
|
+
* Ordering is a per-hash requirement, not a global one (see `takeNext`), so
|
|
19
|
+
* independent hashes can go in parallel. Bounded to stay polite to the SSP.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_CONCURRENT_DOWN = 4;
|
|
11
22
|
const RETRY_MAX_MS = 15_000;
|
|
12
23
|
|
|
13
24
|
export class SyncScheduler {
|
|
@@ -197,14 +208,58 @@ export class SyncScheduler {
|
|
|
197
208
|
|
|
198
209
|
this.isSyncingDown = true;
|
|
199
210
|
let processedAny = false;
|
|
211
|
+
// Hashes currently in flight. `takeNext` skips their events, so per-hash
|
|
212
|
+
// ordering holds while independent hashes run concurrently.
|
|
213
|
+
const busy = new Set<string>();
|
|
214
|
+
const inFlight = new Set<Promise<void>>();
|
|
215
|
+
// First failure of this pass. Once set we stop TAKING work (mirroring the
|
|
216
|
+
// old "halt the pass and retry on a backoff") but still let what is already
|
|
217
|
+
// in flight finish, rather than abandoning RPCs mid-round.
|
|
218
|
+
let failure: unknown | undefined;
|
|
200
219
|
try {
|
|
201
|
-
|
|
202
|
-
if (this.
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
for (;;) {
|
|
221
|
+
if (this.paused) break;
|
|
222
|
+
// Yield to the up queue: stop taking new down work so a pending
|
|
223
|
+
// mutation gets its turn. The old code `break`-ed out and came back
|
|
224
|
+
// only on a backoff, so a steady trickle of local writes could starve
|
|
225
|
+
// registration indefinitely; draining what is in flight and re-arming
|
|
226
|
+
// immediately interleaves the two instead.
|
|
227
|
+
const yieldToUp = this.upQueue.size > 0;
|
|
228
|
+
while (
|
|
229
|
+
failure === undefined &&
|
|
230
|
+
!yieldToUp &&
|
|
231
|
+
inFlight.size < MAX_CONCURRENT_DOWN
|
|
232
|
+
) {
|
|
233
|
+
const event = this.downQueue.takeNext(busy);
|
|
234
|
+
if (!event) break;
|
|
235
|
+
const hash = event.payload.hash;
|
|
236
|
+
busy.add(hash);
|
|
237
|
+
processedAny = true;
|
|
238
|
+
// `run` never rejects — it hands the error back — so one failing
|
|
239
|
+
// event cannot reject the `Promise.race` below and lose the rest.
|
|
240
|
+
const task: Promise<void> = this.downQueue
|
|
241
|
+
.run(event, this.onProcessDown)
|
|
242
|
+
.then((error) => {
|
|
243
|
+
if (error !== undefined && failure === undefined) failure = error;
|
|
244
|
+
})
|
|
245
|
+
.finally(() => {
|
|
246
|
+
busy.delete(hash);
|
|
247
|
+
inFlight.delete(task);
|
|
248
|
+
});
|
|
249
|
+
inFlight.add(task);
|
|
250
|
+
}
|
|
251
|
+
if (inFlight.size === 0) break;
|
|
252
|
+
// Wake as soon as ANY slot frees, so the pool refills continuously
|
|
253
|
+
// instead of in barriers.
|
|
254
|
+
await Promise.race(inFlight);
|
|
255
|
+
if (yieldToUp && inFlight.size === 0) break;
|
|
205
256
|
}
|
|
257
|
+
if (failure !== undefined) throw failure;
|
|
206
258
|
if (processedAny) this.onSyncOutcome?.(true);
|
|
207
259
|
this.downRetryAttempt = 0;
|
|
260
|
+
// Re-arm if we stopped early for the up queue (or hit the pool cap with
|
|
261
|
+
// work still queued) — otherwise that work waits for a fresh enqueue.
|
|
262
|
+
if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
|
|
208
263
|
} catch (error) {
|
|
209
264
|
this.onSyncOutcome?.(false, error);
|
|
210
265
|
this.scheduleDownRetry();
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -74,6 +74,15 @@ export interface Sp00kySyncOptions {
|
|
|
74
74
|
* up-queue for the session. Defaults to 30000; `0` disables the timeout.
|
|
75
75
|
*/
|
|
76
76
|
pushTimeoutMs?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Max time a single down event (`register`/`sync`/`cleanup`) may take before
|
|
79
|
+
* it is treated as a network failure and retried. The mirror of
|
|
80
|
+
* {@link pushTimeoutMs} for the read side, which had no such guard: a
|
|
81
|
+
* `fn::query::register` that never settled held its slot in the down drain,
|
|
82
|
+
* and every later registration behind it, for the rest of the session.
|
|
83
|
+
* Defaults to 30000; `0` disables the timeout.
|
|
84
|
+
*/
|
|
85
|
+
downTimeoutMs?: number;
|
|
77
86
|
/**
|
|
78
87
|
* Transport supervisor. Sync reads its state to report `connection` in
|
|
79
88
|
* {@link SyncHealth} so a UI can show "reconnecting…" the instant the socket
|
|
@@ -225,6 +234,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
225
234
|
private readonly degradeAfterFailures: number;
|
|
226
235
|
/** Per-push RPC deadline; see {@link withPushTimeout}. */
|
|
227
236
|
private readonly pushTimeoutMs: number;
|
|
237
|
+
private readonly downTimeoutMs: number;
|
|
228
238
|
private consecutiveSyncFailures = 0;
|
|
229
239
|
private syncHealthStatus: SyncHealthStatus = 'healthy';
|
|
230
240
|
private lastSyncErrorKind: 'network' | 'application' | undefined;
|
|
@@ -461,6 +471,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
461
471
|
this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
|
|
462
472
|
this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
|
|
463
473
|
this.pushTimeoutMs = Math.max(0, options?.pushTimeoutMs ?? 30_000);
|
|
474
|
+
this.downTimeoutMs = Math.max(0, options?.downTimeoutMs ?? 30_000);
|
|
464
475
|
this.connectionSupervisor = options?.connectionSupervisor;
|
|
465
476
|
}
|
|
466
477
|
|
|
@@ -473,6 +484,13 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
473
484
|
if (this.isInit) throw new Error('Sp00kySync is already initialized');
|
|
474
485
|
this.isInit = true;
|
|
475
486
|
await this.scheduler.init({ loadOutbox: this.tabRole !== 'follower' });
|
|
487
|
+
// Boot is local-first now, so init() routinely runs BEFORE the socket is
|
|
488
|
+
// up. Treat that as "the socket we registered on is gone": otherwise the
|
|
489
|
+
// first `connected` takes the initial-connect branch, returns early, and
|
|
490
|
+
// never re-enqueues `register` for queries that registered while offline.
|
|
491
|
+
// They would still heal via the down-queue backoff, but slowly and only
|
|
492
|
+
// because every query happens to enqueue its own register.
|
|
493
|
+
if (this.remote.getStatus() !== 'connected') this.needsResubscribe = true;
|
|
476
494
|
this.subscribeToReconnect();
|
|
477
495
|
this.subscribeToConnectionState();
|
|
478
496
|
void this.scheduler.syncUp();
|
|
@@ -1449,6 +1467,24 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
1449
1467
|
{ event, Category: 'sp00ky-client::Sp00kySync::processDownEvent' },
|
|
1450
1468
|
'Processing down event'
|
|
1451
1469
|
);
|
|
1470
|
+
// Bounded for the same reason a push is (see withPushTimeout): an RPC that
|
|
1471
|
+
// never settles would otherwise hold its slot in the concurrent down drain
|
|
1472
|
+
// — and, before that drain existed, the WHOLE queue — for the rest of the
|
|
1473
|
+
// session, with no retry, no error, and every dependent `useQuery` stuck
|
|
1474
|
+
// loading. "timed out" in the message keeps `classifySyncError` treating it
|
|
1475
|
+
// as a network failure, so `DownQueue.run` re-heads it for the next pass.
|
|
1476
|
+
return this.withDownTimeout(this.runDownEvent(event), `${event.type} ${event.payload.hash}`);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
private withDownTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
1480
|
+
return withTimeout(
|
|
1481
|
+
promise,
|
|
1482
|
+
this.downTimeoutMs,
|
|
1483
|
+
`Down event timed out after ${this.downTimeoutMs}ms (${label})`
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
private async runDownEvent(event: DownEvent): Promise<void> {
|
|
1452
1488
|
switch (event.type) {
|
|
1453
1489
|
case 'register':
|
|
1454
1490
|
return this.registerQuery(event.payload.hash);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
reviveRow,
|
|
7
7
|
serializeRow,
|
|
8
8
|
project,
|
|
9
|
+
projectedDataSql,
|
|
9
10
|
} from './sqlite-plan-sql';
|
|
10
11
|
import type { Logger } from '../logger/index';
|
|
11
12
|
import type { Sp00kyConfig, StorageHealth } from '../../types';
|
|
@@ -772,7 +773,8 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
772
773
|
}
|
|
773
774
|
await this.ensureTable(plan.table);
|
|
774
775
|
const bind: unknown[] = [];
|
|
775
|
-
|
|
776
|
+
// Projection binds land in the SELECT clause, ahead of any WHERE binds.
|
|
777
|
+
const proj = plan.select ? projectedDataSql(plan.select, bind) : 'data';
|
|
776
778
|
let sql = `SELECT ${proj} FROM "${plan.table}"`;
|
|
777
779
|
if (plan.where && plan.where.length > 0) {
|
|
778
780
|
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
@@ -785,10 +787,8 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
785
787
|
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
786
788
|
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
787
789
|
const rows = await this.execRows(sql, bind);
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
await resolveRelations(projected, plan.relations, this);
|
|
791
|
-
return projected;
|
|
790
|
+
await resolveRelations(rows, plan.relations, this);
|
|
791
|
+
return rows;
|
|
792
792
|
}
|
|
793
793
|
|
|
794
794
|
async fetchRelation(req: RelationFetch): Promise<Row[]> {
|
|
@@ -816,14 +816,19 @@ export class SqliteCacheEngine implements LocalStore {
|
|
|
816
816
|
await this.ensureTable(table);
|
|
817
817
|
const keys = ids.map(stableKey);
|
|
818
818
|
const placeholders = keys.map(() => '?').join(', ');
|
|
819
|
-
|
|
819
|
+
// Projection binds land in the SELECT clause, so they go first. Must stay
|
|
820
|
+
// byte-identical to the worker path in sqlite-select.ts.
|
|
821
|
+
const bind: unknown[] = [];
|
|
822
|
+
const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : 'data';
|
|
823
|
+
bind.push(...keys);
|
|
824
|
+
let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
|
|
820
825
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
821
|
-
let rows = await this.execRows(sql,
|
|
826
|
+
let rows = await this.execRows(sql, bind);
|
|
822
827
|
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
823
828
|
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
824
829
|
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
825
830
|
}
|
|
826
|
-
return
|
|
831
|
+
return rows;
|
|
827
832
|
}
|
|
828
833
|
|
|
829
834
|
async getById(table: string, id: Id): Promise<Row | null> {
|
|
@@ -83,6 +83,38 @@ export function reviveRow(json: string): Row {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
88
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
89
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
90
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
91
|
+
*
|
|
92
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
93
|
+
* what both paths did before, and verified so against real SQLite:
|
|
94
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
95
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
96
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
97
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
98
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
99
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
100
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
103
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
104
|
+
*
|
|
105
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
106
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
107
|
+
*/
|
|
108
|
+
export function projectedDataSql(fields: string[], bind: unknown[]): string {
|
|
109
|
+
const keys = ['id', ...fields];
|
|
110
|
+
for (const k of keys) bind.push(k);
|
|
111
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
112
|
+
return (
|
|
113
|
+
`COALESCE((SELECT json_group_object(je.key, je.value) ` +
|
|
114
|
+
`FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
export function project(row: Row, fields: string[]): Row {
|
|
87
119
|
const out: Row = {};
|
|
88
120
|
for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { projectedDataSql, project, reviveRow, serializeRow } from './sqlite-plan-sql';
|
|
4
|
+
import type { Row } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `projectedDataSql` is the one change in this area that alters what SQLite
|
|
8
|
+
* actually returns, and every row read goes through it — so it is verified
|
|
9
|
+
* against a REAL SQLite, not the scripted responder the parity tests use.
|
|
10
|
+
*
|
|
11
|
+
* The contract: byte-identical to parsing the whole row and running `project`
|
|
12
|
+
* over it, which is what both engine paths did before. The traps it has to
|
|
13
|
+
* clear are an ABSENT key (must stay absent, not become an explicit null — the
|
|
14
|
+
* difference `json_object`/`json_extract` would have introduced), a STORED
|
|
15
|
+
* null, nested objects/arrays, and the `{__u8}` tag that carries binary.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function makeDb(rows: Row[]) {
|
|
19
|
+
const db = new DatabaseSync(':memory:');
|
|
20
|
+
db.exec('CREATE TABLE "game" (id TEXT PRIMARY KEY, data TEXT NOT NULL)');
|
|
21
|
+
const ins = db.prepare('INSERT INTO "game" (id,data) VALUES (?,?)');
|
|
22
|
+
for (const r of rows) ins.run(String(r.id), serializeRow(r));
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the code did before: read the whole row, parse it, then project. */
|
|
27
|
+
function referenceRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
28
|
+
const ph = ids.map(() => '?').join(', ');
|
|
29
|
+
return db
|
|
30
|
+
.prepare(`SELECT data FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
31
|
+
.all(...ids)
|
|
32
|
+
.map((r) => project(reviveRow((r as { data: string }).data), fields));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the code does now: let SQLite narrow the row before it is ever parsed. */
|
|
36
|
+
function projectedRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
37
|
+
const bind: unknown[] = [];
|
|
38
|
+
const col = projectedDataSql(fields, bind);
|
|
39
|
+
bind.push(...ids);
|
|
40
|
+
const ph = ids.map(() => '?').join(', ');
|
|
41
|
+
return db
|
|
42
|
+
.prepare(`SELECT ${col} FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
43
|
+
.all(...(bind as string[]))
|
|
44
|
+
.map((r) => reviveRow((r as { data: string }).data));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FIXTURE: Row[] = [
|
|
48
|
+
{ id: 'game:1', white: 'player_name:PN_a', pgn: '[Event "x"]\n1. e4 e5', sort_index: -5 },
|
|
49
|
+
{ id: 'game:2', white: null, pgn: 'heavy', sort_index: -3 }, // STORED null
|
|
50
|
+
{ id: 'game:3', pgn: 'heavy', sort_index: -4 }, // 'white' ABSENT
|
|
51
|
+
{ id: 'game:4', white: 'w', pgn: 'heavy', sort_index: -1, meta: { a: 1, b: [2, 3] } },
|
|
52
|
+
];
|
|
53
|
+
const IDS = ['game:1', 'game:2', 'game:3', 'game:4'];
|
|
54
|
+
const FIELDS = ['white', 'sort_index', 'meta'];
|
|
55
|
+
|
|
56
|
+
describe('projectedDataSql against real SQLite', () => {
|
|
57
|
+
it('is byte-identical to parse-then-project', () => {
|
|
58
|
+
const db = makeDb(FIXTURE);
|
|
59
|
+
expect(projectedRows(db, IDS, FIELDS)).toEqual(referenceRows(db, IDS, FIELDS));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never returns the fields that were not asked for', () => {
|
|
63
|
+
// The point of the change: `pgn` must not leave SQLite, so it is never
|
|
64
|
+
// parsed, never crosses the worker boundary, never reaches the store.
|
|
65
|
+
const db = makeDb(FIXTURE);
|
|
66
|
+
for (const row of projectedRows(db, IDS, FIELDS)) expect('pgn' in row).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps an absent key absent and a stored null null', () => {
|
|
70
|
+
const db = makeDb(FIXTURE);
|
|
71
|
+
const byId = new Map(projectedRows(db, IDS, FIELDS).map((r) => [r.id, r]));
|
|
72
|
+
expect('white' in byId.get('game:3')!).toBe(false); // absent stays absent
|
|
73
|
+
expect('white' in byId.get('game:2')!).toBe(true); // stored null is a real key
|
|
74
|
+
expect(byId.get('game:2')!.white).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('round-trips nested objects and arrays as JSON, not as strings', () => {
|
|
78
|
+
const db = makeDb(FIXTURE);
|
|
79
|
+
const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:4')!;
|
|
80
|
+
expect(row.meta).toEqual({ a: 1, b: [2, 3] });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('round-trips binary through the {__u8} tag', () => {
|
|
84
|
+
const db = makeDb([{ id: 'game:9', blob: new Uint8Array([0, 1, 2]), pgn: 'heavy' }]);
|
|
85
|
+
const [row] = projectedRows(db, ['game:9'], ['blob']);
|
|
86
|
+
expect(row!.blob).toEqual(new Uint8Array([0, 1, 2]));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('always includes id, even when it is not in the field list', () => {
|
|
90
|
+
const db = makeDb(FIXTURE);
|
|
91
|
+
for (const row of projectedRows(db, IDS, ['sort_index'])) expect(row.id).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('yields an empty object, not NULL, for a row sharing none of the keys', () => {
|
|
95
|
+
// Without COALESCE the subquery returns NULL here and reviveRow throws.
|
|
96
|
+
const db = makeDb([{ id: 'game:8', pgn: 'heavy' }]);
|
|
97
|
+
expect(projectedRows(db, ['game:8'], ['nothing_matches'])).toEqual([{ id: 'game:8' }]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
2
|
import { resolveRelations, stableKey } from './relation-resolver';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
renderOrderSql,
|
|
5
|
+
renderWhereSql,
|
|
6
|
+
reviveRow,
|
|
7
|
+
project,
|
|
8
|
+
projectedDataSql,
|
|
9
|
+
} from './sqlite-plan-sql';
|
|
4
10
|
import type { OrderBy, RelationFetch, Row, RowFetcher } from './cache-engine';
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -48,14 +54,18 @@ function selectByIds(
|
|
|
48
54
|
ensureTable(db, table);
|
|
49
55
|
const keys = ids.map(stableKey);
|
|
50
56
|
const placeholders = keys.map(() => '?').join(', ');
|
|
51
|
-
|
|
57
|
+
// Projection binds land in the SELECT clause, so they go first.
|
|
58
|
+
const bind: unknown[] = [];
|
|
59
|
+
const dataCol = opts?.select ? projectedDataSql(opts.select, bind) : 'data';
|
|
60
|
+
bind.push(...keys);
|
|
61
|
+
let sql = `SELECT ${dataCol} FROM "${table}" WHERE id IN (${placeholders})`;
|
|
52
62
|
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
53
|
-
let rows = execRows(db, sql,
|
|
63
|
+
let rows = execRows(db, sql, bind);
|
|
54
64
|
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
55
65
|
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
56
66
|
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
57
67
|
}
|
|
58
|
-
return
|
|
68
|
+
return rows;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
@@ -98,7 +108,9 @@ export async function executeSelect(
|
|
|
98
108
|
}
|
|
99
109
|
ensureTable(db, plan.table);
|
|
100
110
|
const bind: unknown[] = [];
|
|
101
|
-
|
|
111
|
+
// Projection binds land in the SELECT clause, ahead of any WHERE binds.
|
|
112
|
+
const dataCol = plan.select ? projectedDataSql(plan.select, bind) : 'data';
|
|
113
|
+
let sql = `SELECT ${dataCol} FROM "${plan.table}"`;
|
|
102
114
|
if (plan.where && plan.where.length > 0) {
|
|
103
115
|
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
104
116
|
}
|
|
@@ -114,8 +126,6 @@ export async function executeSelect(
|
|
|
114
126
|
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
115
127
|
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
116
128
|
const rows = execRows(db, sql, bind);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
await resolveRelations(projected, plan.relations, fetcher);
|
|
120
|
-
return { rows: projected, relationFetches: counter.n };
|
|
129
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
130
|
+
return { rows, relationFetches: counter.n };
|
|
121
131
|
}
|