@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.
@@ -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
- try {
96
- await fn(event);
97
- this.failures.delete(event);
98
- } catch (error) {
99
- const attempts = (this.failures.get(event) ?? 0) + 1;
100
- this.failures.set(event, attempts);
101
- // Re-head so a transient failure keeps its ordering, but give up the
102
- // head once it looks permanent and there is other work waiting — one
103
- // unregisterable query must not stall every other query's registration.
104
- const starvingOthers = attempts >= MAX_HEAD_RETRIES && this.queue.length > 0;
105
- if (starvingOthers) {
106
- this.queue.push(event);
107
- } else {
108
- this.queue.unshift(event);
109
- }
110
- this.logger.error(
111
- { error, event, attempts, rotated: starvingOthers, Category: 'sp00ky-client::DownQueue::next' },
112
- 'Failed to process query'
113
- );
114
- throw error;
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
- throw err;
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
- while (this.downQueue.size > 0 && !this.paused) {
202
- if (this.upQueue.size > 0) break;
203
- await this.downQueue.next(this.onProcessDown);
204
- processedAny = true;
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();
@@ -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
- const proj = 'data';
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
- // Optional projection trimming to match `SELECT <fields>`.
789
- const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
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
- let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
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, keys);
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 opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
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 { renderOrderSql, renderWhereSql, reviveRow, project } from './sqlite-plan-sql';
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
- let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
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, keys);
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 opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
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
- let sql = `SELECT data FROM "${plan.table}"`;
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
- // Optional projection trimming to match `SELECT <fields>`.
118
- const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
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
  }