@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.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +1192 -372
- package/dist/index.js +5772 -1270
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-plan-sql.js +194 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +202 -0
- package/dist/types.d.ts +755 -0
- package/package.json +40 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +361 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1134 -128
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +215 -0
- package/src/services/database/sqlite-cache-engine.ts +829 -0
- 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 +160 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +295 -38
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1000 -0
- package/src/types.ts +267 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- 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 { };
|