@spooky-sync/core 0.0.1-canary.15 → 0.0.1-canary.151

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.
Files changed (101) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1192 -372
  3. package/dist/index.js +5772 -1270
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-plan-sql.js +194 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +202 -0
  9. package/dist/types.d.ts +755 -0
  10. package/package.json +40 -8
  11. package/skills/sp00ky-core/SKILL.md +258 -0
  12. package/skills/sp00ky-core/references/auth.md +98 -0
  13. package/skills/sp00ky-core/references/config.md +76 -0
  14. package/src/build-globals.d.ts +12 -0
  15. package/src/events/events.test.ts +2 -1
  16. package/src/events/index.ts +3 -0
  17. package/src/index.ts +16 -2
  18. package/src/modules/app-release/index.test.ts +125 -0
  19. package/src/modules/app-release/index.ts +201 -0
  20. package/src/modules/auth/events/index.ts +2 -1
  21. package/src/modules/auth/index.ts +59 -20
  22. package/src/modules/cache/index.ts +77 -32
  23. package/src/modules/cache/types.ts +2 -2
  24. package/src/modules/crdt/crdt-field.ts +294 -0
  25. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  26. package/src/modules/crdt/index.ts +361 -0
  27. package/src/modules/crdt/loro-loader.ts +25 -0
  28. package/src/modules/data/data.hydration.test.ts +142 -0
  29. package/src/modules/data/data.rebind.test.ts +147 -0
  30. package/src/modules/data/data.run.test.ts +113 -0
  31. package/src/modules/data/data.status.test.ts +249 -0
  32. package/src/modules/data/index.ts +1134 -128
  33. package/src/modules/data/window-query.test.ts +52 -0
  34. package/src/modules/data/window-query.ts +154 -0
  35. package/src/modules/devtools/index.ts +191 -30
  36. package/src/modules/devtools/versions.test.ts +74 -0
  37. package/src/modules/devtools/versions.ts +81 -0
  38. package/src/modules/feature-flag/index.test.ts +120 -0
  39. package/src/modules/feature-flag/index.ts +209 -0
  40. package/src/modules/ref-tables.test.ts +91 -0
  41. package/src/modules/ref-tables.ts +88 -0
  42. package/src/modules/sync/engine.ts +101 -37
  43. package/src/modules/sync/events/index.ts +9 -2
  44. package/src/modules/sync/queue/queue-down.ts +12 -5
  45. package/src/modules/sync/queue/queue-up.ts +29 -14
  46. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  47. package/src/modules/sync/scheduler.ts +73 -7
  48. package/src/modules/sync/sync.health.test.ts +149 -0
  49. package/src/modules/sync/sync.subquery.test.ts +82 -0
  50. package/src/modules/sync/sync.ts +1017 -62
  51. package/src/modules/sync/utils.test.ts +269 -2
  52. package/src/modules/sync/utils.ts +182 -17
  53. package/src/otel/index.ts +127 -0
  54. package/src/services/database/cache-engine.ts +143 -0
  55. package/src/services/database/database.ts +11 -11
  56. package/src/services/database/engine-factory.ts +32 -0
  57. package/src/services/database/events/index.ts +2 -1
  58. package/src/services/database/index.ts +6 -0
  59. package/src/services/database/local-migrator.ts +28 -27
  60. package/src/services/database/local.test.ts +64 -0
  61. package/src/services/database/local.ts +478 -67
  62. package/src/services/database/plan-render.test.ts +159 -0
  63. package/src/services/database/plan-render.ts +108 -0
  64. package/src/services/database/relation-resolver.test.ts +413 -0
  65. package/src/services/database/relation-resolver.ts +0 -0
  66. package/src/services/database/remote.ts +13 -13
  67. package/src/services/database/sqlite-cache-engine.test.ts +215 -0
  68. package/src/services/database/sqlite-cache-engine.ts +829 -0
  69. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  70. package/src/services/database/sqlite-plan-sql.ts +106 -0
  71. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  72. package/src/services/database/sqlite-select.test.ts +279 -0
  73. package/src/services/database/sqlite-select.ts +113 -0
  74. package/src/services/database/sqlite-worker.ts +160 -0
  75. package/src/services/database/surql-translate.ts +291 -0
  76. package/src/services/database/surreal-cache-engine.ts +139 -0
  77. package/src/services/logger/index.ts +6 -101
  78. package/src/services/persistence/localstorage.ts +2 -2
  79. package/src/services/persistence/resilient.ts +11 -4
  80. package/src/services/persistence/surrealdb.ts +10 -10
  81. package/src/services/stream-processor/index.ts +295 -38
  82. package/src/services/stream-processor/permissions.test.ts +47 -0
  83. package/src/services/stream-processor/permissions.ts +53 -0
  84. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  85. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  86. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  87. package/src/services/stream-processor/wasm-types.ts +18 -2
  88. package/src/sp00ky.auth-order.test.ts +92 -0
  89. package/src/sp00ky.init-query.test.ts +183 -0
  90. package/src/sp00ky.ts +1000 -0
  91. package/src/types.ts +267 -15
  92. package/src/utils/error-classification.test.ts +44 -0
  93. package/src/utils/error-classification.ts +7 -0
  94. package/src/utils/index.ts +35 -13
  95. package/src/utils/parser.ts +3 -2
  96. package/src/utils/semver.test.ts +32 -0
  97. package/src/utils/semver.ts +30 -0
  98. package/src/utils/surql.ts +24 -15
  99. package/src/utils/withRetry.test.ts +1 -1
  100. package/tsdown.config.ts +77 -1
  101. package/src/spooky.ts +0 -395
