@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.290
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2027 -137
- package/dist/index.js +14564 -2472
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1700 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +366 -0
- package/src/client/sp00ky-client.test.ts +271 -0
- package/src/client/sp00ky-client.ts +541 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +58 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- 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/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +519 -37
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -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 +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- 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 +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +144 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- 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 +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -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 +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -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 +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- 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 +58 -2
- 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 +386 -0
- package/src/services/tabs/coordinator.ts +638 -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 +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +34 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,358 @@
|
|
|
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
|
+
if (isOne && !looksLikeRecordId(key)) continue;
|
|
50
|
+
const k = stableKey(key);
|
|
51
|
+
if (!keySet.has(k)) keySet.set(k, key);
|
|
52
|
+
}
|
|
53
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
54
|
+
if (keySet.size > 0) {
|
|
55
|
+
const children = await fetcher.fetchRelation({
|
|
56
|
+
table: relation.table,
|
|
57
|
+
matchField,
|
|
58
|
+
keys: [...keySet.values()],
|
|
59
|
+
where: relation.where,
|
|
60
|
+
orderBy: relation.orderBy,
|
|
61
|
+
select: relation.select
|
|
62
|
+
});
|
|
63
|
+
await resolveRelations(children, relation.relations, fetcher, depth + 1, [...path, relation.alias]);
|
|
64
|
+
for (const child of children) {
|
|
65
|
+
const k = stableKey(child[matchField]);
|
|
66
|
+
const bucket = grouped.get(k);
|
|
67
|
+
if (bucket) bucket.push(child);
|
|
68
|
+
else grouped.set(k, [child]);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const parent of parents) {
|
|
72
|
+
const key = parentKeyOf(parent);
|
|
73
|
+
let bucket = key == null ? [] : grouped.get(stableKey(key)) ?? [];
|
|
74
|
+
if (relation.orderBy && relation.orderBy.length > 0) bucket = sortRows(bucket, relation.orderBy);
|
|
75
|
+
if (relation.limit !== void 0) bucket = bucket.slice(0, relation.limit);
|
|
76
|
+
if (isOne) {
|
|
77
|
+
const raw = parent[relation.alias];
|
|
78
|
+
const keepRaw = relation.alias === relation.foreignKeyField && raw != null && !looksLikeRecordId(raw);
|
|
79
|
+
parent[relation.alias] = bucket[0] ?? (keepRaw ? raw : null);
|
|
80
|
+
} else parent[relation.alias] = bucket;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a correlation value can address a record: a RecordId (duck-typed) or
|
|
85
|
+
* a `table:id` string. Anything else (a plain display string, a number) cannot
|
|
86
|
+
* be the target of a to-one relation and is skipped rather than fetched.
|
|
87
|
+
*/
|
|
88
|
+
function looksLikeRecordId(value) {
|
|
89
|
+
if (value == null) return false;
|
|
90
|
+
if (typeof value === "string") return /^[A-Za-z_][A-Za-z0-9_]*:.+/.test(value);
|
|
91
|
+
if (typeof value === "object") {
|
|
92
|
+
const rid = value;
|
|
93
|
+
return rid.tb !== void 0 && rid.id !== void 0;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Stable grouping key for a correlation value. A `RecordId` (duck-typed to
|
|
99
|
+
* avoid a hard surrealdb dependency here) and its `table:id` string form must
|
|
100
|
+
* collapse to the same key so a parent FK stored either way matches the child.
|
|
101
|
+
*/
|
|
102
|
+
function stableKey(value) {
|
|
103
|
+
if (value == null) return "\0null";
|
|
104
|
+
if (typeof value === "string") return value;
|
|
105
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
106
|
+
if (typeof value === "object") {
|
|
107
|
+
const rid = value;
|
|
108
|
+
if (rid.tb !== void 0 && rid.id !== void 0) return `${String(rid.tb)}:${stableKey(rid.id)}`;
|
|
109
|
+
if (typeof rid.toString === "function") {
|
|
110
|
+
const s = rid.toString();
|
|
111
|
+
if (s !== "[object Object]") return s;
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify(value);
|
|
114
|
+
}
|
|
115
|
+
return String(value);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Stable, multi-key sort matching SurrealQL `ORDER BY` semantics closely
|
|
119
|
+
* enough for cache display order: ascending null-last, type-aware compare, and
|
|
120
|
+
* a stable tiebreak (original index) so equal rows keep input order.
|
|
121
|
+
*/
|
|
122
|
+
function sortRows(rows, orderBy) {
|
|
123
|
+
const indexed = rows.map((row, i) => [row, i]);
|
|
124
|
+
indexed.sort((a, b) => {
|
|
125
|
+
for (const [field, direction] of orderBy) {
|
|
126
|
+
const cmp = compareValues(a[0][field], b[0][field]);
|
|
127
|
+
if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
|
|
128
|
+
}
|
|
129
|
+
return a[1] - b[1];
|
|
130
|
+
});
|
|
131
|
+
return indexed.map(([row]) => row);
|
|
132
|
+
}
|
|
133
|
+
function compareValues(a, b) {
|
|
134
|
+
if (a == null && b == null) return 0;
|
|
135
|
+
if (a == null) return 1;
|
|
136
|
+
if (b == null) return -1;
|
|
137
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
138
|
+
const as = stableKey(a);
|
|
139
|
+
const bs = stableKey(b);
|
|
140
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/services/database/sqlite-plan-sql.ts
|
|
145
|
+
/**
|
|
146
|
+
* SQL rendering + value (de)serialization for the SQLite cache backend.
|
|
147
|
+
* Extracted from `sqlite-cache-engine.ts` so BOTH sides of the worker boundary
|
|
148
|
+
* can use it: the engine (main thread) for the legacy/shim paths, and
|
|
149
|
+
* `sqlite-worker.js` for worker-side plan execution (`select`). Pure module —
|
|
150
|
+
* no DOM, no worker, no engine imports — mirroring `plan-render.ts`.
|
|
151
|
+
*/
|
|
152
|
+
function renderOrderSql(orderBy) {
|
|
153
|
+
return ` ORDER BY ${orderBy.map(([f, d]) => `json_extract(data, '$.${f}') ${d === "desc" ? "DESC" : "ASC"}`).join(", ")}`;
|
|
154
|
+
}
|
|
155
|
+
function comparisonSql(c, bind, params) {
|
|
156
|
+
const lhs = c.field === "id" ? "id" : `json_extract(data, '$.${c.field}')`;
|
|
157
|
+
const value = c.paramRef !== void 0 && (c.value === void 0 || Object.prototype.hasOwnProperty.call(params, c.paramRef)) ? params[c.paramRef] : c.value;
|
|
158
|
+
bind.push(scalar(value));
|
|
159
|
+
const op = c.op === "!=" ? "!=" : c.op;
|
|
160
|
+
return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
|
|
161
|
+
}
|
|
162
|
+
function renderWhereSql(nodes, bind, params) {
|
|
163
|
+
return nodes.map((node) => {
|
|
164
|
+
if ("or" in node) return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(" OR ")})`;
|
|
165
|
+
return comparisonSql(node, bind, params);
|
|
166
|
+
}).join(" AND ");
|
|
167
|
+
}
|
|
168
|
+
/** A comparable scalar for SQL binding: record links → `table:id`, everything
|
|
169
|
+
* else passed through (numbers/strings/bools). */
|
|
170
|
+
function scalar(value) {
|
|
171
|
+
if (value == null) return null;
|
|
172
|
+
if (typeof value === "object") return stableKey(value);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
function serializeRow(row) {
|
|
176
|
+
return JSON.stringify(row, (_k, v) => {
|
|
177
|
+
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
178
|
+
if (v && typeof v === "object") {
|
|
179
|
+
const rid = v;
|
|
180
|
+
if (rid.tb !== void 0 && rid.id !== void 0) return stableKey(v);
|
|
181
|
+
}
|
|
182
|
+
return v;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
187
|
+
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
188
|
+
*
|
|
189
|
+
* A top-level own key holding `undefined` means "clear this field", which is
|
|
190
|
+
* what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
|
|
191
|
+
* such a key, so a patch built by {@link serializeRow} alone never mentioned
|
|
192
|
+
* it and the stored value survived every merge: a field cleared on the server
|
|
193
|
+
* stayed set locally. The patch therefore carries each one as a `null` member,
|
|
194
|
+
* which `json_patch` removes; the insert row simply lacks it, exactly as
|
|
195
|
+
* before. Appended to the already-serialized text, so the common case costs no
|
|
196
|
+
* second stringify. Nested `undefined` stays dropped (never a clear here).
|
|
197
|
+
*/
|
|
198
|
+
function serializeMergeRow(row) {
|
|
199
|
+
const insert = serializeRow(row);
|
|
200
|
+
const cleared = [];
|
|
201
|
+
for (const key of Object.keys(row)) if (row[key] === void 0) cleared.push(`${JSON.stringify(key)}:null`);
|
|
202
|
+
if (cleared.length === 0 || insert[0] !== "{") return {
|
|
203
|
+
insert,
|
|
204
|
+
patch: insert
|
|
205
|
+
};
|
|
206
|
+
const members = cleared.join(",");
|
|
207
|
+
return {
|
|
208
|
+
insert,
|
|
209
|
+
patch: insert === "{}" ? `{${members}}` : `${insert.slice(0, -1)},${members}}`
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function reviveRow(json) {
|
|
213
|
+
if (json.indexOf("\"__u8\"") === -1) return JSON.parse(json);
|
|
214
|
+
return JSON.parse(json, (_k, v) => {
|
|
215
|
+
if (v && typeof v === "object" && typeof v.__u8 === "string") return fromBase64(v.__u8);
|
|
216
|
+
return v;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
221
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
222
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
223
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
224
|
+
*
|
|
225
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
226
|
+
* what both paths did before, and verified so against real SQLite:
|
|
227
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
228
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
229
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
230
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
231
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
232
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
233
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
234
|
+
*
|
|
235
|
+
* The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
|
|
236
|
+
* has no boolean type, so `json_each` hands `true`/`false` out as the integers
|
|
237
|
+
* 1/0 and `json_group_object` writes integers back. A projected row therefore
|
|
238
|
+
* read `published: 1` where the same row read whole read `published: true`,
|
|
239
|
+
* and every `=== true` in an app turned false the moment a screen painted from
|
|
240
|
+
* the local cache. (A boolean NESTED in an object or array survives: those come
|
|
241
|
+
* back as whole JSON documents, untouched.) `je.type` still knows what it was,
|
|
242
|
+
* so the two boolean types are rebuilt as JSON literals on the way out.
|
|
243
|
+
*
|
|
244
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
245
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
246
|
+
*
|
|
247
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
248
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
249
|
+
*/
|
|
250
|
+
function projectedDataSql(fields, bind) {
|
|
251
|
+
const keys = ["id", ...fields];
|
|
252
|
+
for (const k of keys) bind.push(k);
|
|
253
|
+
return `COALESCE((SELECT json_group_object(je.key, CASE je.type WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') ELSE je.value END) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
|
|
254
|
+
}
|
|
255
|
+
function project(row, fields) {
|
|
256
|
+
const out = {};
|
|
257
|
+
for (const f of ["id", ...fields]) if (f in row) out[f] = row[f];
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function toBase64(bytes) {
|
|
261
|
+
let bin = "";
|
|
262
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
263
|
+
return typeof btoa !== "undefined" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
264
|
+
}
|
|
265
|
+
function fromBase64(b64) {
|
|
266
|
+
if (typeof atob !== "undefined") {
|
|
267
|
+
const bin = atob(b64);
|
|
268
|
+
const out = new Uint8Array(bin.length);
|
|
269
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/services/database/sqlite-open.ts
|
|
277
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
278
|
+
/** Bounded on purpose: this runs on the boot path, before the first query. */
|
|
279
|
+
const DEFAULT_BACKOFF_MS = [250, 500];
|
|
280
|
+
/**
|
|
281
|
+
* Leader-promotion profile (~5.8s worst case). A dead leader's sync access
|
|
282
|
+
* handles release when the browser garbage-collects its worker, typically
|
|
283
|
+
* well under a second but not synchronously with the Web Lock release the
|
|
284
|
+
* election observed, so promotion retries longer than a cold boot.
|
|
285
|
+
*/
|
|
286
|
+
const PROMOTION_OPEN_OPTIONS = {
|
|
287
|
+
maxAttempts: 10,
|
|
288
|
+
backoffMs: [
|
|
289
|
+
50,
|
|
290
|
+
100,
|
|
291
|
+
200,
|
|
292
|
+
400,
|
|
293
|
+
800,
|
|
294
|
+
1e3
|
|
295
|
+
]
|
|
296
|
+
};
|
|
297
|
+
/** Failures no retry can fix: the APIs aren't there at all (insecure context,
|
|
298
|
+
* or a browser without sync access handles). Fall back immediately. */
|
|
299
|
+
const UNRETRYABLE = ["Missing required OPFS APIs"];
|
|
300
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
301
|
+
/** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
|
|
302
|
+
* locked by another tab): it is the most diagnostic part of the failure. */
|
|
303
|
+
function errMessage(e) {
|
|
304
|
+
if (e instanceof Error) return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
|
|
305
|
+
return String(e);
|
|
306
|
+
}
|
|
307
|
+
function fallbackToMemory(sqlite3, dbName, reason, attempts) {
|
|
308
|
+
const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? "" : "s"}` : "";
|
|
309
|
+
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.`);
|
|
310
|
+
return {
|
|
311
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
312
|
+
persisted: false,
|
|
313
|
+
opfsError: reason
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Open `dbName`'s handle. Never throws for a storage problem: a caller that
|
|
318
|
+
* asked for persistence and can't have it gets a working in-memory handle plus
|
|
319
|
+
* `persisted: false` and an `opfsError` to report.
|
|
320
|
+
*/
|
|
321
|
+
async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
322
|
+
if (!useOpfs) return {
|
|
323
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
324
|
+
persisted: false
|
|
325
|
+
};
|
|
326
|
+
if (!sqlite3.installOpfsSAHPoolVfs) {
|
|
327
|
+
if (opts.disallowMemoryFallback) throw new Error("opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs");
|
|
328
|
+
return fallbackToMemory(sqlite3, dbName, "sqlite-wasm build has no installOpfsSAHPoolVfs", 0);
|
|
329
|
+
}
|
|
330
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
331
|
+
const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
332
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
333
|
+
let lastError = "unknown error";
|
|
334
|
+
let attempts = 0;
|
|
335
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
336
|
+
attempts = attempt;
|
|
337
|
+
try {
|
|
338
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({
|
|
339
|
+
name: `sp00ky-${dbName}`,
|
|
340
|
+
...attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}
|
|
341
|
+
});
|
|
342
|
+
return {
|
|
343
|
+
db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`),
|
|
344
|
+
persisted: true,
|
|
345
|
+
pool
|
|
346
|
+
};
|
|
347
|
+
} catch (e) {
|
|
348
|
+
lastError = errMessage(e);
|
|
349
|
+
if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
|
|
350
|
+
await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (opts.disallowMemoryFallback) throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
|
|
354
|
+
return fallbackToMemory(sqlite3, dbName, lastError, attempts);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
export { renderOrderSql as a, serializeMergeRow as c, stableKey as d, projectedDataSql as i, serializeRow as l, openDb as n, renderWhereSql as o, project as r, reviveRow as s, PROMOTION_OPEN_OPTIONS as t, resolveRelations as u };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|