@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -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 +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -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 +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -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.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -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 +167 -0
- package/src/kernel/events.ts +57 -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 +512 -0
- package/src/live/index.ts +1059 -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 +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -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 +301 -0
- package/src/mutation/push.saga.ts +225 -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 +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -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 +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -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 +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -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 +209 -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 +156 -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 +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -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 +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- 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 +806 -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 +234 -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 +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -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 +97 -0
- package/src/sync/connection.saga.ts +89 -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 +663 -0
- package/src/sync/policy.ts +515 -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 +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -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 +633 -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 +89 -12
- 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 +29 -18
- package/src/utils/utils.test.ts +3 -2
- 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,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { openDb } from './sqlite-open';
|
|
3
|
+
|
|
4
|
+
class FakeDb {
|
|
5
|
+
constructor(public arg: unknown) {}
|
|
6
|
+
exec() {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
close() {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Minimal stand-in for the initialized sqlite-wasm module. `install` is the
|
|
13
|
+
* `installOpfsSAHPoolVfs` behavior under test; omit it to model a build that
|
|
14
|
+
* lacks the SAHPool VFS entirely. */
|
|
15
|
+
function makeSqlite3(install?: (opts: any) => Promise<unknown>) {
|
|
16
|
+
const sqlite3: any = { oo1: { DB: FakeDb } };
|
|
17
|
+
if (install) sqlite3.installOpfsSAHPoolVfs = vi.fn(install);
|
|
18
|
+
return sqlite3;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pool = { OpfsSAHPoolDb: FakeDb };
|
|
22
|
+
/** What a pool locked by another tab of the app actually throws. */
|
|
23
|
+
function lockedError(): Error {
|
|
24
|
+
const e = new Error('Access Handles cannot be acquired');
|
|
25
|
+
e.name = 'NoModificationAllowedError';
|
|
26
|
+
return e;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const noSleep = () => Promise.resolve();
|
|
30
|
+
|
|
31
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
errSpy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The bare `catch {}` this replaces turned every OPFS failure into a silent
|
|
40
|
+
// full-RAM database: no log, no reason, no way for the app to know its writes
|
|
41
|
+
// die on reload. Each case below pins one half of the fix: keep trying when
|
|
42
|
+
// retrying can plausibly work, and when it can't, say so loudly and hand the
|
|
43
|
+
// reason back.
|
|
44
|
+
describe('openDb', () => {
|
|
45
|
+
it('opens the OPFS pool on the first attempt', async () => {
|
|
46
|
+
const sqlite3 = makeSqlite3(async () => pool);
|
|
47
|
+
const res = await openDb(sqlite3, 'user:abc', true, { sleep: noSleep });
|
|
48
|
+
|
|
49
|
+
expect(res.persisted).toBe(true);
|
|
50
|
+
expect(res.opfsError).toBeUndefined();
|
|
51
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
|
|
52
|
+
// The pool is named per bucket, and the first try must NOT force a re-init
|
|
53
|
+
// (that would throw away a pool another attempt is legitimately using).
|
|
54
|
+
expect(sqlite3.installOpfsSAHPoolVfs.mock.calls[0][0]).toEqual({ name: 'sp00ky-user:abc' });
|
|
55
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The tab-closing race: the old tab still holds the sync access handles when
|
|
59
|
+
// the new one boots. Without a retry that tab is stuck in RAM for its whole
|
|
60
|
+
// lifetime, even though the lock frees milliseconds later.
|
|
61
|
+
it('retries a locked pool and succeeds, forcing re-init after the first failure', async () => {
|
|
62
|
+
let calls = 0;
|
|
63
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
64
|
+
if (++calls < 3) throw lockedError();
|
|
65
|
+
return pool;
|
|
66
|
+
});
|
|
67
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
68
|
+
|
|
69
|
+
expect(res.persisted).toBe(true);
|
|
70
|
+
expect(res.opfsError).toBeUndefined();
|
|
71
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
|
|
72
|
+
// sqlite-wasm caches the first rejection against the VFS name, so retries
|
|
73
|
+
// that don't ask for a real re-init just replay it.
|
|
74
|
+
const [first, second, third] = sqlite3.installOpfsSAHPoolVfs.mock.calls.map((c: any[]) => c[0]);
|
|
75
|
+
expect(first.forceReinitIfPreviouslyFailed).toBeUndefined();
|
|
76
|
+
expect(second.forceReinitIfPreviouslyFailed).toBe(true);
|
|
77
|
+
expect(third.forceReinitIfPreviouslyFailed).toBe(true);
|
|
78
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back loudly after exhausting the retries, keeping the reason', async () => {
|
|
82
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
83
|
+
throw lockedError();
|
|
84
|
+
});
|
|
85
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
86
|
+
|
|
87
|
+
expect(res.persisted).toBe(false);
|
|
88
|
+
// The DOMException name is the diagnostic part: it names the lock.
|
|
89
|
+
expect(res.opfsError).toContain('NoModificationAllowedError');
|
|
90
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(3);
|
|
91
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(String(errSpy.mock.calls[0][0])).toContain('IN MEMORY');
|
|
93
|
+
// Still a usable handle: losing durability must not break the app.
|
|
94
|
+
expect(res.db).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// An insecure context has no sync access handles at all, so retrying just
|
|
98
|
+
// adds boot latency to a foregone conclusion.
|
|
99
|
+
it('does not retry when the OPFS APIs are missing entirely', async () => {
|
|
100
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
101
|
+
throw new Error('Missing required OPFS APIs.');
|
|
102
|
+
});
|
|
103
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
104
|
+
|
|
105
|
+
expect(res.persisted).toBe(false);
|
|
106
|
+
expect(res.opfsError).toContain('Missing required OPFS APIs');
|
|
107
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('reports a build without the SAHPool VFS without calling anything', async () => {
|
|
112
|
+
const sqlite3 = makeSqlite3();
|
|
113
|
+
const res = await openDb(sqlite3, 'main', true, { sleep: noSleep });
|
|
114
|
+
|
|
115
|
+
expect(res.persisted).toBe(false);
|
|
116
|
+
expect(res.opfsError).toContain('installOpfsSAHPoolVfs');
|
|
117
|
+
expect(errSpy).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// `store: 'memory'` is a configuration choice, not a degradation, so it must
|
|
121
|
+
// stay silent and carry no error for the UI to warn about.
|
|
122
|
+
it('opens in memory quietly when persistence was not requested', async () => {
|
|
123
|
+
const sqlite3 = makeSqlite3(async () => pool);
|
|
124
|
+
const res = await openDb(sqlite3, 'main', false, { sleep: noSleep });
|
|
125
|
+
|
|
126
|
+
expect(res.persisted).toBe(false);
|
|
127
|
+
expect(res.opfsError).toBeUndefined();
|
|
128
|
+
expect(sqlite3.installOpfsSAHPoolVfs).not.toHaveBeenCalled();
|
|
129
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('honors maxAttempts and waits the configured backoff between tries', async () => {
|
|
133
|
+
const slept: number[] = [];
|
|
134
|
+
const sqlite3 = makeSqlite3(async () => {
|
|
135
|
+
throw lockedError();
|
|
136
|
+
});
|
|
137
|
+
const res = await openDb(sqlite3, 'main', true, {
|
|
138
|
+
maxAttempts: 4,
|
|
139
|
+
backoffMs: [10, 20],
|
|
140
|
+
sleep: async (ms) => {
|
|
141
|
+
slept.push(ms);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res.persisted).toBe(false);
|
|
146
|
+
expect(sqlite3.installOpfsSAHPoolVfs).toHaveBeenCalledTimes(4);
|
|
147
|
+
// One sleep per gap (never after the last attempt), last delay repeating.
|
|
148
|
+
expect(slept).toEqual([10, 20, 20]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening the worker's SQLite handle: the OPFS SAHPool VFS when durable
|
|
3
|
+
* storage was asked for, an in-memory DB only as a last resort. Extracted from
|
|
4
|
+
* `sqlite-worker.ts` (which imports the wasm module at module scope and so
|
|
5
|
+
* can't be loaded in a unit test) to keep the retry/fallback policy testable
|
|
6
|
+
* off-worker, the same split as `sqlite-select.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Why retry: SAHPool holds an EXCLUSIVE sync access handle on every file in
|
|
9
|
+
* its pool, so only one client per pool name can have it open. A second tab of
|
|
10
|
+
* the same app therefore fails init, and `installOpfsSAHPoolVfs` CACHES that
|
|
11
|
+
* rejection per VFS name, so a later call only gets a real second chance when
|
|
12
|
+
* it passes `forceReinitIfPreviouslyFailed`. Retrying with that flag turns the
|
|
13
|
+
* common "the other tab is still closing" race into a success instead of a
|
|
14
|
+
* permanent in-memory session.
|
|
15
|
+
*
|
|
16
|
+
* Why the noise: `:memory:` holds the whole dataset in RAM (the
|
|
17
|
+
* OOM-on-wasm-heavy-pages failure mode the OPFS store exists to avoid) and
|
|
18
|
+
* drops every local write on reload. Host apps run pino at their own level,
|
|
19
|
+
* some at `fatal`, so the fallback ALSO writes to `console.error` from inside
|
|
20
|
+
* the worker, and the reason travels back to the engine as `opfsError` for the
|
|
21
|
+
* app to surface.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The DB surface the worker uses (a `sqlite3.oo1.DB` or an `OpfsSAHPoolDb`). */
|
|
25
|
+
export interface SqliteDbHandle {
|
|
26
|
+
exec: (opts: { sql: string; bind?: unknown[]; rowMode?: string; returnValue?: string }) => unknown;
|
|
27
|
+
close: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The slice of the OpfsSAHPoolUtil the worker needs for teardown. */
|
|
31
|
+
export interface SqlitePoolHandle {
|
|
32
|
+
/** Unregisters the VFS and releases every sync access handle, leaving the
|
|
33
|
+
* files intact, so another worker can open the pool without waiting for
|
|
34
|
+
* this worker to be garbage collected. Throws while files are open. */
|
|
35
|
+
pauseVfs?: () => unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OpenDbResult {
|
|
39
|
+
db: SqliteDbHandle;
|
|
40
|
+
/** True only when the handle is backed by OPFS and survives a reload. */
|
|
41
|
+
persisted: boolean;
|
|
42
|
+
/** Why persistence failed. Set only when OPFS was requested and fell back. */
|
|
43
|
+
opfsError?: string;
|
|
44
|
+
/** Pool util, present only for an OPFS-backed handle. */
|
|
45
|
+
pool?: SqlitePoolHandle;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OpenDbOptions {
|
|
49
|
+
/** Total OPFS init attempts, including the first. Default 3. */
|
|
50
|
+
maxAttempts?: number;
|
|
51
|
+
/** Delay before each retry; the last entry repeats. Default [250, 500]. */
|
|
52
|
+
backoffMs?: number[];
|
|
53
|
+
/**
|
|
54
|
+
* Throw (`opfs-unavailable: <reason>`) instead of falling back to memory.
|
|
55
|
+
* Used by shared-tabs leader promotion: a silently-in-memory LEADER would
|
|
56
|
+
* put every tab's data in RAM, so promotion prefers failing the election
|
|
57
|
+
* (the broker retries, possibly on another tab) over degrading. The broker
|
|
58
|
+
* grants an explicit memory fallback only after repeated failed cycles.
|
|
59
|
+
*/
|
|
60
|
+
disallowMemoryFallback?: boolean;
|
|
61
|
+
/** Injectable for tests. */
|
|
62
|
+
sleep?: (ms: number) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
66
|
+
/** Bounded on purpose: this runs on the boot path, before the first query. */
|
|
67
|
+
const DEFAULT_BACKOFF_MS = [250, 500];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Leader-promotion profile (~5.8s worst case). A dead leader's sync access
|
|
71
|
+
* handles release when the browser garbage-collects its worker, typically
|
|
72
|
+
* well under a second but not synchronously with the Web Lock release the
|
|
73
|
+
* election observed, so promotion retries longer than a cold boot.
|
|
74
|
+
*/
|
|
75
|
+
export const PROMOTION_OPEN_OPTIONS: Pick<OpenDbOptions, 'maxAttempts' | 'backoffMs'> = {
|
|
76
|
+
maxAttempts: 10,
|
|
77
|
+
backoffMs: [50, 100, 200, 400, 800, 1000],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Failures no retry can fix: the APIs aren't there at all (insecure context,
|
|
81
|
+
* or a browser without sync access handles). Fall back immediately. */
|
|
82
|
+
const UNRETRYABLE = ['Missing required OPFS APIs'];
|
|
83
|
+
|
|
84
|
+
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
|
|
86
|
+
/** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
|
|
87
|
+
* locked by another tab): it is the most diagnostic part of the failure. */
|
|
88
|
+
function errMessage(e: unknown): string {
|
|
89
|
+
if (e instanceof Error) return e.name && e.name !== 'Error' ? `${e.name}: ${e.message}` : e.message;
|
|
90
|
+
return String(e);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fallbackToMemory(
|
|
94
|
+
sqlite3: any,
|
|
95
|
+
dbName: string,
|
|
96
|
+
reason: string,
|
|
97
|
+
attempts: number
|
|
98
|
+
): OpenDbResult {
|
|
99
|
+
const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? '' : 's'}` : '';
|
|
100
|
+
// Deliberately console, not the logger: host apps configure pino's level (some
|
|
101
|
+
// run `fatal`), and losing durability must never be filtered into silence.
|
|
102
|
+
// oxlint-disable-next-line no-console
|
|
103
|
+
console.error(
|
|
104
|
+
`[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite ` +
|
|
105
|
+
'cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local ' +
|
|
106
|
+
'write on reload. The usual cause is another tab of this app holding the storage lock, so ' +
|
|
107
|
+
'closing the other tabs and reloading restores persistence.'
|
|
108
|
+
);
|
|
109
|
+
return { db: new sqlite3.oo1.DB(':memory:', 'c'), persisted: false, opfsError: reason };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Open `dbName`'s handle. Never throws for a storage problem: a caller that
|
|
114
|
+
* asked for persistence and can't have it gets a working in-memory handle plus
|
|
115
|
+
* `persisted: false` and an `opfsError` to report.
|
|
116
|
+
*/
|
|
117
|
+
export async function openDb(
|
|
118
|
+
sqlite3: any,
|
|
119
|
+
dbName: string,
|
|
120
|
+
useOpfs: boolean,
|
|
121
|
+
opts: OpenDbOptions = {}
|
|
122
|
+
): Promise<OpenDbResult> {
|
|
123
|
+
// Memory was the configured choice (`store: 'memory'`), not a failure, so no
|
|
124
|
+
// error and no noise.
|
|
125
|
+
if (!useOpfs) return { db: new sqlite3.oo1.DB(':memory:', 'c'), persisted: false };
|
|
126
|
+
|
|
127
|
+
if (!sqlite3.installOpfsSAHPoolVfs) {
|
|
128
|
+
if (opts.disallowMemoryFallback) {
|
|
129
|
+
throw new Error('opfs-unavailable: sqlite-wasm build has no installOpfsSAHPoolVfs');
|
|
130
|
+
}
|
|
131
|
+
return fallbackToMemory(sqlite3, dbName, 'sqlite-wasm build has no installOpfsSAHPoolVfs', 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
135
|
+
const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
136
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
137
|
+
|
|
138
|
+
let lastError = 'unknown error';
|
|
139
|
+
let attempts = 0;
|
|
140
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
141
|
+
attempts = attempt;
|
|
142
|
+
try {
|
|
143
|
+
// `initialCapacity` stays at the sqlite-wasm default (6 files): one pool
|
|
144
|
+
// per bucket holds a single DB plus its journals, so preallocating more
|
|
145
|
+
// OPFS files would only be waste. A "SAH pool is full" error still
|
|
146
|
+
// reaches the caller verbatim via `opfsError`.
|
|
147
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({
|
|
148
|
+
name: `sp00ky-${dbName}`,
|
|
149
|
+
// The first failure is cached against the VFS name, so a retry that
|
|
150
|
+
// doesn't ask for a real re-init just replays the same rejection.
|
|
151
|
+
...(attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}),
|
|
152
|
+
});
|
|
153
|
+
return { db: new pool.OpfsSAHPoolDb(`/${dbName}.sqlite3`), persisted: true, pool };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
lastError = errMessage(e);
|
|
156
|
+
if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
|
|
157
|
+
await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (opts.disallowMemoryFallback) {
|
|
161
|
+
throw new Error(`opfs-unavailable: ${lastError} (after ${attempts} attempts)`);
|
|
162
|
+
}
|
|
163
|
+
return fallbackToMemory(sqlite3, dbName, lastError, attempts);
|
|
164
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import type { WhereNode } from '@spooky-sync/query-builder';
|
|
4
|
+
import {
|
|
5
|
+
comparisonSql,
|
|
6
|
+
renderWhereSql,
|
|
7
|
+
renderOrderSql,
|
|
8
|
+
scalar,
|
|
9
|
+
serializeRow,
|
|
10
|
+
serializeMergeRow,
|
|
11
|
+
reviveRow,
|
|
12
|
+
project,
|
|
13
|
+
} from './sqlite-plan-sql';
|
|
14
|
+
|
|
15
|
+
describe('comparisonSql param slaving (aa4af79b)', () => {
|
|
16
|
+
it('prefers params[paramRef] over the baked literal', () => {
|
|
17
|
+
const bind: unknown[] = [];
|
|
18
|
+
const sql = comparisonSql(
|
|
19
|
+
{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
|
|
20
|
+
bind,
|
|
21
|
+
{ id: 'thread:B' }
|
|
22
|
+
);
|
|
23
|
+
expect(sql).toBe('id = ?');
|
|
24
|
+
expect(bind).toEqual(['thread:B']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('falls back to the baked literal when the param key is absent', () => {
|
|
28
|
+
const bind: unknown[] = [];
|
|
29
|
+
comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
|
|
30
|
+
expect(bind).toEqual(['thread:A']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('a pure $-ref (no baked value) always reads the param', () => {
|
|
34
|
+
const bind: unknown[] = [];
|
|
35
|
+
comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
|
|
36
|
+
auth: 'user:1',
|
|
37
|
+
});
|
|
38
|
+
expect(bind).toEqual(['user:1']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('renders non-id fields via json_extract and honors swap', () => {
|
|
42
|
+
const bind: unknown[] = [];
|
|
43
|
+
const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
|
|
44
|
+
expect(sql).toBe(`? < json_extract(data, '$.votes')`);
|
|
45
|
+
expect(bind).toEqual([5]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('renderWhereSql', () => {
|
|
50
|
+
it('joins top-level nodes with AND and parenthesizes OR groups', () => {
|
|
51
|
+
const nodes: WhereNode[] = [
|
|
52
|
+
{ field: 'kind', op: '=', value: 'a' },
|
|
53
|
+
{
|
|
54
|
+
or: [
|
|
55
|
+
{ field: 'votes', op: '>', value: 1 },
|
|
56
|
+
{ field: 'votes', op: '=', value: 0 },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
const bind: unknown[] = [];
|
|
61
|
+
const sql = renderWhereSql(nodes, bind, {});
|
|
62
|
+
expect(sql).toBe(
|
|
63
|
+
`json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
|
|
64
|
+
);
|
|
65
|
+
expect(bind).toEqual(['a', 1, 0]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('renderOrderSql', () => {
|
|
70
|
+
it('renders multi-key ordering', () => {
|
|
71
|
+
expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
|
|
72
|
+
` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('scalar', () => {
|
|
78
|
+
it('binds RecordId-shaped objects as table:id strings', () => {
|
|
79
|
+
expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
|
|
80
|
+
expect(scalar('plain')).toBe('plain');
|
|
81
|
+
expect(scalar(3)).toBe(3);
|
|
82
|
+
expect(scalar(null)).toBeNull();
|
|
83
|
+
expect(scalar(undefined)).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('serializeRow / reviveRow round-trip', () => {
|
|
88
|
+
it('tags Uint8Array as {__u8} and revives it', () => {
|
|
89
|
+
const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
|
|
90
|
+
expect(json).toContain('"__u8"');
|
|
91
|
+
const back = reviveRow(json);
|
|
92
|
+
expect(back.blob).toBeInstanceOf(Uint8Array);
|
|
93
|
+
expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The surrealdb SDK decodes a `bytes` column as an ArrayBuffer, which
|
|
97
|
+
// JSON.stringify writes as `{}`: synced CRDT log rows (and presence states)
|
|
98
|
+
// landed with no data, so every other client's edit was refused as empty.
|
|
99
|
+
it('keeps the bytes of every binary shape: an SDK ArrayBuffer, a view into a larger buffer', () => {
|
|
100
|
+
const backing = new Uint8Array([9, 9, 1, 2, 250, 9]);
|
|
101
|
+
const json = serializeRow({
|
|
102
|
+
id: 'thread__crdt_log:1',
|
|
103
|
+
data: backing.slice(2, 5).buffer,
|
|
104
|
+
window: backing.subarray(2, 5),
|
|
105
|
+
dv: new DataView(backing.buffer, 2, 3),
|
|
106
|
+
nested: { state: [new Int8Array([1, 2, -6]).buffer] },
|
|
107
|
+
});
|
|
108
|
+
const back = reviveRow(json) as any;
|
|
109
|
+
for (const bytes of [back.data, back.window, back.dv, back.nested.state[0]]) {
|
|
110
|
+
expect(bytes).toBeInstanceOf(Uint8Array);
|
|
111
|
+
expect([...bytes]).toEqual([1, 2, 250]);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
|
|
116
|
+
const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
|
|
117
|
+
expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('stores an SDK RecordId link raw, not as its escaped toJSON', () => {
|
|
121
|
+
const json = serializeRow({ id: 'game:1', white: new RecordId('player_name', '3abc'), tags: [new RecordId('t', '9z')] });
|
|
122
|
+
expect(reviveRow(json)).toEqual({ id: 'game:1', white: 'player_name:3abc', tags: ['t:9z'] });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('project', () => {
|
|
127
|
+
it('keeps id plus the listed fields only, skipping absent ones', () => {
|
|
128
|
+
expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('serializeMergeRow', () => {
|
|
133
|
+
it('reuses one string for insert and patch when nothing is cleared', () => {
|
|
134
|
+
const row = { id: 't:1', a: 1, b: null };
|
|
135
|
+
const { insert, patch } = serializeMergeRow(row);
|
|
136
|
+
expect(insert).toBe(serializeRow(row));
|
|
137
|
+
expect(patch).toBe(insert);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// json_patch (RFC 7396) removes a member whose patch value is null; JSON drops
|
|
141
|
+
// an undefined one, which is how a cleared field used to survive the MERGE.
|
|
142
|
+
it('carries a top-level undefined as a null member in the patch only', () => {
|
|
143
|
+
const { insert, patch } = serializeMergeRow({ id: 't:1', a: 1, club: undefined, 'we"ird': undefined });
|
|
144
|
+
expect(JSON.parse(insert)).toStrictEqual({ id: 't:1', a: 1 });
|
|
145
|
+
expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', a: 1, club: null, 'we"ird': null });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles a row with nothing but cleared keys', () => {
|
|
149
|
+
const { insert, patch } = serializeMergeRow({ club: undefined });
|
|
150
|
+
expect(insert).toBe('{}');
|
|
151
|
+
expect(JSON.parse(patch)).toStrictEqual({ club: null });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('leaves nested undefined dropped (only top-level keys clear)', () => {
|
|
155
|
+
const { patch } = serializeMergeRow({ id: 't:1', meta: { x: undefined, y: 1 } });
|
|
156
|
+
expect(JSON.parse(patch)).toStrictEqual({ id: 't:1', meta: { y: 1 } });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does not append to a root the replacer collapsed to a scalar', () => {
|
|
160
|
+
const { insert, patch } = serializeMergeRow({ tb: 'user', id: 'u1', gone: undefined });
|
|
161
|
+
expect(insert).toBe('"user:u1"');
|
|
162
|
+
expect(patch).toBe(insert);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
|
|
2
|
+
import { recordIdKey, stableKey } from './relation-resolver';
|
|
3
|
+
import type { OrderBy, Row } from './cache-engine';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SQL rendering + value (de)serialization for the SQLite cache backend.
|
|
7
|
+
* Extracted from `sqlite-cache-engine.ts` so BOTH sides of the worker boundary
|
|
8
|
+
* can use it: the engine (main thread) for the legacy/shim paths, and
|
|
9
|
+
* `sqlite-worker.ts` for worker-side plan execution (`select`). Pure module —
|
|
10
|
+
* no DOM, no worker, no engine imports — mirroring `plan-render.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function renderOrderSql(orderBy: OrderBy): string {
|
|
14
|
+
return ` ORDER BY ${orderBy
|
|
15
|
+
.map(([f, d]) => `json_extract(data, '$.${f}') ${d === 'desc' ? 'DESC' : 'ASC'}`)
|
|
16
|
+
.join(', ')}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function comparisonSql(
|
|
20
|
+
c: WhereComparison,
|
|
21
|
+
bind: unknown[],
|
|
22
|
+
params: Record<string, unknown>
|
|
23
|
+
): string {
|
|
24
|
+
const lhs = c.field === 'id' ? 'id' : `json_extract(data, '$.${c.field}')`;
|
|
25
|
+
// Prefer the query's own param so a filter materializes from `params` (the
|
|
26
|
+
// query's identity), not a baked literal. A pure `$`-ref has no `value`; a
|
|
27
|
+
// slave-mode node keeps `value` as a fallback for a param absent from params.
|
|
28
|
+
const useParam =
|
|
29
|
+
c.paramRef !== undefined &&
|
|
30
|
+
(c.value === undefined || Object.prototype.hasOwnProperty.call(params, c.paramRef));
|
|
31
|
+
const value = useParam ? params[c.paramRef!] : c.value;
|
|
32
|
+
bind.push(scalar(value));
|
|
33
|
+
const op = c.op === '!=' ? '!=' : c.op;
|
|
34
|
+
return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderWhereSql(
|
|
38
|
+
nodes: WhereNode[],
|
|
39
|
+
bind: unknown[],
|
|
40
|
+
params: Record<string, unknown>
|
|
41
|
+
): string {
|
|
42
|
+
return nodes
|
|
43
|
+
.map((node) => {
|
|
44
|
+
if ('or' in node) {
|
|
45
|
+
return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(' OR ')})`;
|
|
46
|
+
}
|
|
47
|
+
return comparisonSql(node, bind, params);
|
|
48
|
+
})
|
|
49
|
+
.join(' AND ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ==================== value (de)serialization ====================
|
|
53
|
+
|
|
54
|
+
/** A comparable scalar for SQL binding: record links → `table:id`, everything
|
|
55
|
+
* else passed through (numbers/strings/bools). */
|
|
56
|
+
export function scalar(value: unknown): unknown {
|
|
57
|
+
if (value == null) return null;
|
|
58
|
+
if (typeof value === 'object') return stableKey(value);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function serializeRow(row: Row): string {
|
|
63
|
+
return JSON.stringify(row, function (this: Record<string, unknown>, k, v) {
|
|
64
|
+
// `v` has already been through `toJSON()`, and a RecordId's is escaped
|
|
65
|
+
// SurrealQL (`game:⟨476g…⟩`), so key the original value instead.
|
|
66
|
+
const raw = this[k];
|
|
67
|
+
if (raw && typeof raw === 'object') {
|
|
68
|
+
const bytes = bytesOf(raw);
|
|
69
|
+
if (bytes) return { __u8: toBase64(bytes) };
|
|
70
|
+
const key = recordIdKey(raw);
|
|
71
|
+
if (key !== null) return key;
|
|
72
|
+
}
|
|
73
|
+
return v;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The bytes of a binary value, whatever shape it arrived in. Rows from the
|
|
79
|
+
* server carry a `bytes` column as an `ArrayBuffer` (the surrealdb SDK's CBOR
|
|
80
|
+
* decode), not a `Uint8Array`, and `JSON.stringify` turns an `ArrayBuffer`
|
|
81
|
+
* into `{}`: every synced CRDT log row and presence state was stored empty.
|
|
82
|
+
*/
|
|
83
|
+
function bytesOf(value: object): Uint8Array | null {
|
|
84
|
+
if (value instanceof Uint8Array) return value;
|
|
85
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
86
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Serialize a MERGE row ONCE for both halves of the merge upsert: the row a
|
|
92
|
+
* fresh INSERT stores and the RFC 7396 delta `json_patch` applies on conflict.
|
|
93
|
+
*
|
|
94
|
+
* A top-level own key holding `undefined` means "clear this field", which is
|
|
95
|
+
* what SurrealDB's MERGE does with it (the SDK sends it as NONE). JSON drops
|
|
96
|
+
* such a key, so a patch built by {@link serializeRow} alone never mentioned
|
|
97
|
+
* it and the stored value survived every merge: a field cleared on the server
|
|
98
|
+
* stayed set locally. The patch therefore carries each one as a `null` member,
|
|
99
|
+
* which `json_patch` removes; the insert row simply lacks it, exactly as
|
|
100
|
+
* before. Appended to the already-serialized text, so the common case costs no
|
|
101
|
+
* second stringify. Nested `undefined` stays dropped (never a clear here).
|
|
102
|
+
*/
|
|
103
|
+
export function serializeMergeRow(row: Row): { insert: string; patch: string } {
|
|
104
|
+
const insert = serializeRow(row);
|
|
105
|
+
const cleared: string[] = [];
|
|
106
|
+
for (const key of Object.keys(row)) {
|
|
107
|
+
if (row[key] === undefined) cleared.push(`${JSON.stringify(key)}:null`);
|
|
108
|
+
}
|
|
109
|
+
// Not an object text (the replacer collapsed the root): nothing to append to.
|
|
110
|
+
if (cleared.length === 0 || insert[0] !== '{') return { insert, patch: insert };
|
|
111
|
+
const members = cleared.join(',');
|
|
112
|
+
const patch = insert === '{}' ? `{${members}}` : `${insert.slice(0, -1)},${members}}`;
|
|
113
|
+
return { insert, patch };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function reviveRow(json: string): Row {
|
|
117
|
+
// Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
|
|
118
|
+
// `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
|
|
119
|
+
// invoking a JS callback for every key of every row on the read hot path.
|
|
120
|
+
if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
|
|
121
|
+
return JSON.parse(json, (_k, v) => {
|
|
122
|
+
if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
|
|
123
|
+
return fromBase64((v as any).__u8);
|
|
124
|
+
}
|
|
125
|
+
return v;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
131
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
132
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
133
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
134
|
+
*
|
|
135
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
136
|
+
* what both paths did before, and verified so against real SQLite:
|
|
137
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
138
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
139
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
140
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
141
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
142
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
143
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
144
|
+
*
|
|
145
|
+
* The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
|
|
146
|
+
* has no boolean type, so `json_each` hands `true`/`false` out as the integers
|
|
147
|
+
* 1/0 and `json_group_object` writes integers back. A projected row therefore
|
|
148
|
+
* read `published: 1` where the same row read whole read `published: true`,
|
|
149
|
+
* and every `=== true` in an app turned false the moment a screen painted from
|
|
150
|
+
* the local cache. (A boolean NESTED in an object or array survives: those come
|
|
151
|
+
* back as whole JSON documents, untouched.) `je.type` still knows what it was,
|
|
152
|
+
* so the two boolean types are rebuilt as JSON literals on the way out.
|
|
153
|
+
*
|
|
154
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
155
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
156
|
+
*
|
|
157
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
158
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
159
|
+
*/
|
|
160
|
+
export function projectedDataSql(fields: string[], bind: unknown[]): string {
|
|
161
|
+
const keys = ['id', ...fields];
|
|
162
|
+
for (const k of keys) bind.push(k);
|
|
163
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
164
|
+
// `json('true')` / `json('false')` return JSON-subtyped values, so
|
|
165
|
+
// `json_group_object` embeds them as the literals `true` / `false` rather
|
|
166
|
+
// than as quoted text.
|
|
167
|
+
const value =
|
|
168
|
+
`CASE je.type WHEN 'true' THEN json('true') ` +
|
|
169
|
+
`WHEN 'false' THEN json('false') ELSE je.value END`;
|
|
170
|
+
return (
|
|
171
|
+
`COALESCE((SELECT json_group_object(je.key, ${value}) ` +
|
|
172
|
+
`FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function project(row: Row, fields: string[]): Row {
|
|
177
|
+
const out: Row = {};
|
|
178
|
+
for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
183
|
+
let bin = '';
|
|
184
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
185
|
+
return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function fromBase64(b64: string): Uint8Array {
|
|
189
|
+
if (typeof atob !== 'undefined') {
|
|
190
|
+
const bin = atob(b64);
|
|
191
|
+
const out = new Uint8Array(bin.length);
|
|
192
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
196
|
+
}
|