@spooky-sync/core 0.0.1-canary.133 → 0.0.1-canary.134

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.
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { WhereNode } from '@spooky-sync/query-builder';
3
+ import {
4
+ comparisonSql,
5
+ renderWhereSql,
6
+ renderOrderSql,
7
+ scalar,
8
+ serializeRow,
9
+ reviveRow,
10
+ project,
11
+ } from './sqlite-plan-sql';
12
+
13
+ describe('comparisonSql param slaving (aa4af79b)', () => {
14
+ it('prefers params[paramRef] over the baked literal', () => {
15
+ const bind: unknown[] = [];
16
+ const sql = comparisonSql(
17
+ { field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
18
+ bind,
19
+ { id: 'thread:B' }
20
+ );
21
+ expect(sql).toBe('id = ?');
22
+ expect(bind).toEqual(['thread:B']);
23
+ });
24
+
25
+ it('falls back to the baked literal when the param key is absent', () => {
26
+ const bind: unknown[] = [];
27
+ comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
28
+ expect(bind).toEqual(['thread:A']);
29
+ });
30
+
31
+ it('a pure $-ref (no baked value) always reads the param', () => {
32
+ const bind: unknown[] = [];
33
+ comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
34
+ auth: 'user:1',
35
+ });
36
+ expect(bind).toEqual(['user:1']);
37
+ });
38
+
39
+ it('renders non-id fields via json_extract and honors swap', () => {
40
+ const bind: unknown[] = [];
41
+ const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
42
+ expect(sql).toBe(`? < json_extract(data, '$.votes')`);
43
+ expect(bind).toEqual([5]);
44
+ });
45
+ });
46
+
47
+ describe('renderWhereSql', () => {
48
+ it('joins top-level nodes with AND and parenthesizes OR groups', () => {
49
+ const nodes: WhereNode[] = [
50
+ { field: 'kind', op: '=', value: 'a' },
51
+ {
52
+ or: [
53
+ { field: 'votes', op: '>', value: 1 },
54
+ { field: 'votes', op: '=', value: 0 },
55
+ ],
56
+ },
57
+ ];
58
+ const bind: unknown[] = [];
59
+ const sql = renderWhereSql(nodes, bind, {});
60
+ expect(sql).toBe(
61
+ `json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
62
+ );
63
+ expect(bind).toEqual(['a', 1, 0]);
64
+ });
65
+ });
66
+
67
+ describe('renderOrderSql', () => {
68
+ it('renders multi-key ordering', () => {
69
+ expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
70
+ ` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
71
+ );
72
+ });
73
+ });
74
+
75
+ describe('scalar', () => {
76
+ it('binds RecordId-shaped objects as table:id strings', () => {
77
+ expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
78
+ expect(scalar('plain')).toBe('plain');
79
+ expect(scalar(3)).toBe(3);
80
+ expect(scalar(null)).toBeNull();
81
+ expect(scalar(undefined)).toBeNull();
82
+ });
83
+ });
84
+
85
+ describe('serializeRow / reviveRow round-trip', () => {
86
+ it('tags Uint8Array as {__u8} and revives it', () => {
87
+ const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
88
+ expect(json).toContain('"__u8"');
89
+ const back = reviveRow(json);
90
+ expect(back.blob).toBeInstanceOf(Uint8Array);
91
+ expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
92
+ });
93
+
94
+ it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
95
+ const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
96
+ expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
97
+ });
98
+ });
99
+
100
+ describe('project', () => {
101
+ it('keeps id plus the listed fields only, skipping absent ones', () => {
102
+ expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
103
+ });
104
+ });
@@ -0,0 +1,106 @@
1
+ import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
2
+ import { stableKey } from './relation-resolver';
3
+ import type { OrderBy, Row } from './cache-engine';
4
+
5
+ /**
6
+ * SQL rendering + value (de)serialization for the SQLite cache backend.
7
+ * Extracted from `sqlite-cache-engine.ts` so BOTH sides of the worker boundary
8
+ * can use it: the engine (main thread) for the legacy/shim paths, and
9
+ * `sqlite-worker.ts` for worker-side plan execution (`select`). Pure module —
10
+ * no DOM, no worker, no engine imports — mirroring `plan-render.ts`.
11
+ */
12
+
13
+ export function renderOrderSql(orderBy: OrderBy): string {
14
+ return ` ORDER BY ${orderBy
15
+ .map(([f, d]) => `json_extract(data, '$.${f}') ${d === 'desc' ? 'DESC' : 'ASC'}`)
16
+ .join(', ')}`;
17
+ }
18
+
19
+ export function comparisonSql(
20
+ c: WhereComparison,
21
+ bind: unknown[],
22
+ params: Record<string, unknown>
23
+ ): string {
24
+ const lhs = c.field === 'id' ? 'id' : `json_extract(data, '$.${c.field}')`;
25
+ // Prefer the query's own param so a filter materializes from `params` (the
26
+ // query's identity), not a baked literal. A pure `$`-ref has no `value`; a
27
+ // slave-mode node keeps `value` as a fallback for a param absent from params.
28
+ const useParam =
29
+ c.paramRef !== undefined &&
30
+ (c.value === undefined || Object.prototype.hasOwnProperty.call(params, c.paramRef));
31
+ const value = useParam ? params[c.paramRef!] : c.value;
32
+ bind.push(scalar(value));
33
+ const op = c.op === '!=' ? '!=' : c.op;
34
+ return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
35
+ }
36
+
37
+ export function renderWhereSql(
38
+ nodes: WhereNode[],
39
+ bind: unknown[],
40
+ params: Record<string, unknown>
41
+ ): string {
42
+ return nodes
43
+ .map((node) => {
44
+ if ('or' in node) {
45
+ return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(' OR ')})`;
46
+ }
47
+ return comparisonSql(node, bind, params);
48
+ })
49
+ .join(' AND ');
50
+ }
51
+
52
+ // ==================== value (de)serialization ====================
53
+
54
+ /** A comparable scalar for SQL binding: record links → `table:id`, everything
55
+ * else passed through (numbers/strings/bools). */
56
+ export function scalar(value: unknown): unknown {
57
+ if (value == null) return null;
58
+ if (typeof value === 'object') return stableKey(value);
59
+ return value;
60
+ }
61
+
62
+ export function serializeRow(row: Row): string {
63
+ return JSON.stringify(row, (_k, v) => {
64
+ if (v instanceof Uint8Array) return { __u8: toBase64(v) };
65
+ if (v && typeof v === 'object') {
66
+ const rid = v as { tb?: unknown; id?: unknown };
67
+ if (rid.tb !== undefined && rid.id !== undefined) return stableKey(v);
68
+ }
69
+ return v;
70
+ });
71
+ }
72
+
73
+ export function reviveRow(json: string): Row {
74
+ // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
75
+ // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
76
+ // invoking a JS callback for every key of every row on the read hot path.
77
+ if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
78
+ return JSON.parse(json, (_k, v) => {
79
+ if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
80
+ return fromBase64((v as any).__u8);
81
+ }
82
+ return v;
83
+ });
84
+ }
85
+
86
+ export function project(row: Row, fields: string[]): Row {
87
+ const out: Row = {};
88
+ for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
89
+ return out;
90
+ }
91
+
92
+ export function toBase64(bytes: Uint8Array): string {
93
+ let bin = '';
94
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
95
+ return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
96
+ }
97
+
98
+ export function fromBase64(b64: string): Uint8Array {
99
+ if (typeof atob !== 'undefined') {
100
+ const bin = atob(b64);
101
+ const out = new Uint8Array(bin.length);
102
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
103
+ return out;
104
+ }
105
+ return new Uint8Array(Buffer.from(b64, 'base64'));
106
+ }
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
3
+ import type { QueryPlan } from '@spooky-sync/query-builder';
4
+ import { executeSelect, type SelectDb } from './sqlite-select';
5
+ import { serializeRow } from './sqlite-plan-sql';
6
+ import type { Row } from './cache-engine';
7
+
8
+ /**
9
+ * Integration: `executeSelect` (the worker-side plan executor) against a REAL
10
+ * in-memory SQLite (the same @sqlite.org/sqlite-wasm build the worker loads).
11
+ * The scripted-responder tests in `sqlite-select.test.ts` prove parity of the
12
+ * statement sequence; these prove the SQL itself is correct — json_extract
13
+ * filtering, ORDER BY, LIMIT/OFFSET, IN matching, per-parent relation
14
+ * order/limit, and the JSON round-trip of row bodies.
15
+ */
16
+
17
+ let db: any;
18
+ let selectDb: SelectDb;
19
+
20
+ function seed(table: string, rows: Row[]): void {
21
+ db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
22
+ selectDb.knownTables.add(table);
23
+ for (const row of rows) {
24
+ db.exec({
25
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?)`,
26
+ bind: [row.id, serializeRow(row)],
27
+ });
28
+ }
29
+ }
30
+
31
+ beforeAll(async () => {
32
+ const sqlite3: any = await sqlite3InitModule();
33
+ db = new sqlite3.oo1.DB(':memory:', 'c');
34
+ selectDb = {
35
+ exec: (sql, bind) =>
36
+ db.exec({ sql, bind, rowMode: 'object', returnValue: 'resultRows' }) as { data: string }[],
37
+ run: (sql, bind) => {
38
+ db.exec({ sql, bind });
39
+ },
40
+ knownTables: new Set<string>(),
41
+ };
42
+
43
+ seed('thread', [
44
+ { id: 'thread:A', title: 'alpha', createdAt: 3, author: 'user:1' },
45
+ { id: 'thread:B', title: 'beta', createdAt: 1, author: 'user:2' },
46
+ { id: 'thread:C', title: 'gamma', createdAt: 2, author: 'user:1' },
47
+ ]);
48
+ seed('comment', [
49
+ { id: 'comment:1', thread: 'thread:B', body: 'b-old', votes: 1, author: 'user:1' },
50
+ { id: 'comment:2', thread: 'thread:B', body: 'b-new', votes: 5, author: 'user:2' },
51
+ { id: 'comment:3', thread: 'thread:B', body: 'b-mid', votes: 3, author: 'user:1' },
52
+ { id: 'comment:4', thread: 'thread:A', body: 'a-only', votes: 2, author: 'user:2' },
53
+ ]);
54
+ seed('user', [
55
+ { id: 'user:1', name: 'ada' },
56
+ { id: 'user:2', name: 'grace' },
57
+ ]);
58
+ seed('snapshot', [{ id: 'snapshot:1', blob: new Uint8Array([1, 2, 250]), kind: 'crdt' }]);
59
+ });
60
+
61
+ afterAll(() => {
62
+ db?.close();
63
+ });
64
+
65
+ describe('executeSelect against real SQLite', () => {
66
+ it('filters via json_extract, slaved to params over the baked literal (aa4af79b)', async () => {
67
+ const plan: QueryPlan = {
68
+ table: 'thread',
69
+ where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
70
+ };
71
+ const withParam = await executeSelect(plan, { id: 'thread:B' }, selectDb);
72
+ expect(withParam.rows.map((r) => r.id)).toEqual(['thread:B']);
73
+
74
+ const withoutParam = await executeSelect(plan, {}, selectDb);
75
+ expect(withoutParam.rows.map((r) => r.id)).toEqual(['thread:A']);
76
+ });
77
+
78
+ it('filters on a non-id field and binds a RecordId-shaped value as table:id', async () => {
79
+ const plan: QueryPlan = {
80
+ table: 'thread',
81
+ where: [{ field: 'author', op: '=', value: { tb: 'user', id: '1' } }],
82
+ orderBy: [['createdAt', 'asc']],
83
+ };
84
+ const { rows } = await executeSelect(plan, {}, selectDb);
85
+ expect(rows.map((r) => r.id)).toEqual(['thread:C', 'thread:A']);
86
+ });
87
+
88
+ it('applies ORDER BY / LIMIT / OFFSET in SQL', async () => {
89
+ const plan: QueryPlan = {
90
+ table: 'thread',
91
+ orderBy: [['createdAt', 'desc']],
92
+ limit: 2,
93
+ offset: 1,
94
+ };
95
+ const { rows } = await executeSelect(plan, {}, selectDb);
96
+ expect(rows.map((r) => r.id)).toEqual(['thread:C', 'thread:B']);
97
+ });
98
+
99
+ it('supports OR groups', async () => {
100
+ const plan: QueryPlan = {
101
+ table: 'thread',
102
+ where: [
103
+ {
104
+ or: [
105
+ { field: 'title', op: '=', value: 'alpha' },
106
+ { field: 'title', op: '=', value: 'beta' },
107
+ ],
108
+ },
109
+ ],
110
+ orderBy: [['title', 'asc']],
111
+ };
112
+ const { rows } = await executeSelect(plan, {}, selectDb);
113
+ expect(rows.map((r) => r.id)).toEqual(['thread:A', 'thread:B']);
114
+ });
115
+
116
+ it('resolves a nested relation tree with per-parent order/limit, one batch per level', async () => {
117
+ const plan: QueryPlan = {
118
+ table: 'thread',
119
+ where: [{ field: 'id', op: '=', value: 'thread:B' }],
120
+ relations: [
121
+ {
122
+ alias: 'comments',
123
+ table: 'comment',
124
+ cardinality: 'many',
125
+ foreignKeyField: 'thread',
126
+ orderBy: [['votes', 'desc']],
127
+ limit: 2,
128
+ relations: [
129
+ { alias: 'author', table: 'user', cardinality: 'one', foreignKeyField: 'author' },
130
+ ],
131
+ },
132
+ ],
133
+ };
134
+ const { rows, relationFetches } = await executeSelect(plan, {}, selectDb);
135
+ expect(rows).toHaveLength(1);
136
+ const comments = rows[0].comments as Row[];
137
+ // Top-2 by votes, per parent.
138
+ expect(comments.map((c) => c.id)).toEqual(['comment:2', 'comment:3']);
139
+ // Nested `one` relation attached on each child.
140
+ expect(comments.map((c) => (c.author as Row).name)).toEqual(['grace', 'ada']);
141
+ // Level-ordered batching: one fetch for comments + one for users.
142
+ expect(relationFetches).toBe(2);
143
+ });
144
+
145
+ it('materializes an ids window preserving ids order, or ORDER BY when given', async () => {
146
+ const windowPlan: QueryPlan = { table: 'comment', ids: ['comment:3', 'comment:1'] };
147
+ const inIdsOrder = await executeSelect(windowPlan, {}, selectDb);
148
+ expect(inIdsOrder.rows.map((r) => r.id)).toEqual(['comment:3', 'comment:1']);
149
+
150
+ const ordered = await executeSelect(
151
+ { ...windowPlan, orderBy: [['votes', 'asc']] },
152
+ {},
153
+ selectDb
154
+ );
155
+ expect(ordered.rows.map((r) => r.id)).toEqual(['comment:1', 'comment:3']);
156
+ });
157
+
158
+ it('trims to the projection while keeping id', async () => {
159
+ const plan: QueryPlan = {
160
+ table: 'thread',
161
+ where: [{ field: 'id', op: '=', value: 'thread:A' }],
162
+ select: ['title'],
163
+ };
164
+ const { rows } = await executeSelect(plan, {}, selectDb);
165
+ expect(rows).toEqual([{ id: 'thread:A', title: 'alpha' }]);
166
+ });
167
+
168
+ it('revives Uint8Array bodies through the {__u8} tag round-trip', async () => {
169
+ const plan: QueryPlan = {
170
+ table: 'snapshot',
171
+ where: [{ field: 'kind', op: '=', value: 'crdt' }],
172
+ };
173
+ const { rows } = await executeSelect(plan, {}, selectDb);
174
+ expect(rows).toHaveLength(1);
175
+ expect(rows[0].blob).toBeInstanceOf(Uint8Array);
176
+ expect([...(rows[0].blob as Uint8Array)]).toEqual([1, 2, 250]);
177
+ });
178
+
179
+ it('creates a missing table on first touch instead of failing', async () => {
180
+ const plan: QueryPlan = { table: 'never_written', where: [{ field: 'x', op: '=', value: 1 }] };
181
+ const { rows } = await executeSelect(plan, {}, selectDb);
182
+ expect(rows).toEqual([]);
183
+ expect(selectDb.knownTables.has('never_written')).toBe(true);
184
+ });
185
+ });
@@ -0,0 +1,279 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { QueryPlan } from '@spooky-sync/query-builder';
4
+ import { executeSelect, type SelectDb } from './sqlite-select';
5
+ import { SqliteCacheEngine } from './sqlite-cache-engine';
6
+ import { stableKey } from './relation-resolver';
7
+ import type { Row } from './cache-engine';
8
+
9
+ function makeLogger(): any {
10
+ const noop = () => {};
11
+ const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
12
+ l.child = () => l;
13
+ return l;
14
+ }
15
+
16
+ /**
17
+ * A tiny scripted "SQL responder" for the EXACT statement shapes the SQLite
18
+ * renderers emit (base select by id, id-window IN, relation json_extract IN).
19
+ * Not a SQL engine — just enough to make legacy-vs-worker parity observable.
20
+ */
21
+ function makeResponder(tables: Record<string, Row[]>) {
22
+ return (sql: string, bind: unknown[] = []): { data: string }[] => {
23
+ const table = /FROM "([^"]+)"/.exec(sql)?.[1];
24
+ let rows = table ? (tables[table] ?? []) : [];
25
+ const rel = /WHERE json_extract\(data, '\$\.([A-Za-z0-9_.]+)'\) IN/.exec(sql);
26
+ if (rel) {
27
+ rows = rows.filter((r) => bind.includes(r[rel[1]]));
28
+ } else if (/WHERE id IN/.test(sql)) {
29
+ rows = rows.filter((r) => bind.includes(r.id));
30
+ } else if (/WHERE id = \?/.test(sql)) {
31
+ rows = rows.filter((r) => r.id === bind[0]);
32
+ }
33
+ return rows.map((r) => ({ data: JSON.stringify(r) }));
34
+ };
35
+ }
36
+
37
+ const FIXTURE: Record<string, Row[]> = {
38
+ thread: [
39
+ { id: 'thread:A', title: 'thread A', author: 'user:1' },
40
+ { id: 'thread:B', title: 'thread B', author: 'user:2' },
41
+ ],
42
+ comment: [
43
+ { id: 'comment:1', thread: 'thread:B', body: 'first' },
44
+ { id: 'comment:2', thread: 'thread:B', body: 'second' },
45
+ { id: 'comment:3', thread: 'thread:A', body: 'other thread' },
46
+ ],
47
+ };
48
+
49
+ /** The `.one()` detail-view plan shape: id baked as a literal AND slaved to
50
+ * the `id` param (aa4af79b), plus a `many` relation. */
51
+ const DETAIL_PLAN: QueryPlan = {
52
+ table: 'thread',
53
+ where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
54
+ relations: [
55
+ { alias: 'comments', table: 'comment', cardinality: 'many', foreignKeyField: 'thread' },
56
+ ],
57
+ };
58
+
59
+ function makeSelectDb(
60
+ tables: Record<string, Row[]>,
61
+ calls: { sql: string; bind?: unknown[] }[]
62
+ ): SelectDb {
63
+ const respond = makeResponder(tables);
64
+ return {
65
+ exec: (sql, bind) => {
66
+ calls.push({ sql, bind });
67
+ return respond(sql, bind ?? []);
68
+ },
69
+ run: (sql, bind) => {
70
+ calls.push({ sql, bind });
71
+ },
72
+ knownTables: new Set<string>(),
73
+ };
74
+ }
75
+
76
+ /** An engine whose worker answers exec/run against the fixture (legacy path)
77
+ * and optionally the one-hop `select` op, recording every message. */
78
+ function makeEngine(opts: {
79
+ workerSelect: boolean;
80
+ answerSelect?: boolean;
81
+ calls?: { type: string; sql?: string; bind?: unknown[]; payload?: any }[];
82
+ }) {
83
+ const respond = makeResponder(FIXTURE);
84
+ const calls = opts.calls ?? [];
85
+ const engine = new SqliteCacheEngine(
86
+ { namespace: 'n', database: 'd' } as any,
87
+ makeLogger(),
88
+ { useOpfs: false, workerSelect: opts.workerSelect }
89
+ );
90
+ (engine as any).spawnWorker = () => {
91
+ const w: any = {
92
+ onerror: null,
93
+ onmessageerror: null,
94
+ terminate() {},
95
+ postMessage(msg: any) {
96
+ calls.push({
97
+ type: msg.type,
98
+ sql: msg.payload?.sql,
99
+ bind: msg.payload?.bind,
100
+ payload: msg.payload,
101
+ });
102
+ Promise.resolve().then(async () => {
103
+ let ok = true;
104
+ let error: string | undefined;
105
+ let rest: Record<string, unknown> = {};
106
+ try {
107
+ if (msg.type === 'open') rest = { persisted: false };
108
+ else if (msg.type === 'exec') rest = { rows: respond(msg.payload.sql, msg.payload.bind ?? []) };
109
+ else if (msg.type === 'run' || msg.type === 'batch' || msg.type === 'close') rest = {};
110
+ else if (msg.type === 'select') {
111
+ if (!opts.answerSelect) throw new Error(`sqlite worker: unknown message ${msg.type}`);
112
+ const dbCalls: { sql: string; bind?: unknown[] }[] = [];
113
+ rest = await executeSelect(
114
+ msg.payload.plan,
115
+ msg.payload.params ?? {},
116
+ makeSelectDb(FIXTURE, dbCalls)
117
+ );
118
+ } else throw new Error(`sqlite worker: unknown message ${msg.type}`);
119
+ } catch (e) {
120
+ ok = false;
121
+ error = e instanceof Error ? e.message : String(e);
122
+ }
123
+ (engine as any).worker &&
124
+ (engine as any).pending.get(msg.id) &&
125
+ ((): void => {
126
+ const p = (engine as any).pending.get(msg.id);
127
+ (engine as any).pending.delete(msg.id);
128
+ if (ok) p.resolve(rest);
129
+ else p.reject(new Error(error));
130
+ })();
131
+ });
132
+ },
133
+ };
134
+ return w;
135
+ };
136
+ return { engine, calls };
137
+ }
138
+
139
+ describe('executeSelect (worker-side plan execution)', () => {
140
+ it('slaves the base filter to params over the baked literal (aa4af79b guard)', async () => {
141
+ const calls: { sql: string; bind?: unknown[] }[] = [];
142
+ const { rows } = await executeSelect(DETAIL_PLAN, { id: 'thread:B' }, makeSelectDb(FIXTURE, calls));
143
+ // Filtered by the PARAM (thread:B), not the baked literal (thread:A).
144
+ expect(rows).toHaveLength(1);
145
+ expect(rows[0].id).toBe('thread:B');
146
+ expect(rows[0].comments).toEqual([
147
+ { id: 'comment:1', thread: 'thread:B', body: 'first' },
148
+ { id: 'comment:2', thread: 'thread:B', body: 'second' },
149
+ ]);
150
+ });
151
+
152
+ it('falls back to the baked literal when the param key is absent', async () => {
153
+ const calls: { sql: string; bind?: unknown[] }[] = [];
154
+ const { rows } = await executeSelect(DETAIL_PLAN, {}, makeSelectDb(FIXTURE, calls));
155
+ expect(rows).toHaveLength(1);
156
+ expect(rows[0].id).toBe('thread:A');
157
+ });
158
+
159
+ it('counts relation fetches and honors the ids window ordering', async () => {
160
+ const calls: { sql: string; bind?: unknown[] }[] = [];
161
+ const plan: QueryPlan = {
162
+ table: 'comment',
163
+ ids: ['comment:2', 'comment:1'],
164
+ };
165
+ const { rows, relationFetches } = await executeSelect(plan, {}, makeSelectDb(FIXTURE, calls));
166
+ expect(relationFetches).toBe(0);
167
+ // ids order preserved (no ORDER BY).
168
+ expect(rows.map((r) => r.id)).toEqual(['comment:2', 'comment:1']);
169
+ });
170
+ });
171
+
172
+ describe('worker-select parity with the legacy multi-hop path', () => {
173
+ it('produces identical rows AND an identical SQL statement sequence', async () => {
174
+ // Legacy: engine drives one worker round-trip per statement.
175
+ const legacy = makeEngine({ workerSelect: false });
176
+ await legacy.engine.connect('anon');
177
+ const legacyRows = await legacy.engine.select(DETAIL_PLAN, { id: 'thread:B' });
178
+ const legacySql = legacy.calls
179
+ .filter((c) => c.type === 'exec' || c.type === 'run')
180
+ .map((c) => ({ sql: c.sql, bind: c.bind ?? [] }));
181
+
182
+ // Worker path: same plan executed in one hop via executeSelect.
183
+ const workerCalls: { sql: string; bind?: unknown[] }[] = [];
184
+ const { rows: workerRows } = await executeSelect(
185
+ DETAIL_PLAN,
186
+ { id: 'thread:B' },
187
+ makeSelectDb(FIXTURE, workerCalls)
188
+ );
189
+
190
+ expect(workerRows).toEqual(legacyRows);
191
+ expect(workerCalls.map((c) => ({ sql: c.sql, bind: c.bind ?? [] }))).toEqual(legacySql);
192
+ });
193
+ });
194
+
195
+ describe('SqliteCacheEngine.select via worker (one hop)', () => {
196
+ it('sends exactly one select message, zero exec/run', async () => {
197
+ const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
198
+ await engine.connect('anon');
199
+ const rows = await engine.select(DETAIL_PLAN, { id: 'thread:B' });
200
+ expect(rows).toHaveLength(1);
201
+ expect(rows[0].id).toBe('thread:B');
202
+ const afterOpen = calls.filter((c) => c.type !== 'open');
203
+ expect(afterOpen.map((c) => c.type)).toEqual(['select']);
204
+ });
205
+
206
+ it('normalizes RecordId param VALUES to strings but never drops param KEYS', async () => {
207
+ const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
208
+ await engine.connect('anon');
209
+ await engine.select(DETAIL_PLAN, { id: new RecordId('thread', 'B'), untouched: 7 });
210
+ const sel = calls.find((c) => c.type === 'select')!;
211
+ // VALUE normalized (a class instance would lose its prototype in the real
212
+ // structured clone); KEY present — comparisonSql's paramRef resolution
213
+ // checks hasOwnProperty, and a dropped key silently falls back to the
214
+ // baked literal (the aa4af79b crossed-results class).
215
+ expect(sel.payload.params).toEqual({ id: 'thread:B', untouched: 7 });
216
+ expect(Object.prototype.hasOwnProperty.call(sel.payload.params, 'id')).toBe(true);
217
+ });
218
+
219
+ it('normalizes a window plan\'s RecordId ids to the same strings the legacy path binds', async () => {
220
+ const rid = new RecordId('comment', '2');
221
+ const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
222
+ await engine.connect('anon');
223
+ await engine.select({ table: 'comment', ids: [rid, 'comment:1'] }, {});
224
+ const sel = calls.find((c) => c.type === 'select')!;
225
+ // stableKey output (surrealdb may escape the id part, e.g. comment:⟨2⟩) —
226
+ // identical to what the legacy `selectByIds` binds, so stored rows match.
227
+ expect(sel.payload.plan.ids).toEqual([stableKey(rid), 'comment:1']);
228
+ expect(sel.payload.plan.ids.every((i: unknown) => typeof i === 'string')).toBe(true);
229
+ });
230
+
231
+ it('normalizes RecordId values baked inside the plan\'s where tree', async () => {
232
+ const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: true });
233
+ await engine.connect('anon');
234
+ const plan: QueryPlan = {
235
+ table: 'thread',
236
+ where: [{ field: 'author', op: '=', value: new RecordId('user', 'u1') }],
237
+ relations: [
238
+ {
239
+ alias: 'comments',
240
+ table: 'comment',
241
+ cardinality: 'many',
242
+ foreignKeyField: 'thread',
243
+ where: [{ field: 'author', op: '=', value: new RecordId('user', 'u1') }],
244
+ },
245
+ ],
246
+ };
247
+ await engine.select(plan, {});
248
+ const sel = calls.find((c) => c.type === 'select')!;
249
+ // Both the top-level where and the relation sub-where cross the boundary
250
+ // as strings — a RecordId instance would structured-clone to `{}`.
251
+ expect(sel.payload.plan.where[0].value).toBe('user:u1');
252
+ expect(sel.payload.plan.relations[0].where[0].value).toBe('user:u1');
253
+ });
254
+
255
+ it('folds the worker-reported relationFetches into __sqliteStats', async () => {
256
+ const { engine } = makeEngine({ workerSelect: true, answerSelect: true });
257
+ await engine.connect('anon');
258
+ const before = (globalThis as any).__sqliteStats?.relationFetches ?? 0;
259
+ await engine.select(DETAIL_PLAN, { id: 'thread:B' });
260
+ // DETAIL_PLAN has one relation level → executeSelect reports 1 fetch.
261
+ expect((globalThis as any).__sqliteStats.relationFetches).toBe(before + 1);
262
+ });
263
+
264
+ it('degrades to the legacy path when the worker lacks the select op', async () => {
265
+ const { engine, calls } = makeEngine({ workerSelect: true, answerSelect: false });
266
+ await engine.connect('anon');
267
+ const rows = await engine.select(DETAIL_PLAN, { id: 'thread:B' });
268
+ expect(rows).toHaveLength(1);
269
+ expect(rows[0].id).toBe('thread:B');
270
+ // First attempt was 'select'; the rejection flipped the flag and the
271
+ // legacy path completed with exec/run hops.
272
+ expect(calls.some((c) => c.type === 'select')).toBe(true);
273
+ expect(calls.some((c) => c.type === 'exec')).toBe(true);
274
+ // The flag stays off: a second select never retries the worker op.
275
+ const before = calls.filter((c) => c.type === 'select').length;
276
+ await engine.select(DETAIL_PLAN, { id: 'thread:A' });
277
+ expect(calls.filter((c) => c.type === 'select').length).toBe(before);
278
+ });
279
+ });