@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/bucket-blurhash.test.ts +148 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- 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 +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -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.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +484 -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 +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- 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 +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
package/dist/otel/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { d as PinoTransmit } from "../types.js";
|
|
2
2
|
import { Level } from "pino";
|
|
3
3
|
|
|
4
4
|
//#region src/otel/index.d.ts
|
|
@@ -10,7 +10,7 @@ import { Level } from "pino";
|
|
|
10
10
|
* ```ts
|
|
11
11
|
* import { createOtelTransmit } from '@spooky-sync/core/otel';
|
|
12
12
|
*
|
|
13
|
-
* new
|
|
13
|
+
* new Sp00kyClient({
|
|
14
14
|
* // ...
|
|
15
15
|
* otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
|
|
16
16
|
* });
|
package/dist/otel/index.js
CHANGED
|
@@ -18,7 +18,7 @@ async function loadOtelModules(otelEndpoint) {
|
|
|
18
18
|
import("@opentelemetry/semantic-conventions")
|
|
19
19
|
]);
|
|
20
20
|
const loggerProvider = new LoggerProvider({
|
|
21
|
-
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "
|
|
21
|
+
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "sp00ky-client" }),
|
|
22
22
|
processors: [new BatchLogRecordProcessor(new OTLPLogExporter({ url: otelEndpoint }))]
|
|
23
23
|
});
|
|
24
24
|
const otelLoggerCache = {};
|
|
@@ -34,7 +34,7 @@ async function loadOtelModules(otelEndpoint) {
|
|
|
34
34
|
* ```ts
|
|
35
35
|
* import { createOtelTransmit } from '@spooky-sync/core/otel';
|
|
36
36
|
*
|
|
37
|
-
* new
|
|
37
|
+
* new Sp00kyClient({
|
|
38
38
|
* // ...
|
|
39
39
|
* otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
|
|
40
40
|
* });
|
|
@@ -50,10 +50,10 @@ function createOtelTransmit(endpoint, level = "info") {
|
|
|
50
50
|
const messages = [...logEvent.messages];
|
|
51
51
|
const severityNumber = mapLevelToSeverityNumber(levelLabel);
|
|
52
52
|
let body = "";
|
|
53
|
-
const
|
|
54
|
-
if (typeof
|
|
55
|
-
else if (
|
|
56
|
-
let category = "
|
|
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
57
|
const attributes = {};
|
|
58
58
|
for (const msg of messages) if (typeof msg === "object") {
|
|
59
59
|
if (msg.Category) {
|
|
@@ -0,0 +1,303 @@
|
|
|
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
|
+
/**
|
|
174
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
175
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
176
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
177
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
178
|
+
*
|
|
179
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
180
|
+
* what both paths did before, and verified so against real SQLite:
|
|
181
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
182
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
183
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
184
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
185
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
186
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
187
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
188
|
+
*
|
|
189
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
190
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
191
|
+
*
|
|
192
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
193
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
194
|
+
*/
|
|
195
|
+
function projectedDataSql(fields, bind) {
|
|
196
|
+
const keys = ["id", ...fields];
|
|
197
|
+
for (const k of keys) bind.push(k);
|
|
198
|
+
return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
|
|
199
|
+
}
|
|
200
|
+
function project(row, fields) {
|
|
201
|
+
const out = {};
|
|
202
|
+
for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
function toBase64(bytes) {
|
|
206
|
+
let bin = "";
|
|
207
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
208
|
+
return typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
209
|
+
}
|
|
210
|
+
function fromBase64(b64) {
|
|
211
|
+
if (typeof atob !== "undefined") {
|
|
212
|
+
const bin = atob(b64);
|
|
213
|
+
const out = new Uint8Array(bin.length);
|
|
214
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/services/database/sqlite-open.ts
|
|
222
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
223
|
+
/** Bounded on purpose: this runs on the boot path, before the first query. */
|
|
224
|
+
const DEFAULT_BACKOFF_MS = [250, 500];
|
|
225
|
+
/**
|
|
226
|
+
* Leader-promotion profile (~5.8s worst case). A dead leader's sync access
|
|
227
|
+
* handles release when the browser garbage-collects its worker, typically
|
|
228
|
+
* well under a second but not synchronously with the Web Lock release the
|
|
229
|
+
* election observed, so promotion retries longer than a cold boot.
|
|
230
|
+
*/
|
|
231
|
+
const PROMOTION_OPEN_OPTIONS = {
|
|
232
|
+
maxAttempts: 10,
|
|
233
|
+
backoffMs: [
|
|
234
|
+
50,
|
|
235
|
+
100,
|
|
236
|
+
200,
|
|
237
|
+
400,
|
|
238
|
+
800,
|
|
239
|
+
1e3
|
|
240
|
+
]
|
|
241
|
+
};
|
|
242
|
+
/** Failures no retry can fix: the APIs aren't there at all (insecure context,
|
|
243
|
+
* or a browser without sync access handles). Fall back immediately. */
|
|
244
|
+
const UNRETRYABLE = ["Missing required OPFS APIs"];
|
|
245
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
246
|
+
/** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
|
|
247
|
+
* locked by another tab): it is the most diagnostic part of the failure. */
|
|
248
|
+
function errMessage(e) {
|
|
249
|
+
if (e instanceof Error) return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
|
|
250
|
+
return String(e);
|
|
251
|
+
}
|
|
252
|
+
function fallbackToMemory(sqlite3, dbName, reason, attempts) {
|
|
253
|
+
const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? "" : "s"}` : "";
|
|
254
|
+
console.error(`[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local write on reload. The usual cause is another tab of this app holding the storage lock, so closing the other tabs and reloading restores persistence.`);
|
|
255
|
+
return {
|
|
256
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
257
|
+
persisted: false,
|
|
258
|
+
opfsError: reason
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Open `dbName`'s handle. Never throws for a storage problem: a caller that
|
|
263
|
+
* asked for persistence and can't have it gets a working in-memory handle plus
|
|
264
|
+
* `persisted: false` and an `opfsError` to report.
|
|
265
|
+
*/
|
|
266
|
+
async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
267
|
+
if (!useOpfs) return {
|
|
268
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
269
|
+
persisted: false
|
|
270
|
+
};
|
|
271
|
+
if (!sqlite3.installOpfsSAHPoolVfs) {
|
|
272
|
+
if (opts.disallowMemoryFallback) throw new Error("opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs");
|
|
273
|
+
return fallbackToMemory(sqlite3, dbName, "sqlite-wasm build has no installOpfsSAHPoolVfs", 0);
|
|
274
|
+
}
|
|
275
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
276
|
+
const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
277
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
278
|
+
let lastError = "unknown error";
|
|
279
|
+
let attempts = 0;
|
|
280
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
281
|
+
attempts = attempt;
|
|
282
|
+
try {
|
|
283
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({
|
|
284
|
+
name: `sp00ky-${dbName}`,
|
|
285
|
+
...attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`),
|
|
289
|
+
persisted: true,
|
|
290
|
+
pool
|
|
291
|
+
};
|
|
292
|
+
} catch (e) {
|
|
293
|
+
lastError = errMessage(e);
|
|
294
|
+
if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
|
|
295
|
+
await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (opts.disallowMemoryFallback) throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
|
|
299
|
+
return fallbackToMemory(sqlite3, dbName, lastError, attempts);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
export { renderOrderSql as a, serializeRow as c, projectedDataSql as i, resolveRelations as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, stableKey as u };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|