@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,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', 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)
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({ id, ok: true, ...(result as object) });
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;