@@ -0,0 +1,21 @@
1
+ import { u as PinoTransmit } from "../types.js";
2
+ import { Level } from "pino";
3
+
4
+ //#region src/otel/index.d.ts
5
+
6
+ /**
7
+ * Creates a pino browser transmit object that forwards logs to an OpenTelemetry collector.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { createOtelTransmit } from '@spooky-sync/core/otel';
12
+ *
13
+ * new Sp00kyClient({
14
+ * // ...
15
+ * otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
16
+ * });
17
+ * ```
18
+ */
19
+ declare function createOtelTransmit(endpoint: string, level?: Level): PinoTransmit;
20
+ //#endregion
21
+ export { createOtelTransmit };
@@ -0,0 +1,86 @@
1
+ //#region src/otel/index.ts
2
+ function mapLevelToSeverityNumber(level) {
3
+ switch (level) {
4
+ case "trace": return 1;
5
+ case "debug": return 5;
6
+ case "info": return 9;
7
+ case "warn": return 13;
8
+ case "error": return 17;
9
+ case "fatal": return 21;
10
+ default: return 9;
11
+ }
12
+ }
13
+ async function loadOtelModules(otelEndpoint) {
14
+ const [{ LoggerProvider, BatchLogRecordProcessor }, { OTLPLogExporter }, { resourceFromAttributes }, { ATTR_SERVICE_NAME }] = await Promise.all([
15
+ import("@opentelemetry/sdk-logs"),
16
+ import("@opentelemetry/exporter-logs-otlp-proto"),
17
+ import("@opentelemetry/resources"),
18
+ import("@opentelemetry/semantic-conventions")
19
+ ]);
20
+ const loggerProvider = new LoggerProvider({
21
+ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "sp00ky-client" }),
22
+ processors: [new BatchLogRecordProcessor(new OTLPLogExporter({ url: otelEndpoint }))]
23
+ });
24
+ const otelLoggerCache = {};
25
+ return (category) => {
26
+ if (!otelLoggerCache[category]) otelLoggerCache[category] = loggerProvider.getLogger(category);
27
+ return otelLoggerCache[category];
28
+ };
29
+ }
30
+ /**
31
+ * Creates a pino browser transmit object that forwards logs to an OpenTelemetry collector.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { createOtelTransmit } from '@spooky-sync/core/otel';
36
+ *
37
+ * new Sp00kyClient({
38
+ * // ...
39
+ * otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
40
+ * });
41
+ * ```
42
+ */
43
+ function createOtelTransmit(endpoint, level = "info") {
44
+ const otelReady = loadOtelModules(endpoint);
45
+ return {
46
+ level,
47
+ send: (levelLabel, logEvent) => {
48
+ otelReady.then((getOtelLogger) => {
49
+ try {
50
+ const messages = [...logEvent.messages];
51
+ const severityNumber = mapLevelToSeverityNumber(levelLabel);
52
+ let body = "";
53
+ const lastMsg = messages.pop();
54
+ if (typeof lastMsg === "string") body = lastMsg;
55
+ else if (lastMsg) body = JSON.stringify(lastMsg);
56
+ let category = "sp00ky-client::unknown";
57
+ const attributes = {};
58
+ for (const msg of messages) if (typeof msg === "object") {
59
+ if (msg.Category) {
60
+ category = msg.Category;
61
+ delete msg.Category;
62
+ }
63
+ Object.assign(attributes, msg);
64
+ }
65
+ getOtelLogger(category).emit({
66
+ severityNumber,
67
+ severityText: levelLabel.toUpperCase(),
68
+ body,
69
+ attributes: {
70
+ ...logEvent.bindings[0],
71
+ ...attributes
72
+ },
73
+ timestamp: new Date(logEvent.ts)
74
+ });
75
+ } catch (e) {
76
+ console.warn("Failed to transmit log to OTEL endpoint", e);
77
+ }
78
+ }).catch((e) => {
79
+ console.warn("Failed to load OpenTelemetry modules", e);
80
+ });
81
+ }
82
+ };
83
+ }
84
+
85
+ //#endregion
86
+ export { createOtelTransmit };
@@ -0,0 +1,194 @@
1
+ //#region src/services/database/cache-engine.ts
2
+ /** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
3
+ * a guard against a cyclic schema producing unbounded fan-out. */
4
+ var RelationCycleError = class extends Error {
5
+ constructor(path) {
6
+ super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(" -> ")}`);
7
+ this.name = "RelationCycleError";
8
+ }
9
+ };
10
+ /** Defensive ceiling on relation nesting depth. A finite plan tree never hits
11
+ * this in practice; it exists so a malformed/cyclic plan fails loudly instead
12
+ * of running away. */
13
+ const MAX_RELATION_DEPTH = 12;
14
+
15
+ //#endregion
16
+ //#region src/services/database/relation-resolver.ts
17
+ /**
18
+ * Resolve the `.related()` tree of a query by **level-ordered, batched
19
+ * fan-out** — the engine-neutral replacement for SurrealQL's nested
20
+ * `(SELECT..) AS alias` projections. One batched fetch per relation per level
21
+ * (never per-row N+1, never a flat join); grouping, per-parent ORDER/LIMIT and
22
+ * attachment happen in JS.
23
+ *
24
+ * Correlation (mirrors `buildSubquery`):
25
+ * - `one` → parent[`foreignKeyField`] = child.id → attach `bucket[0] ?? null`
26
+ * - `many` → child[`foreignKeyField`] = parent.id → attach `bucket`
27
+ *
28
+ * Cross-level dependency is handled by ordering: a child's correlation key is a
29
+ * field on its parent, known only once the parent level has resolved, so each
30
+ * level's batch runs after the previous level's rows exist. `parents` is
31
+ * mutated in place — each resolved alias is appended LAST so key order matches
32
+ * SurrealQL's `SELECT *, <sub> AS alias` (base fields, then alias).
33
+ *
34
+ * @throws {RelationCycleError} if nesting exceeds {@link MAX_RELATION_DEPTH}.
35
+ */
36
+ async function resolveRelations(parents, relations, fetcher, depth = 0, path = []) {
37
+ if (!relations || relations.length === 0 || parents.length === 0) return;
38
+ if (depth >= MAX_RELATION_DEPTH) throw new RelationCycleError([...path, relations.map((r) => r.alias).join("|")]);
39
+ await Promise.all(relations.map((relation) => resolveOne(parents, relation, fetcher, depth, path)));
40
+ }
41
+ async function resolveOne(parents, relation, fetcher, depth, path) {
42
+ const isOne = relation.cardinality === "one";
43
+ const matchField = isOne ? "id" : relation.foreignKeyField;
44
+ const parentKeyOf = (parent) => isOne ? parent[relation.foreignKeyField] : parent["id"];
45
+ const keySet = /* @__PURE__ */ new Map();
46
+ for (const parent of parents) {
47
+ const key = parentKeyOf(parent);
48
+ if (key == null) continue;
49
+ const k = stableKey(key);
50
+ if (!keySet.has(k)) keySet.set(k, key);
51
+ }
52
+ const grouped = /* @__PURE__ */ new Map();
53
+ if (keySet.size > 0) {
54
+ const children = await fetcher.fetchRelation({
55
+ table: relation.table,
56
+ matchField,
57
+ keys: [...keySet.values()],
58
+ where: relation.where,
59
+ orderBy: relation.orderBy,
60
+ select: relation.select
61
+ });
62
+ await resolveRelations(children, relation.relations, fetcher, depth + 1, [...path, relation.alias]);
63
+ for (const child of children) {
64
+ const k = stableKey(child[matchField]);
65
+ const bucket = grouped.get(k);
66
+ if (bucket) bucket.push(child);
67
+ else grouped.set(k, [child]);
68
+ }
69
+ }
70
+ for (const parent of parents) {
71
+ const key = parentKeyOf(parent);
72
+ let bucket = key == null ? [] : grouped.get(stableKey(key)) ?? [];
73
+ if (relation.orderBy && relation.orderBy.length > 0) bucket = sortRows(bucket, relation.orderBy);
74
+ if (relation.limit !== void 0) bucket = bucket.slice(0, relation.limit);
75
+ parent[relation.alias] = isOne ? bucket[0] ?? null : bucket;
76
+ }
77
+ }
78
+ /**
79
+ * Stable grouping key for a correlation value. A `RecordId` (duck-typed to
80
+ * avoid a hard surrealdb dependency here) and its `table:id` string form must
81
+ * collapse to the same key so a parent FK stored either way matches the child.
82
+ */
83
+ function stableKey(value) {
84
+ if (value == null) return "\0null";
85
+ if (typeof value === "string") return value;
86
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
87
+ if (typeof value === "object") {
88
+ const rid = value;
89
+ if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
90
+ if (typeof rid.toString === "function") {
91
+ const s = rid.toString();
92
+ if (s !== "[object Object]") return s;
93
+ }
94
+ return JSON.stringify(value);
95
+ }
96
+ return String(value);
97
+ }
98
+ /**
99
+ * Stable, multi-key sort matching SurrealQL `ORDER BY` semantics closely
100
+ * enough for cache display order: ascending null-last, type-aware compare, and
101
+ * a stable tiebreak (original index) so equal rows keep input order.
102
+ */
103
+ function sortRows(rows, orderBy) {
104
+ const indexed = rows.map((row, i) => [row, i]);
105
+ indexed.sort((a, b) => {
106
+ for (const [field, direction] of orderBy) {
107
+ const cmp = compareValues(a[0][field], b[0][field]);
108
+ if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
109
+ }
110
+ return a[1] - b[1];
111
+ });
112
+ return indexed.map(([row]) => row);
113
+ }
114
+ function compareValues(a, b) {
115
+ if (a == null && b == null) return 0;
116
+ if (a == null) return 1;
117
+ if (b == null) return -1;
118
+ if (typeof a === "number" && typeof b === "number") return a - b;
119
+ const as = stableKey(a);
120
+ const bs = stableKey(b);
121
+ return as < bs ? -1 : as > bs ? 1 : 0;
122
+ }
123
+
124
+ //#endregion
125
+ //#region src/services/database/sqlite-plan-sql.ts
126
+ /**
127
+ * SQL rendering + value (de)serialization for the SQLite cache backend.
128
+ * Extracted from `sqlite-cache-engine.ts` so BOTH sides of the worker boundary
129
+ * can use it: the engine (main thread) for the legacy/shim paths, and
130
+ * `sqlite-worker.js` for worker-side plan execution (`select`). Pure module —
131
+ * no DOM, no worker, no engine imports — mirroring `plan-render.ts`.
132
+ */
133
+ function renderOrderSql(orderBy) {
134
+ return ` ORDER BY ${orderBy.map(([f, d]) => `json_extract(data, '$.${f}') ${d === "desc" ? "DESC" : "ASC"}`).join(", ")}`;
135
+ }
136
+ function comparisonSql(c, bind, params) {
137
+ const lhs = c.field === "id" ? "id" : `json_extract(data, '$.${c.field}')`;
138
+ const value = c.paramRef !== void 0 && (c.value === void 0 || Object.prototype.hasOwnProperty.call(params, c.paramRef)) ? params[c.paramRef] : c.value;
139
+ bind.push(scalar(value));
140
+ const op = c.op === "!=" ? "!=" : c.op;
141
+ return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
142
+ }
143
+ function renderWhereSql(nodes, bind, params) {
144
+ return nodes.map((node) => {
145
+ if ("or" in node) return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(" OR ")})`;
146
+ return comparisonSql(node, bind, params);
147
+ }).join(" AND ");
148
+ }
149
+ /** A comparable scalar for SQL binding: record links → `table:id`, everything
150
+ * else passed through (numbers/strings/bools). */
151
+ function scalar(value) {
152
+ if (value == null) return null;
153
+ if (typeof value === "object") return stableKey(value);
154
+ return value;
155
+ }
156
+ function serializeRow(row) {
157
+ return JSON.stringify(row, (_k, v) => {
158
+ if (v instanceof Uint8Array) return { __u8: toBase64(v) };
159
+ if (v && typeof v === "object") {
160
+ const rid = v;
161
+ if (rid.tb !== void 0 && rid.id !== void 0) return stableKey(v);
162
+ }
163
+ return v;
164
+ });
165
+ }
166
+ function reviveRow(json) {
167
+ if (json.indexOf("\"__u8\"") === -1) return JSON.parse(json);
168
+ return JSON.parse(json, (_k, v) => {
169
+ if (v && typeof v === "object" && typeof v.__u8 === "string") return fromBase64(v.__u8);
170
+ return v;
171
+ });
172
+ }
173
+ function project(row, fields) {
174
+ const out = {};
175
+ for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
176
+ return out;
177
+ }
178
+ function toBase64(bytes) {
179
+ let bin = "";
180
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
181
+ return typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
182
+ }
183
+ function fromBase64(b64) {
184
+ if (typeof atob !== "undefined") {
185
+ const bin = atob(b64);
186
+ const out = new Uint8Array(bin.length);
187
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
188
+ return out;
189
+ }
190
+ return new Uint8Array(Buffer.from(b64, "base64"));
191
+ }
192
+
193
+ //#endregion
194
+ export { serializeRow as a, reviveRow as i, renderOrderSql as n, resolveRelations as o, renderWhereSql as r, stableKey as s, project as t };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,202 @@
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";
2
+ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
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
75
+ //#region src/services/database/sqlite-worker.ts
76
+ /**
77
+ * Dedicated Web Worker that owns the SQLite-WASM handle. All DB access is
78
+ * funnelled through here (the main thread never touches the wasm module), which
79
+ * is also what the OPFS VFS requires — file access must happen off the main
80
+ * thread. Persistence uses the **OPFS SAHPool VFS**: durable, and (unlike the
81
+ * classic OPFS VFS) it does NOT require COOP/COEP cross-origin isolation
82
+ * headers, so host apps embedding the client need no server changes. Falls back
83
+ * to an in-memory DB when OPFS is unavailable.
84
+ *
85
+ * Message protocol (request/response keyed by `id`):
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 }
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.
97
+ */
98
+ let db = null;
99
+ async function open(dbName, useOpfs, systemTables = []) {
100
+ const sqlite3 = await sqlite3InitModule();
101
+ let persisted = false;
102
+ if (useOpfs && sqlite3.installOpfsSAHPoolVfs) try {
103
+ db = new (await (sqlite3.installOpfsSAHPoolVfs({ name: `sp00ky-${dbName}` }))).OpfsSAHPoolDb(`/${dbName}.sqlite3`);
104
+ persisted = true;
105
+ } catch {}
106
+ if (!db) db = new sqlite3.oo1.DB(":memory:", "c");
107
+ for (const t of systemTables) db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
108
+ try {
109
+ db.exec({ sql: "PRAGMA busy_timeout = 5000; PRAGMA cache_size = -32000;" });
110
+ } catch {}
111
+ return { persisted };
112
+ }
113
+ function exec(sql, bind) {
114
+ if (!db) throw new Error("sqlite: DB not open");
115
+ return db.exec({
116
+ sql,
117
+ bind,
118
+ rowMode: "object",
119
+ returnValue: "resultRows"
120
+ });
121
+ }
122
+ function run(sql, bind) {
123
+ if (!db) throw new Error("sqlite: DB not open");
124
+ db.exec({
125
+ sql,
126
+ bind
127
+ });
128
+ }
129
+ function batch(stmts) {
130
+ if (!db) throw new Error("sqlite: DB not open");
131
+ db.exec({ sql: "BEGIN" });
132
+ try {
133
+ for (const s of stmts) db.exec({
134
+ sql: s.sql,
135
+ bind: s.bind
136
+ });
137
+ db.exec({ sql: "COMMIT" });
138
+ } catch (e) {
139
+ try {
140
+ db.exec({ sql: "ROLLBACK" });
141
+ } catch {}
142
+ throw e;
143
+ }
144
+ }
145
+ /** DB handle for `executeSelect` (see `sqlite-select.ts`, the unit-testable
146
+ * plan executor). `knownTables` is cleared on open/close — a bucket switch
147
+ * spawns a fresh worker anyway. */
148
+ const selectDb = {
149
+ exec: (sql, bind) => exec(sql, bind),
150
+ run,
151
+ knownTables: /* @__PURE__ */ new Set()
152
+ };
153
+ self.onmessage = async (ev) => {
154
+ const { id, type, payload } = ev.data ?? {};
155
+ const t0 = performance.now();
156
+ try {
157
+ let result;
158
+ switch (type) {
159
+ case "open":
160
+ selectDb.knownTables.clear();
161
+ result = await open(payload.dbName, payload.useOpfs, payload.systemTables);
162
+ for (const t of payload.systemTables ?? []) selectDb.knownTables.add(t);
163
+ break;
164
+ case "select":
165
+ result = await executeSelect(payload.plan, payload.params ?? {}, selectDb);
166
+ break;
167
+ case "exec":
168
+ result = { rows: exec(payload.sql, payload.bind) };
169
+ break;
170
+ case "run":
171
+ run(payload.sql, payload.bind);
172
+ result = {};
173
+ break;
174
+ case "batch":
175
+ batch(payload);
176
+ result = {};
177
+ break;
178
+ case "close":
179
+ db?.close();
180
+ db = null;
181
+ selectDb.knownTables.clear();
182
+ result = {};
183
+ break;
184
+ default: throw new Error(`sqlite worker: unknown message ${type}`);
185
+ }
186
+ self.postMessage({
187
+ id,
188
+ ok: true,
189
+ wt: performance.now() - t0,
190
+ ...result
191
+ });
192
+ } catch (err) {
193
+ self.postMessage({
194
+ id,
195
+ ok: false,
196
+ error: err instanceof Error ? err.message : String(err)
197
+ });
198
+ }
199
+ };
200
+
201
+ //#endregion
202
+ export { };