@spooky-sync/core 0.0.1-canary.133 → 0.0.1-canary.135
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
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { a as serializeRow, 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 { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
|
|
2
3
|
import { createWasmWorkerEngines } from "@surrealdb/wasm";
|
|
3
4
|
import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
|
|
@@ -1046,130 +1047,6 @@ var LocalMigrator = class {
|
|
|
1046
1047
|
}
|
|
1047
1048
|
};
|
|
1048
1049
|
|
|
1049
|
-
//#endregion
|
|
1050
|
-
//#region src/services/database/cache-engine.ts
|
|
1051
|
-
/** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
|
|
1052
|
-
* a guard against a cyclic schema producing unbounded fan-out. */
|
|
1053
|
-
var RelationCycleError = class extends Error {
|
|
1054
|
-
constructor(path) {
|
|
1055
|
-
super(`Relation nesting exceeded safe depth; possible cyclic schema: ${path.join(" -> ")}`);
|
|
1056
|
-
this.name = "RelationCycleError";
|
|
1057
|
-
}
|
|
1058
|
-
};
|
|
1059
|
-
/** Defensive ceiling on relation nesting depth. A finite plan tree never hits
|
|
1060
|
-
* this in practice; it exists so a malformed/cyclic plan fails loudly instead
|
|
1061
|
-
* of running away. */
|
|
1062
|
-
const MAX_RELATION_DEPTH = 12;
|
|
1063
|
-
|
|
1064
|
-
//#endregion
|
|
1065
|
-
//#region src/services/database/relation-resolver.ts
|
|
1066
|
-
/**
|
|
1067
|
-
* Resolve the `.related()` tree of a query by **level-ordered, batched
|
|
1068
|
-
* fan-out** — the engine-neutral replacement for SurrealQL's nested
|
|
1069
|
-
* `(SELECT..) AS alias` projections. One batched fetch per relation per level
|
|
1070
|
-
* (never per-row N+1, never a flat join); grouping, per-parent ORDER/LIMIT and
|
|
1071
|
-
* attachment happen in JS.
|
|
1072
|
-
*
|
|
1073
|
-
* Correlation (mirrors `buildSubquery`):
|
|
1074
|
-
* - `one` → parent[`foreignKeyField`] = child.id → attach `bucket[0] ?? null`
|
|
1075
|
-
* - `many` → child[`foreignKeyField`] = parent.id → attach `bucket`
|
|
1076
|
-
*
|
|
1077
|
-
* Cross-level dependency is handled by ordering: a child's correlation key is a
|
|
1078
|
-
* field on its parent, known only once the parent level has resolved, so each
|
|
1079
|
-
* level's batch runs after the previous level's rows exist. `parents` is
|
|
1080
|
-
* mutated in place — each resolved alias is appended LAST so key order matches
|
|
1081
|
-
* SurrealQL's `SELECT *, <sub> AS alias` (base fields, then alias).
|
|
1082
|
-
*
|
|
1083
|
-
* @throws {RelationCycleError} if nesting exceeds {@link MAX_RELATION_DEPTH}.
|
|
1084
|
-
*/
|
|
1085
|
-
async function resolveRelations(parents, relations, fetcher, depth = 0, path = []) {
|
|
1086
|
-
if (!relations || relations.length === 0 || parents.length === 0) return;
|
|
1087
|
-
if (depth >= MAX_RELATION_DEPTH) throw new RelationCycleError([...path, relations.map((r) => r.alias).join("|")]);
|
|
1088
|
-
await Promise.all(relations.map((relation) => resolveOne(parents, relation, fetcher, depth, path)));
|
|
1089
|
-
}
|
|
1090
|
-
async function resolveOne(parents, relation, fetcher, depth, path) {
|
|
1091
|
-
const isOne = relation.cardinality === "one";
|
|
1092
|
-
const matchField = isOne ? "id" : relation.foreignKeyField;
|
|
1093
|
-
const parentKeyOf = (parent) => isOne ? parent[relation.foreignKeyField] : parent["id"];
|
|
1094
|
-
const keySet = /* @__PURE__ */ new Map();
|
|
1095
|
-
for (const parent of parents) {
|
|
1096
|
-
const key = parentKeyOf(parent);
|
|
1097
|
-
if (key == null) continue;
|
|
1098
|
-
const k = stableKey(key);
|
|
1099
|
-
if (!keySet.has(k)) keySet.set(k, key);
|
|
1100
|
-
}
|
|
1101
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1102
|
-
if (keySet.size > 0) {
|
|
1103
|
-
const children = await fetcher.fetchRelation({
|
|
1104
|
-
table: relation.table,
|
|
1105
|
-
matchField,
|
|
1106
|
-
keys: [...keySet.values()],
|
|
1107
|
-
where: relation.where,
|
|
1108
|
-
orderBy: relation.orderBy,
|
|
1109
|
-
select: relation.select
|
|
1110
|
-
});
|
|
1111
|
-
await resolveRelations(children, relation.relations, fetcher, depth + 1, [...path, relation.alias]);
|
|
1112
|
-
for (const child of children) {
|
|
1113
|
-
const k = stableKey(child[matchField]);
|
|
1114
|
-
const bucket = grouped.get(k);
|
|
1115
|
-
if (bucket) bucket.push(child);
|
|
1116
|
-
else grouped.set(k, [child]);
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
for (const parent of parents) {
|
|
1120
|
-
const key = parentKeyOf(parent);
|
|
1121
|
-
let bucket = key == null ? [] : grouped.get(stableKey(key)) ?? [];
|
|
1122
|
-
if (relation.orderBy && relation.orderBy.length > 0) bucket = sortRows(bucket, relation.orderBy);
|
|
1123
|
-
if (relation.limit !== void 0) bucket = bucket.slice(0, relation.limit);
|
|
1124
|
-
parent[relation.alias] = isOne ? bucket[0] ?? null : bucket;
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
/**
|
|
1128
|
-
* Stable grouping key for a correlation value. A `RecordId` (duck-typed to
|
|
1129
|
-
* avoid a hard surrealdb dependency here) and its `table:id` string form must
|
|
1130
|
-
* collapse to the same key so a parent FK stored either way matches the child.
|
|
1131
|
-
*/
|
|
1132
|
-
function stableKey(value) {
|
|
1133
|
-
if (value == null) return "\0null";
|
|
1134
|
-
if (typeof value === "string") return value;
|
|
1135
|
-
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
1136
|
-
if (typeof value === "object") {
|
|
1137
|
-
const rid = value;
|
|
1138
|
-
if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
|
|
1139
|
-
if (typeof rid.toString === "function") {
|
|
1140
|
-
const s = rid.toString();
|
|
1141
|
-
if (s !== "[object Object]") return s;
|
|
1142
|
-
}
|
|
1143
|
-
return JSON.stringify(value);
|
|
1144
|
-
}
|
|
1145
|
-
return String(value);
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Stable, multi-key sort matching SurrealQL `ORDER BY` semantics closely
|
|
1149
|
-
* enough for cache display order: ascending null-last, type-aware compare, and
|
|
1150
|
-
* a stable tiebreak (original index) so equal rows keep input order.
|
|
1151
|
-
*/
|
|
1152
|
-
function sortRows(rows, orderBy) {
|
|
1153
|
-
const indexed = rows.map((row, i) => [row, i]);
|
|
1154
|
-
indexed.sort((a, b) => {
|
|
1155
|
-
for (const [field, direction] of orderBy) {
|
|
1156
|
-
const cmp = compareValues(a[0][field], b[0][field]);
|
|
1157
|
-
if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
|
|
1158
|
-
}
|
|
1159
|
-
return a[1] - b[1];
|
|
1160
|
-
});
|
|
1161
|
-
return indexed.map(([row]) => row);
|
|
1162
|
-
}
|
|
1163
|
-
function compareValues(a, b) {
|
|
1164
|
-
if (a == null && b == null) return 0;
|
|
1165
|
-
if (a == null) return 1;
|
|
1166
|
-
if (b == null) return -1;
|
|
1167
|
-
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
1168
|
-
const as = stableKey(a);
|
|
1169
|
-
const bs = stableKey(b);
|
|
1170
|
-
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
1050
|
//#endregion
|
|
1174
1051
|
//#region src/services/database/plan-render.ts
|
|
1175
1052
|
function bind(ctx, value) {
|
|
@@ -1178,7 +1055,7 @@ function bind(ctx, value) {
|
|
|
1178
1055
|
return `$${name}`;
|
|
1179
1056
|
}
|
|
1180
1057
|
function renderComparisonSurql(c, ctx) {
|
|
1181
|
-
const rawRight = c.paramRef ? `$${c.paramRef}` : bind(ctx, c.value);
|
|
1058
|
+
const rawRight = c.paramRef !== void 0 && (c.value === void 0 || Object.prototype.hasOwnProperty.call(ctx.vars, c.paramRef)) ? `$${c.paramRef}` : bind(ctx, c.value);
|
|
1182
1059
|
const right = c.field === "id" ? `type::record(<string> ${rawRight})` : rawRight;
|
|
1183
1060
|
return c.swap ? `${right} ${c.op} ${c.field}` : `${c.field} ${c.op} ${right}`;
|
|
1184
1061
|
}
|
|
@@ -1595,6 +1472,10 @@ var SqliteCacheEngine = class {
|
|
|
1595
1472
|
storeEpoch = 0;
|
|
1596
1473
|
knownTables = /* @__PURE__ */ new Set();
|
|
1597
1474
|
useOpfs;
|
|
1475
|
+
/** Whether `select` runs as one worker round-trip (plan executed in-worker).
|
|
1476
|
+
* Flipped off at runtime if the worker script predates the `select` op
|
|
1477
|
+
* (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
|
|
1478
|
+
workerSelect;
|
|
1598
1479
|
events = createDatabaseEventSystem();
|
|
1599
1480
|
bucketId = "anon";
|
|
1600
1481
|
/** Schemaless — tables are created lazily on first write; no migrator. */
|
|
@@ -1603,6 +1484,7 @@ var SqliteCacheEngine = class {
|
|
|
1603
1484
|
this.config = config;
|
|
1604
1485
|
this.logger = logger;
|
|
1605
1486
|
this.useOpfs = opts.useOpfs ?? true;
|
|
1487
|
+
this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
|
|
1606
1488
|
}
|
|
1607
1489
|
get epoch() {
|
|
1608
1490
|
return this.storeEpoch;
|
|
@@ -1655,7 +1537,11 @@ var SqliteCacheEngine = class {
|
|
|
1655
1537
|
* single-flight query queue. */
|
|
1656
1538
|
opQueue = Promise.resolve();
|
|
1657
1539
|
call(type, payload) {
|
|
1658
|
-
const
|
|
1540
|
+
const enqueuedAt = performance.now();
|
|
1541
|
+
const run = () => {
|
|
1542
|
+
getStats().queueWaitMs += performance.now() - enqueuedAt;
|
|
1543
|
+
return this.rawCall(type, payload);
|
|
1544
|
+
};
|
|
1659
1545
|
const result = this.opQueue.then(run, run);
|
|
1660
1546
|
this.opQueue = result.then(() => void 0, () => void 0);
|
|
1661
1547
|
return result;
|
|
@@ -1675,10 +1561,16 @@ var SqliteCacheEngine = class {
|
|
|
1675
1561
|
const done = () => {
|
|
1676
1562
|
s.inFlight--;
|
|
1677
1563
|
};
|
|
1564
|
+
const sentAt = performance.now();
|
|
1678
1565
|
return new Promise((resolve, reject) => {
|
|
1679
1566
|
this.pending.set(id, {
|
|
1680
1567
|
resolve: (v) => {
|
|
1681
1568
|
done();
|
|
1569
|
+
const wt = v?.wt;
|
|
1570
|
+
if (typeof wt === "number") {
|
|
1571
|
+
s.workerMs += wt;
|
|
1572
|
+
s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
|
|
1573
|
+
}
|
|
1682
1574
|
resolve(v);
|
|
1683
1575
|
},
|
|
1684
1576
|
reject: (e) => {
|
|
@@ -1753,9 +1645,44 @@ var SqliteCacheEngine = class {
|
|
|
1753
1645
|
sql,
|
|
1754
1646
|
bind
|
|
1755
1647
|
});
|
|
1756
|
-
|
|
1648
|
+
const s = getStats();
|
|
1649
|
+
const t0 = performance.now();
|
|
1650
|
+
const out = (rows ?? []).map((r) => {
|
|
1651
|
+
s.bytesParsed += r.data.length;
|
|
1652
|
+
return reviveRow(r.data);
|
|
1653
|
+
});
|
|
1654
|
+
s.parseMs += performance.now() - t0;
|
|
1655
|
+
s.rowsParsed += out.length;
|
|
1656
|
+
return out;
|
|
1757
1657
|
}
|
|
1758
1658
|
async select(plan, params = {}) {
|
|
1659
|
+
if (this.workerSelect) {
|
|
1660
|
+
const normPlan = normalizePlanForClone(plan);
|
|
1661
|
+
const normParams = {};
|
|
1662
|
+
for (const [k, v] of Object.entries(params)) normParams[k] = toCloneSafe(v);
|
|
1663
|
+
try {
|
|
1664
|
+
const res = await this.call("select", {
|
|
1665
|
+
plan: normPlan,
|
|
1666
|
+
params: normParams
|
|
1667
|
+
});
|
|
1668
|
+
getStats().relationFetches += res.relationFetches ?? 0;
|
|
1669
|
+
return res.rows ?? [];
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
if (err instanceof Error && err.message.includes("unknown message")) {
|
|
1672
|
+
this.workerSelect = false;
|
|
1673
|
+
this.logger.warn({
|
|
1674
|
+
err,
|
|
1675
|
+
Category: "sp00ky-client::SqliteCacheEngine::select"
|
|
1676
|
+
}, "Worker lacks select op (stale script?) — falling back to multi-hop select");
|
|
1677
|
+
} else throw err;
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
return this.selectLegacy(plan, params);
|
|
1681
|
+
}
|
|
1682
|
+
/** Pre-worker-select path: one worker round-trip per table/relation level,
|
|
1683
|
+
* rows parsed on the main thread. Kept as the `workerSelect:false` escape
|
|
1684
|
+
* hatch and the stale-worker fallback. */
|
|
1685
|
+
async selectLegacy(plan, params = {}) {
|
|
1759
1686
|
if (plan.ids) {
|
|
1760
1687
|
const result = await this.selectByIds(plan.table, plan.ids, {
|
|
1761
1688
|
select: plan.select,
|
|
@@ -1777,6 +1704,7 @@ var SqliteCacheEngine = class {
|
|
|
1777
1704
|
return projected;
|
|
1778
1705
|
}
|
|
1779
1706
|
async fetchRelation(req) {
|
|
1707
|
+
getStats().relationFetches++;
|
|
1780
1708
|
await this.ensureTable(req.table);
|
|
1781
1709
|
const keys = req.keys.map(stableKey);
|
|
1782
1710
|
const placeholders = keys.map(() => "?").join(", ");
|
|
@@ -2013,79 +1941,76 @@ var SqliteCacheEngine = class {
|
|
|
2013
1941
|
});
|
|
2014
1942
|
}
|
|
2015
1943
|
};
|
|
1944
|
+
const EMPTY_STATS = {
|
|
1945
|
+
roundTrips: 0,
|
|
1946
|
+
batchStatements: 0,
|
|
1947
|
+
maxBatch: 0,
|
|
1948
|
+
inFlight: 0,
|
|
1949
|
+
maxInFlight: 0,
|
|
1950
|
+
byType: {},
|
|
1951
|
+
queueWaitMs: 0,
|
|
1952
|
+
workerMs: 0,
|
|
1953
|
+
rpcOverheadMs: 0,
|
|
1954
|
+
parseMs: 0,
|
|
1955
|
+
rowsParsed: 0,
|
|
1956
|
+
bytesParsed: 0,
|
|
1957
|
+
relationFetches: 0
|
|
1958
|
+
};
|
|
2016
1959
|
/** Live stats, inspectable in the browser console via `__sqliteStats`. Counts
|
|
2017
|
-
* worker round-trips (the sync-down cost driver), batch sizes,
|
|
2018
|
-
* (`maxInFlight`)
|
|
1960
|
+
* worker round-trips (the sync-down cost driver), batch sizes, queue depth
|
|
1961
|
+
* (`maxInFlight`), and the latency split of each round-trip (queue wait vs
|
|
1962
|
+
* worker time vs RPC overhead vs main-thread row parsing) so first-load cost
|
|
1963
|
+
* can be measured rather than guessed. */
|
|
2019
1964
|
function getStats() {
|
|
2020
1965
|
const g = globalThis;
|
|
2021
1966
|
if (!g.__sqliteStats) g.__sqliteStats = {
|
|
2022
|
-
|
|
2023
|
-
batchStatements: 0,
|
|
2024
|
-
maxBatch: 0,
|
|
2025
|
-
inFlight: 0,
|
|
2026
|
-
maxInFlight: 0,
|
|
1967
|
+
...EMPTY_STATS,
|
|
2027
1968
|
byType: {}
|
|
2028
1969
|
};
|
|
1970
|
+
else for (const [k, v] of Object.entries(EMPTY_STATS)) if (g.__sqliteStats[k] === void 0) g.__sqliteStats[k] = v;
|
|
2029
1971
|
return g.__sqliteStats;
|
|
2030
1972
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
}).join(" AND ");
|
|
2046
|
-
}
|
|
2047
|
-
/** A comparable scalar for SQL binding: record links → `table:id`, everything
|
|
2048
|
-
* else passed through (numbers/strings/bools). */
|
|
2049
|
-
function scalar(value) {
|
|
2050
|
-
if (value == null) return null;
|
|
2051
|
-
if (typeof value === "object") return stableKey(value);
|
|
2052
|
-
return value;
|
|
2053
|
-
}
|
|
2054
|
-
function serializeRow(row) {
|
|
2055
|
-
return JSON.stringify(row, (_k, v) => {
|
|
2056
|
-
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
2057
|
-
if (v && typeof v === "object") {
|
|
2058
|
-
const rid = v;
|
|
2059
|
-
if (rid.tb !== void 0 && rid.id !== void 0) return stableKey(v);
|
|
2060
|
-
}
|
|
2061
|
-
return v;
|
|
2062
|
-
});
|
|
1973
|
+
/**
|
|
1974
|
+
* Make a bind/param value safe to cross the worker boundary. structuredClone
|
|
1975
|
+
* keeps plain data intact but strips a CLASS instance to a bare object — and
|
|
1976
|
+
* surrealdb's `RecordId` stores its fields behind getters (zero own
|
|
1977
|
+
* properties), so it clones to `{}`. Convert such instances to their
|
|
1978
|
+
* `stableKey` string (the exact value `scalar()` would bind on the main
|
|
1979
|
+
* thread), leave everything clone-representable untouched.
|
|
1980
|
+
*/
|
|
1981
|
+
function toCloneSafe(v) {
|
|
1982
|
+
if (v === null || typeof v !== "object") return v;
|
|
1983
|
+
if (Array.isArray(v) || v instanceof Uint8Array || v instanceof Date) return v;
|
|
1984
|
+
const proto = Object.getPrototypeOf(v);
|
|
1985
|
+
if (proto === Object.prototype || proto === null) return v;
|
|
1986
|
+
return stableKey(v);
|
|
2063
1987
|
}
|
|
2064
|
-
function
|
|
2065
|
-
if (
|
|
2066
|
-
return
|
|
2067
|
-
|
|
2068
|
-
|
|
1988
|
+
function normalizeWhereForClone(nodes) {
|
|
1989
|
+
if (!nodes) return nodes;
|
|
1990
|
+
return nodes.map((n) => "or" in n ? { or: n.or.map((c) => ({
|
|
1991
|
+
...c,
|
|
1992
|
+
value: toCloneSafe(c.value)
|
|
1993
|
+
})) } : {
|
|
1994
|
+
...n,
|
|
1995
|
+
value: toCloneSafe(n.value)
|
|
2069
1996
|
});
|
|
2070
1997
|
}
|
|
2071
|
-
function
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
let bin = "";
|
|
2078
|
-
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
2079
|
-
return typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
1998
|
+
function normalizeRelationForClone(r) {
|
|
1999
|
+
return {
|
|
2000
|
+
...r,
|
|
2001
|
+
where: normalizeWhereForClone(r.where),
|
|
2002
|
+
relations: r.relations?.map(normalizeRelationForClone)
|
|
2003
|
+
};
|
|
2080
2004
|
}
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2005
|
+
/** Normalize every baked value a plan carries (where trees, window ids) for
|
|
2006
|
+
* the postMessage to the worker's `select` op. */
|
|
2007
|
+
function normalizePlanForClone(plan) {
|
|
2008
|
+
return {
|
|
2009
|
+
...plan,
|
|
2010
|
+
ids: plan.ids?.map(stableKey),
|
|
2011
|
+
where: normalizeWhereForClone(plan.where),
|
|
2012
|
+
relations: plan.relations?.map(normalizeRelationForClone)
|
|
2013
|
+
};
|
|
2089
2014
|
}
|
|
2090
2015
|
|
|
2091
2016
|
//#endregion
|
|
@@ -5148,8 +5073,8 @@ function parseBackendInfo(raw) {
|
|
|
5148
5073
|
|
|
5149
5074
|
//#endregion
|
|
5150
5075
|
//#region src/modules/devtools/index.ts
|
|
5151
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5152
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
5076
|
+
const CORE_VERSION = "0.0.1-canary.135";
|
|
5077
|
+
const WASM_VERSION = "0.0.1-canary.135";
|
|
5153
5078
|
const SURREAL_VERSION = "3.0.3";
|
|
5154
5079
|
var DevToolsService = class {
|
|
5155
5080
|
eventsHistory = [];
|
|
@@ -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 };
|