@spooky-sync/core 0.0.1-canary.132 → 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.
- package/dist/index.js +142 -200
- package/dist/sqlite-plan-sql.js +194 -0
- package/dist/sqlite-worker.js +97 -4
- package/dist/types.d.ts +7 -0
- package/package.json +3 -3
- package/src/services/database/plan-render.test.ts +26 -0
- package/src/services/database/plan-render.ts +9 -1
- package/src/services/database/sqlite-cache-engine.test.ts +92 -1
- package/src/services/database/sqlite-cache-engine.ts +204 -106
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +279 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-worker.ts +36 -5
- package/src/types.ts +7 -0
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
|
+
import { i as reviveRow, n as renderOrderSql, o as resolveRelations, r as renderWhereSql, s as stableKey, t as project } from "./sqlite-plan-sql.js";
|
|
1
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
2
3
|
|
|
4
|
+
//#region src/services/database/sqlite-select.ts
|
|
5
|
+
function ensureTable(db, table) {
|
|
6
|
+
if (db.knownTables.has(table)) return;
|
|
7
|
+
db.run(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`);
|
|
8
|
+
db.knownTables.add(table);
|
|
9
|
+
}
|
|
10
|
+
function execRows(db, sql, bind) {
|
|
11
|
+
return db.exec(sql, bind).map((r) => reviveRow(r.data));
|
|
12
|
+
}
|
|
13
|
+
/** Mirrors the engine's `selectByIds`: fetch by primary id, preserving `ids`
|
|
14
|
+
* order unless an ORDER BY overrides it. */
|
|
15
|
+
function selectByIds(db, table, ids, opts) {
|
|
16
|
+
if (ids.length === 0) return [];
|
|
17
|
+
ensureTable(db, table);
|
|
18
|
+
const keys = ids.map(stableKey);
|
|
19
|
+
let sql = `SELECT data FROM "${table}" WHERE id IN (${keys.map(() => "?").join(", ")})`;
|
|
20
|
+
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
21
|
+
let rows = execRows(db, sql, keys);
|
|
22
|
+
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
23
|
+
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
24
|
+
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
25
|
+
}
|
|
26
|
+
return opts?.select ? rows.map((r) => project(r, opts.select)) : rows;
|
|
27
|
+
}
|
|
28
|
+
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
29
|
+
function fetchRelation(db, req) {
|
|
30
|
+
ensureTable(db, req.table);
|
|
31
|
+
const keys = req.keys.map(stableKey);
|
|
32
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
33
|
+
const bind = [...keys];
|
|
34
|
+
const lhs = req.matchField === "id" ? "id" : `json_extract(data, '$.${req.matchField}')`;
|
|
35
|
+
let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
|
|
36
|
+
if (req.where && req.where.length > 0) sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
|
|
37
|
+
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
|
|
38
|
+
const rows = execRows(db, sql, bind);
|
|
39
|
+
return req.select ? rows.map((r) => project(r, req.select)) : rows;
|
|
40
|
+
}
|
|
41
|
+
async function executeSelect(plan, params, db) {
|
|
42
|
+
const counter = { n: 0 };
|
|
43
|
+
const fetcher = { fetchRelation: (req) => {
|
|
44
|
+
counter.n++;
|
|
45
|
+
return Promise.resolve(fetchRelation(db, req));
|
|
46
|
+
} };
|
|
47
|
+
if (plan.ids) {
|
|
48
|
+
const rows = selectByIds(db, plan.table, plan.ids, {
|
|
49
|
+
select: plan.select,
|
|
50
|
+
orderBy: plan.orderBy
|
|
51
|
+
});
|
|
52
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
53
|
+
return {
|
|
54
|
+
rows,
|
|
55
|
+
relationFetches: counter.n
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
ensureTable(db, plan.table);
|
|
59
|
+
const bind = [];
|
|
60
|
+
let sql = `SELECT data FROM "${plan.table}"`;
|
|
61
|
+
if (plan.where && plan.where.length > 0) sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
62
|
+
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
63
|
+
if (plan.limit !== void 0) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
64
|
+
if (plan.offset !== void 0) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
65
|
+
const rows = execRows(db, sql, bind);
|
|
66
|
+
const projected = plan.select ? rows.map((r) => project(r, plan.select)) : rows;
|
|
67
|
+
await resolveRelations(projected, plan.relations, fetcher);
|
|
68
|
+
return {
|
|
69
|
+
rows: projected,
|
|
70
|
+
relationFetches: counter.n
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
3
75
|
//#region src/services/database/sqlite-worker.ts
|
|
4
76
|
/**
|
|
5
77
|
* Dedicated Web Worker that owns the SQLite-WASM handle. All DB access is
|
|
@@ -11,11 +83,17 @@ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
|
11
83
|
* to an in-memory DB when OPFS is unavailable.
|
|
12
84
|
*
|
|
13
85
|
* Message protocol (request/response keyed by `id`):
|
|
14
|
-
* { id, type: 'open',
|
|
15
|
-
* { id, type: 'exec',
|
|
16
|
-
* { id, type: 'run',
|
|
17
|
-
* { id, type: 'batch',
|
|
86
|
+
* { id, type: 'open', payload: { dbName, useOpfs } }
|
|
87
|
+
* { id, type: 'exec', payload: { sql, bind } } -> { id, ok, rows }
|
|
88
|
+
* { id, type: 'run', payload: { sql, bind } } -> { id, ok }
|
|
89
|
+
* { id, type: 'batch', payload: [{ sql, bind }] } (atomic BEGIN/COMMIT)
|
|
90
|
+
* { id, type: 'select', payload: { plan, params } } -> { id, ok, rows, relationFetches }
|
|
18
91
|
* { id, type: 'close' }
|
|
92
|
+
*
|
|
93
|
+
* `select` executes a whole QueryPlan — table creation, base select, the full
|
|
94
|
+
* `.related()` tree (shared `resolveRelations`), and JSON row parsing — in ONE
|
|
95
|
+
* round-trip, returning structured-clone row objects. This is the first-load
|
|
96
|
+
* hot path; the per-statement ops remain for the write/shim paths.
|
|
19
97
|
*/
|
|
20
98
|
let db = null;
|
|
21
99
|
async function open(dbName, useOpfs) {
|
|
@@ -63,14 +141,27 @@ function batch(stmts) {
|
|
|
63
141
|
throw e;
|
|
64
142
|
}
|
|
65
143
|
}
|
|
144
|
+
/** DB handle for `executeSelect` (see `sqlite-select.ts`, the unit-testable
|
|
145
|
+
* plan executor). `knownTables` is cleared on open/close — a bucket switch
|
|
146
|
+
* spawns a fresh worker anyway. */
|
|
147
|
+
const selectDb = {
|
|
148
|
+
exec: (sql, bind) => exec(sql, bind),
|
|
149
|
+
run,
|
|
150
|
+
knownTables: /* @__PURE__ */ new Set()
|
|
151
|
+
};
|
|
66
152
|
self.onmessage = async (ev) => {
|
|
67
153
|
const { id, type, payload } = ev.data ?? {};
|
|
154
|
+
const t0 = performance.now();
|
|
68
155
|
try {
|
|
69
156
|
let result;
|
|
70
157
|
switch (type) {
|
|
71
158
|
case "open":
|
|
159
|
+
selectDb.knownTables.clear();
|
|
72
160
|
result = await open(payload.dbName, payload.useOpfs);
|
|
73
161
|
break;
|
|
162
|
+
case "select":
|
|
163
|
+
result = await executeSelect(payload.plan, payload.params ?? {}, selectDb);
|
|
164
|
+
break;
|
|
74
165
|
case "exec":
|
|
75
166
|
result = { rows: exec(payload.sql, payload.bind) };
|
|
76
167
|
break;
|
|
@@ -85,6 +176,7 @@ self.onmessage = async (ev) => {
|
|
|
85
176
|
case "close":
|
|
86
177
|
db?.close();
|
|
87
178
|
db = null;
|
|
179
|
+
selectDb.knownTables.clear();
|
|
88
180
|
result = {};
|
|
89
181
|
break;
|
|
90
182
|
default: throw new Error(`sqlite worker: unknown message ${type}`);
|
|
@@ -92,6 +184,7 @@ self.onmessage = async (ev) => {
|
|
|
92
184
|
self.postMessage({
|
|
93
185
|
id,
|
|
94
186
|
ok: true,
|
|
187
|
+
wt: performance.now() - t0,
|
|
95
188
|
...result
|
|
96
189
|
});
|
|
97
190
|
} catch (err) {
|
package/dist/types.d.ts
CHANGED
|
@@ -404,6 +404,13 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
404
404
|
store?: StoreType;
|
|
405
405
|
/** Authentication token. */
|
|
406
406
|
token?: string;
|
|
407
|
+
/**
|
|
408
|
+
* SQLite engine only: execute `select` plans (base rows + relation tree +
|
|
409
|
+
* row parsing) inside the worker as ONE round-trip instead of one hop per
|
|
410
|
+
* table/relation level. Defaults to true; set false to force the legacy
|
|
411
|
+
* multi-hop path (escape hatch while the worker-side path beds in).
|
|
412
|
+
*/
|
|
413
|
+
workerSelect?: boolean;
|
|
407
414
|
};
|
|
408
415
|
/** The schema definition. */
|
|
409
416
|
schema: S;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.134",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
62
|
+
"@spooky-sync/query-builder": "0.0.1-canary.134",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.134",
|
|
64
64
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
65
65
|
"@surrealdb/wasm": "^3.0.3",
|
|
66
66
|
"fast-json-patch": "^3.1.1",
|
|
@@ -73,6 +73,32 @@ describe('record-id coercion (authors/comments loading regression)', () => {
|
|
|
73
73
|
expect(sql).toBe('SELECT * FROM thread WHERE title = $__p0 AND published = $__p1;');
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
// Regression guard for the thread-detail "crossed results → 404" bug: a
|
|
77
|
+
// slave-mode node carries BOTH a baked `value` and a `paramRef` (= the field
|
|
78
|
+
// name). Materialization MUST bind `params[paramRef]` (the query's identity),
|
|
79
|
+
// NOT the baked value — otherwise a stale/other plan whose baked id ≠ the
|
|
80
|
+
// query's params would surface a different record's row. So rendering a plan
|
|
81
|
+
// baked to id-A with params for id-B must filter by B.
|
|
82
|
+
it('slave-mode node binds params over the baked value (records follow identity)', () => {
|
|
83
|
+
const planBakedToA: QueryPlan = {
|
|
84
|
+
table: 'thread',
|
|
85
|
+
where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
|
|
86
|
+
};
|
|
87
|
+
const { sql, vars } = renderBaseSelectSurql(planBakedToA, { id: 'thread:B' });
|
|
88
|
+
expect(sql).toBe('SELECT * FROM thread WHERE id = type::record(<string> $id);');
|
|
89
|
+
expect(vars).toEqual({ id: 'thread:B' }); // B wins — the baked A is ignored
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('slave-mode node falls back to the baked value when the param is absent', () => {
|
|
93
|
+
const plan: QueryPlan = {
|
|
94
|
+
table: 'thread',
|
|
95
|
+
where: [{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }],
|
|
96
|
+
};
|
|
97
|
+
const { sql, vars } = renderBaseSelectSurql(plan, {}); // no params.id
|
|
98
|
+
expect(sql).toBe('SELECT * FROM thread WHERE id = type::record(<string> $__p0);');
|
|
99
|
+
expect(vars).toEqual({ __p0: 'thread:A' });
|
|
100
|
+
});
|
|
101
|
+
|
|
76
102
|
it('coerces `id` inside an OR group too', () => {
|
|
77
103
|
const plan: QueryPlan = {
|
|
78
104
|
table: 'thread',
|
|
@@ -26,7 +26,15 @@ function bind(ctx: RenderCtx, value: unknown): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function renderComparisonSurql(c: WhereComparison, ctx: RenderCtx): string {
|
|
29
|
-
|
|
29
|
+
// Prefer the query's own param (`paramRef`) so a filter materializes from
|
|
30
|
+
// `params` (the query's identity), not a baked literal — this is what slaves
|
|
31
|
+
// a query's rows to its id. A pure `$`-ref node has no `value`, so it always
|
|
32
|
+
// uses the param. A slave-mode node also carries `value` as a fallback for
|
|
33
|
+
// when the param is absent (e.g. a non-column field stripped by parseParams).
|
|
34
|
+
const useParam =
|
|
35
|
+
c.paramRef !== undefined &&
|
|
36
|
+
(c.value === undefined || Object.prototype.hasOwnProperty.call(ctx.vars, c.paramRef));
|
|
37
|
+
const rawRight = useParam ? `$${c.paramRef}` : bind(ctx, c.value);
|
|
30
38
|
// `id` is a RecordId, but correlation/filter values arrive as record-id
|
|
31
39
|
// STRINGS (e.g. "thread:abc"). On SurrealDB `id = "thread:abc"` never matches
|
|
32
40
|
// (string ≠ record), so a base select filtered by id (e.g. the ThreadDetail
|
|
@@ -1,10 +1,101 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { RecordId } from 'surrealdb';
|
|
3
|
-
import { pureWriteOpResult } from './sqlite-cache-engine';
|
|
3
|
+
import { pureWriteOpResult, SqliteCacheEngine } from './sqlite-cache-engine';
|
|
4
4
|
import { translateSurql } from './surql-translate';
|
|
5
5
|
import type { SqlOp } from './surql-translate';
|
|
6
6
|
import { surql } from '../../utils/surql';
|
|
7
7
|
|
|
8
|
+
function makeLogger(): any {
|
|
9
|
+
const noop = () => {};
|
|
10
|
+
const l: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
|
|
11
|
+
l.child = () => l;
|
|
12
|
+
return l;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A fake Worker that models the real SQLite worker's open/closed lifecycle:
|
|
17
|
+
* `open` opens the DB, `close` closes it, and `exec`/`run` REJECT with
|
|
18
|
+
* "sqlite: DB not open" when the DB isn't currently open — exactly the failure
|
|
19
|
+
* the fix targets. Records every message `type` (across worker generations) so
|
|
20
|
+
* tests can also inspect dispatch order.
|
|
21
|
+
*/
|
|
22
|
+
class FakeWorker {
|
|
23
|
+
onmessage: ((ev: any) => void) | null = null;
|
|
24
|
+
onerror: any = null;
|
|
25
|
+
onmessageerror: any = null;
|
|
26
|
+
private dbOpen = false;
|
|
27
|
+
constructor(private log: string[]) {}
|
|
28
|
+
postMessage(msg: any) {
|
|
29
|
+
this.log.push(msg.type);
|
|
30
|
+
Promise.resolve().then(() => {
|
|
31
|
+
let ok = true;
|
|
32
|
+
let error: string | undefined;
|
|
33
|
+
let rest: Record<string, unknown> = {};
|
|
34
|
+
if (msg.type === 'open') {
|
|
35
|
+
this.dbOpen = true;
|
|
36
|
+
rest = { persisted: true };
|
|
37
|
+
} else if (msg.type === 'close') {
|
|
38
|
+
this.dbOpen = false;
|
|
39
|
+
} else if (msg.type === 'exec') {
|
|
40
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
41
|
+
else rest = { rows: [] };
|
|
42
|
+
} else if (msg.type === 'select') {
|
|
43
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
44
|
+
else rest = { rows: [], relationFetches: 0 };
|
|
45
|
+
} else if (msg.type === 'run' || msg.type === 'batch') {
|
|
46
|
+
if (!this.dbOpen) { ok = false; error = 'sqlite: DB not open'; }
|
|
47
|
+
}
|
|
48
|
+
this.onmessage?.({ data: { id: msg.id, ok, error, ...rest } });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
terminate() {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Regression: switchBucket must run close → reopen as a single serialized
|
|
55
|
+
// opQueue entry. Otherwise a read/write enqueued during an auth/bucket change
|
|
56
|
+
// dispatches to the just-closed worker → "sqlite: DB not open" (the crash on
|
|
57
|
+
// sign-in). The invariant: no `exec` may appear between a `close` and the next
|
|
58
|
+
// `open` in the message stream.
|
|
59
|
+
describe('SqliteCacheEngine.switchBucket serialization', () => {
|
|
60
|
+
it('never dispatches an op to a closed DB during a bucket switch', async () => {
|
|
61
|
+
const log: string[] = [];
|
|
62
|
+
const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, makeLogger());
|
|
63
|
+
// Replicate the real spawnWorker's onmessage wiring (resolve/reject pending).
|
|
64
|
+
(engine as any).spawnWorker = () => {
|
|
65
|
+
const w = new FakeWorker(log);
|
|
66
|
+
w.onmessage = (ev: any) => {
|
|
67
|
+
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
68
|
+
const p = (engine as any).pending.get(id);
|
|
69
|
+
if (!p) return;
|
|
70
|
+
(engine as any).pending.delete(id);
|
|
71
|
+
if (ok) p.resolve(rest);
|
|
72
|
+
else p.reject(new Error(error));
|
|
73
|
+
};
|
|
74
|
+
return w as unknown as Worker;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await engine.connect('anon');
|
|
78
|
+
expect(log).toContain('open');
|
|
79
|
+
|
|
80
|
+
// Fire a switch and a concurrent read (exactly what the sign-in query
|
|
81
|
+
// re-registration does). With the old non-atomic switch the read's `exec`
|
|
82
|
+
// dispatched to the closed/terminated worker and REJECTED ("sqlite: DB not
|
|
83
|
+
// open" / "not connected") — the uncaught crash. The atomic switch makes the
|
|
84
|
+
// read wait for the reopen and resolve against the new bucket.
|
|
85
|
+
const switching = engine.switchBucket('user:abc');
|
|
86
|
+
const reading = engine.getById('_00_query', 'h1');
|
|
87
|
+
await expect(Promise.all([switching, reading])).resolves.toBeDefined();
|
|
88
|
+
await expect(reading).resolves.toBeNull(); // missing row → null, not a throw
|
|
89
|
+
|
|
90
|
+
// And the close→reopen ran with no op wedged between them.
|
|
91
|
+
const closeIdx = log.indexOf('close');
|
|
92
|
+
const openAfter = log.indexOf('open', closeIdx + 1);
|
|
93
|
+
expect(openAfter).toBeGreaterThan(closeIdx);
|
|
94
|
+
expect(log.slice(closeIdx + 1, openAfter)).not.toContain('exec');
|
|
95
|
+
expect(engine.currentBucketId).toBe('user:abc');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
8
99
|
// `pureWriteOpResult` is the single source of truth for what a pure-write op
|
|
9
100
|
// contributes to a query's per-statement results. The batched fast path in
|
|
10
101
|
// `query()` and the per-op `execOp` path BOTH route through it, so a caller that
|