@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.
- package/dist/index.js +115 -190
- 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 +3 -0
- package/src/services/database/sqlite-cache-engine.ts +163 -95
- 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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { QueryPlan } from '@spooky-sync/query-builder';
|
|
2
|
+
import { resolveRelations, stableKey } from './relation-resolver';
|
|
3
|
+
import { renderOrderSql, renderWhereSql, reviveRow, project } from './sqlite-plan-sql';
|
|
4
|
+
import type { OrderBy, RelationFetch, Row, RowFetcher } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worker-side execution of a whole {@link QueryPlan} — table creation, base
|
|
8
|
+
* select (either the `ids` window or where/order/limit), projection, and the
|
|
9
|
+
* full `.related()` tree via the SHARED `resolveRelations` — against an
|
|
10
|
+
* injected DB handle. Runs inside `sqlite-worker.ts` so the engine pays ONE
|
|
11
|
+
* postMessage round-trip per select instead of one per table/relation level;
|
|
12
|
+
* extracted into its own module so the logic is unit-testable off-worker
|
|
13
|
+
* (parity with the engine's legacy multi-hop path).
|
|
14
|
+
*
|
|
15
|
+
* Semantics mirror `SqliteCacheEngine.selectLegacy` exactly: same SQL, same
|
|
16
|
+
* ordering rules, same projection, same resolver.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The slice of the worker's DB surface `executeSelect` needs. */
|
|
20
|
+
export interface SelectDb {
|
|
21
|
+
/** Run a row-returning statement; rows come back as `{ data: <json> }`. */
|
|
22
|
+
exec(sql: string, bind?: unknown[]): { data: string }[];
|
|
23
|
+
/** Run a statement for effect only (CREATE TABLE). */
|
|
24
|
+
run(sql: string, bind?: unknown[]): void;
|
|
25
|
+
/** Tables already CREATEd on this handle (caller owns the lifecycle). */
|
|
26
|
+
knownTables: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureTable(db: SelectDb, table: string): void {
|
|
30
|
+
if (db.knownTables.has(table)) return;
|
|
31
|
+
db.run(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`);
|
|
32
|
+
db.knownTables.add(table);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function execRows(db: SelectDb, sql: string, bind: unknown[]): Row[] {
|
|
36
|
+
return db.exec(sql, bind).map((r) => reviveRow(r.data));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mirrors the engine's `selectByIds`: fetch by primary id, preserving `ids`
|
|
40
|
+
* order unless an ORDER BY overrides it. */
|
|
41
|
+
function selectByIds(
|
|
42
|
+
db: SelectDb,
|
|
43
|
+
table: string,
|
|
44
|
+
ids: unknown[],
|
|
45
|
+
opts?: { select?: string[]; orderBy?: OrderBy }
|
|
46
|
+
): Row[] {
|
|
47
|
+
if (ids.length === 0) return [];
|
|
48
|
+
ensureTable(db, table);
|
|
49
|
+
const keys = ids.map(stableKey);
|
|
50
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
51
|
+
let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
|
|
52
|
+
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
53
|
+
let rows = execRows(db, sql, keys);
|
|
54
|
+
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
55
|
+
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
56
|
+
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
57
|
+
}
|
|
58
|
+
return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Mirrors the engine's `fetchRelation` SQL exactly. */
|
|
62
|
+
function fetchRelation(db: SelectDb, req: RelationFetch): Row[] {
|
|
63
|
+
ensureTable(db, req.table);
|
|
64
|
+
const keys = req.keys.map(stableKey);
|
|
65
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
66
|
+
const bind: unknown[] = [...keys];
|
|
67
|
+
const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
|
|
68
|
+
let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
|
|
69
|
+
if (req.where && req.where.length > 0) {
|
|
70
|
+
sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
|
|
71
|
+
}
|
|
72
|
+
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
|
|
73
|
+
const rows = execRows(db, sql, bind);
|
|
74
|
+
return req.select ? rows.map((r) => project(r, req.select!)) : rows;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function executeSelect(
|
|
78
|
+
plan: QueryPlan,
|
|
79
|
+
params: Record<string, unknown>,
|
|
80
|
+
db: SelectDb
|
|
81
|
+
): Promise<{ rows: Row[]; relationFetches: number }> {
|
|
82
|
+
// Per-call fetch counter (the engine folds it into `__sqliteStats`).
|
|
83
|
+
const counter = { n: 0 };
|
|
84
|
+
const fetcher: RowFetcher = {
|
|
85
|
+
fetchRelation: (req) => {
|
|
86
|
+
counter.n++;
|
|
87
|
+
return Promise.resolve(fetchRelation(db, req));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
// Window materialization: base rows are exactly `plan.ids`, ordered.
|
|
91
|
+
if (plan.ids) {
|
|
92
|
+
const rows = selectByIds(db, plan.table, plan.ids, {
|
|
93
|
+
select: plan.select,
|
|
94
|
+
orderBy: plan.orderBy,
|
|
95
|
+
});
|
|
96
|
+
await resolveRelations(rows, plan.relations, fetcher);
|
|
97
|
+
return { rows, relationFetches: counter.n };
|
|
98
|
+
}
|
|
99
|
+
ensureTable(db, plan.table);
|
|
100
|
+
const bind: unknown[] = [];
|
|
101
|
+
let sql = `SELECT data FROM "${plan.table}"`;
|
|
102
|
+
if (plan.where && plan.where.length > 0) {
|
|
103
|
+
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
104
|
+
}
|
|
105
|
+
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
106
|
+
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
107
|
+
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
108
|
+
const rows = execRows(db, sql, bind);
|
|
109
|
+
// Optional projection trimming to match `SELECT <fields>`.
|
|
110
|
+
const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
|
|
111
|
+
await resolveRelations(projected, plan.relations, fetcher);
|
|
112
|
+
return { rows: projected, relationFetches: counter.n };
|
|
113
|
+
}
|
|
@@ -9,13 +9,20 @@
|
|
|
9
9
|
* to an in-memory DB when OPFS is unavailable.
|
|
10
10
|
*
|
|
11
11
|
* Message protocol (request/response keyed by `id`):
|
|
12
|
-
* { id, type: 'open',
|
|
13
|
-
* { id, type: 'exec',
|
|
14
|
-
* { id, type: 'run',
|
|
15
|
-
* { id, type: 'batch',
|
|
12
|
+
* { id, type: 'open', payload: { dbName, useOpfs } }
|
|
13
|
+
* { id, type: 'exec', payload: { sql, bind } } -> { id, ok, rows }
|
|
14
|
+
* { id, type: 'run', payload: { sql, bind } } -> { id, ok }
|
|
15
|
+
* { id, type: 'batch', payload: [{ sql, bind }] } (atomic BEGIN/COMMIT)
|
|
16
|
+
* { id, type: 'select', payload: { plan, params } } -> { id, ok, rows, relationFetches }
|
|
16
17
|
* { id, type: 'close' }
|
|
18
|
+
*
|
|
19
|
+
* `select` executes a whole QueryPlan — table creation, base select, the full
|
|
20
|
+
* `.related()` tree (shared `resolveRelations`), and JSON row parsing — in ONE
|
|
21
|
+
* round-trip, returning structured-clone row objects. This is the first-load
|
|
22
|
+
* hot path; the per-statement ops remain for the write/shim paths.
|
|
17
23
|
*/
|
|
18
24
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
|
25
|
+
import { executeSelect, type SelectDb } from './sqlite-select';
|
|
19
26
|
|
|
20
27
|
interface Stmt {
|
|
21
28
|
sql: string;
|
|
@@ -78,14 +85,32 @@ function batch(stmts: Stmt[]): void {
|
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
|
|
88
|
+
// ==================== worker-side plan execution ('select') ====================
|
|
89
|
+
|
|
90
|
+
/** DB handle for `executeSelect` (see `sqlite-select.ts`, the unit-testable
|
|
91
|
+
* plan executor). `knownTables` is cleared on open/close — a bucket switch
|
|
92
|
+
* spawns a fresh worker anyway. */
|
|
93
|
+
const selectDb: SelectDb = {
|
|
94
|
+
exec: (sql, bind) => exec(sql, bind) as { data: string }[],
|
|
95
|
+
run,
|
|
96
|
+
knownTables: new Set<string>(),
|
|
97
|
+
};
|
|
98
|
+
|
|
81
99
|
self.onmessage = async (ev: MessageEvent) => {
|
|
82
100
|
const { id, type, payload } = ev.data ?? {};
|
|
101
|
+
// `wt` (worker time) lets the main thread split a round-trip into actual DB
|
|
102
|
+
// work vs postMessage/queue overhead (see `__sqliteStats`).
|
|
103
|
+
const t0 = performance.now();
|
|
83
104
|
try {
|
|
84
105
|
let result: unknown;
|
|
85
106
|
switch (type) {
|
|
86
107
|
case 'open':
|
|
108
|
+
selectDb.knownTables.clear();
|
|
87
109
|
result = await open(payload.dbName, payload.useOpfs);
|
|
88
110
|
break;
|
|
111
|
+
case 'select':
|
|
112
|
+
result = await executeSelect(payload.plan, payload.params ?? {}, selectDb);
|
|
113
|
+
break;
|
|
89
114
|
case 'exec':
|
|
90
115
|
result = { rows: exec(payload.sql, payload.bind) };
|
|
91
116
|
break;
|
|
@@ -100,12 +125,18 @@ self.onmessage = async (ev: MessageEvent) => {
|
|
|
100
125
|
case 'close':
|
|
101
126
|
db?.close();
|
|
102
127
|
db = null;
|
|
128
|
+
selectDb.knownTables.clear();
|
|
103
129
|
result = {};
|
|
104
130
|
break;
|
|
105
131
|
default:
|
|
106
132
|
throw new Error(`sqlite worker: unknown message ${type}`);
|
|
107
133
|
}
|
|
108
|
-
(self as unknown as Worker).postMessage({
|
|
134
|
+
(self as unknown as Worker).postMessage({
|
|
135
|
+
id,
|
|
136
|
+
ok: true,
|
|
137
|
+
wt: performance.now() - t0,
|
|
138
|
+
...(result as object),
|
|
139
|
+
});
|
|
109
140
|
} catch (err) {
|
|
110
141
|
(self as unknown as Worker).postMessage({
|
|
111
142
|
id,
|
package/src/types.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
117
117
|
store?: StoreType;
|
|
118
118
|
/** Authentication token. */
|
|
119
119
|
token?: string;
|
|
120
|
+
/**
|
|
121
|
+
* SQLite engine only: execute `select` plans (base rows + relation tree +
|
|
122
|
+
* row parsing) inside the worker as ONE round-trip instead of one hop per
|
|
123
|
+
* table/relation level. Defaults to true; set false to force the legacy
|
|
124
|
+
* multi-hop path (escape hatch while the worker-side path beds in).
|
|
125
|
+
*/
|
|
126
|
+
workerSelect?: boolean;
|
|
120
127
|
};
|
|
121
128
|
/** The schema definition. */
|
|
122
129
|
schema: S;
|