@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 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 run = () => this.rawCall(type, payload);
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
- return (rows ?? []).map((r) => reviveRow(r.data));
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, and queue depth
2018
- * (`maxInFlight`) so a churn OOM can be measured rather than guessed. */
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
- roundTrips: 0,
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
- function renderOrderSql(orderBy) {
2032
- return ` ORDER BY ${orderBy.map(([f, d]) => `json_extract(data, '$.${f}') ${d === "desc" ? "DESC" : "ASC"}`).join(", ")}`;
2033
- }
2034
- function comparisonSql(c, bind, params) {
2035
- const lhs = c.field === "id" ? "id" : `json_extract(data, '$.${c.field}')`;
2036
- const value = c.paramRef ? params[c.paramRef] : c.value;
2037
- bind.push(scalar(value));
2038
- const op = c.op === "!=" ? "!=" : c.op;
2039
- return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
2040
- }
2041
- function renderWhereSql(nodes, bind, params) {
2042
- return nodes.map((node) => {
2043
- if ("or" in node) return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(" OR ")})`;
2044
- return comparisonSql(node, bind, params);
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 reviveRow(json) {
2065
- if (json.indexOf("\"__u8\"") === -1) return JSON.parse(json);
2066
- return JSON.parse(json, (_k, v) => {
2067
- if (v && typeof v === "object" && typeof v.__u8 === "string") return fromBase64(v.__u8);
2068
- return v;
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 project(row, fields) {
2072
- const out = {};
2073
- for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
2074
- return out;
2075
- }
2076
- function toBase64(bytes) {
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
- function fromBase64(b64) {
2082
- if (typeof atob !== "undefined") {
2083
- const bin = atob(b64);
2084
- const out = new Uint8Array(bin.length);
2085
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
2086
- return out;
2087
- }
2088
- return new Uint8Array(Buffer.from(b64, "base64"));
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.133";
5152
- const WASM_VERSION = "0.0.1-canary.133";
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 };