@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
|
@@ -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,104 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { WhereNode } from '@spooky-sync/query-builder';
|
|
3
|
+
import {
|
|
4
|
+
comparisonSql,
|
|
5
|
+
renderWhereSql,
|
|
6
|
+
renderOrderSql,
|
|
7
|
+
scalar,
|
|
8
|
+
serializeRow,
|
|
9
|
+
reviveRow,
|
|
10
|
+
project,
|
|
11
|
+
} from './sqlite-plan-sql';
|
|
12
|
+
|
|
13
|
+
describe('comparisonSql param slaving (aa4af79b)', () => {
|
|
14
|
+
it('prefers params[paramRef] over the baked literal', () => {
|
|
15
|
+
const bind: unknown[] = [];
|
|
16
|
+
const sql = comparisonSql(
|
|
17
|
+
{ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' },
|
|
18
|
+
bind,
|
|
19
|
+
{ id: 'thread:B' }
|
|
20
|
+
);
|
|
21
|
+
expect(sql).toBe('id = ?');
|
|
22
|
+
expect(bind).toEqual(['thread:B']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to the baked literal when the param key is absent', () => {
|
|
26
|
+
const bind: unknown[] = [];
|
|
27
|
+
comparisonSql({ field: 'id', op: '=', value: 'thread:A', paramRef: 'id' }, bind, {});
|
|
28
|
+
expect(bind).toEqual(['thread:A']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('a pure $-ref (no baked value) always reads the param', () => {
|
|
32
|
+
const bind: unknown[] = [];
|
|
33
|
+
comparisonSql({ field: 'owner', op: '=', value: undefined, paramRef: 'auth' }, bind, {
|
|
34
|
+
auth: 'user:1',
|
|
35
|
+
});
|
|
36
|
+
expect(bind).toEqual(['user:1']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders non-id fields via json_extract and honors swap', () => {
|
|
40
|
+
const bind: unknown[] = [];
|
|
41
|
+
const sql = comparisonSql({ field: 'votes', op: '<', value: 5, swap: true }, bind, {});
|
|
42
|
+
expect(sql).toBe(`? < json_extract(data, '$.votes')`);
|
|
43
|
+
expect(bind).toEqual([5]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('renderWhereSql', () => {
|
|
48
|
+
it('joins top-level nodes with AND and parenthesizes OR groups', () => {
|
|
49
|
+
const nodes: WhereNode[] = [
|
|
50
|
+
{ field: 'kind', op: '=', value: 'a' },
|
|
51
|
+
{
|
|
52
|
+
or: [
|
|
53
|
+
{ field: 'votes', op: '>', value: 1 },
|
|
54
|
+
{ field: 'votes', op: '=', value: 0 },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const bind: unknown[] = [];
|
|
59
|
+
const sql = renderWhereSql(nodes, bind, {});
|
|
60
|
+
expect(sql).toBe(
|
|
61
|
+
`json_extract(data, '$.kind') = ? AND (json_extract(data, '$.votes') > ? OR json_extract(data, '$.votes') = ?)`
|
|
62
|
+
);
|
|
63
|
+
expect(bind).toEqual(['a', 1, 0]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('renderOrderSql', () => {
|
|
68
|
+
it('renders multi-key ordering', () => {
|
|
69
|
+
expect(renderOrderSql([['a', 'desc'], ['b', 'asc']])).toBe(
|
|
70
|
+
` ORDER BY json_extract(data, '$.a') DESC, json_extract(data, '$.b') ASC`
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('scalar', () => {
|
|
76
|
+
it('binds RecordId-shaped objects as table:id strings', () => {
|
|
77
|
+
expect(scalar({ tb: 'user', id: '1' })).toBe('user:1');
|
|
78
|
+
expect(scalar('plain')).toBe('plain');
|
|
79
|
+
expect(scalar(3)).toBe(3);
|
|
80
|
+
expect(scalar(null)).toBeNull();
|
|
81
|
+
expect(scalar(undefined)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('serializeRow / reviveRow round-trip', () => {
|
|
86
|
+
it('tags Uint8Array as {__u8} and revives it', () => {
|
|
87
|
+
const json = serializeRow({ id: 's:1', blob: new Uint8Array([0, 128, 255]) });
|
|
88
|
+
expect(json).toContain('"__u8"');
|
|
89
|
+
const back = reviveRow(json);
|
|
90
|
+
expect(back.blob).toBeInstanceOf(Uint8Array);
|
|
91
|
+
expect([...(back.blob as Uint8Array)]).toEqual([0, 128, 255]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('serializes RecordId-shaped links to strings and takes the fast parse path otherwise', () => {
|
|
95
|
+
const json = serializeRow({ id: 't:1', author: { tb: 'user', id: 'u1' }, n: 2 });
|
|
96
|
+
expect(reviveRow(json)).toEqual({ id: 't:1', author: 'user:u1', n: 2 });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('project', () => {
|
|
101
|
+
it('keeps id plus the listed fields only, skipping absent ones', () => {
|
|
102
|
+
expect(project({ id: 'a', x: 1, y: 2 }, ['x', 'missing'])).toEqual({ id: 'a', x: 1 });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { WhereComparison, WhereNode } from '@spooky-sync/query-builder';
|
|
2
|
+
import { 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, (_k, v) => {
|
|
64
|
+
if (v instanceof Uint8Array) return { __u8: toBase64(v) };
|
|
65
|
+
if (v && typeof v === 'object') {
|
|
66
|
+
const rid = v as { tb?: unknown; id?: unknown };
|
|
67
|
+
if (rid.tb !== undefined && rid.id !== undefined) return stableKey(v);
|
|
68
|
+
}
|
|
69
|
+
return v;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function reviveRow(json: string): Row {
|
|
74
|
+
// Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
|
|
75
|
+
// `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
|
|
76
|
+
// invoking a JS callback for every key of every row on the read hot path.
|
|
77
|
+
if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
|
|
78
|
+
return JSON.parse(json, (_k, v) => {
|
|
79
|
+
if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
|
|
80
|
+
return fromBase64((v as any).__u8);
|
|
81
|
+
}
|
|
82
|
+
return v;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SELECT-clause expression yielding the row's `data` narrowed to `fields` (plus
|
|
88
|
+
* `id`), so SQLite never returns — and neither side ever parses — the fields the
|
|
89
|
+
* caller did not ask for. On a game list that is every row's `pgn`, which was
|
|
90
|
+
* being read, parsed and thrown away 50 rows at a time for eight rendered fields.
|
|
91
|
+
*
|
|
92
|
+
* Byte-identical to running {@link project} over the fully-parsed row, which is
|
|
93
|
+
* what both paths did before, and verified so against real SQLite:
|
|
94
|
+
* `json_each` walks the keys the row ACTUALLY has, so an ABSENT key stays absent
|
|
95
|
+
* rather than becoming an explicit null — the difference `json_object`/
|
|
96
|
+
* `json_extract` would have introduced. A stored null, nested objects and
|
|
97
|
+
* arrays, and `{__u8}` blob tags all round-trip unchanged, because
|
|
98
|
+
* `json_group_object` understands `json_each`'s `value` column as JSON rather
|
|
99
|
+
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
100
|
+
* where the subquery yields NULL and `reviveRow` would throw.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
103
|
+
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
104
|
+
*
|
|
105
|
+
* Binds one parameter per key, pushed onto `bind` — these land in the SELECT
|
|
106
|
+
* clause, so they must be bound BEFORE any WHERE parameters.
|
|
107
|
+
*/
|
|
108
|
+
export function projectedDataSql(fields: string[], bind: unknown[]): string {
|
|
109
|
+
const keys = ['id', ...fields];
|
|
110
|
+
for (const k of keys) bind.push(k);
|
|
111
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
112
|
+
return (
|
|
113
|
+
`COALESCE((SELECT json_group_object(je.key, je.value) ` +
|
|
114
|
+
`FROM json_each(data) je WHERE je.key IN (${placeholders})), '{}') AS data`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function project(row: Row, fields: string[]): Row {
|
|
119
|
+
const out: Row = {};
|
|
120
|
+
for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
125
|
+
let bin = '';
|
|
126
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
127
|
+
return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function fromBase64(b64: string): Uint8Array {
|
|
131
|
+
if (typeof atob !== 'undefined') {
|
|
132
|
+
const bin = atob(b64);
|
|
133
|
+
const out = new Uint8Array(bin.length);
|
|
134
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
138
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { projectedDataSql, project, reviveRow, serializeRow } from './sqlite-plan-sql';
|
|
4
|
+
import type { Row } from './cache-engine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `projectedDataSql` is the one change in this area that alters what SQLite
|
|
8
|
+
* actually returns, and every row read goes through it — so it is verified
|
|
9
|
+
* against a REAL SQLite, not the scripted responder the parity tests use.
|
|
10
|
+
*
|
|
11
|
+
* The contract: byte-identical to parsing the whole row and running `project`
|
|
12
|
+
* over it, which is what both engine paths did before. The traps it has to
|
|
13
|
+
* clear are an ABSENT key (must stay absent, not become an explicit null — the
|
|
14
|
+
* difference `json_object`/`json_extract` would have introduced), a STORED
|
|
15
|
+
* null, nested objects/arrays, and the `{__u8}` tag that carries binary.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function makeDb(rows: Row[]) {
|
|
19
|
+
const db = new DatabaseSync(':memory:');
|
|
20
|
+
db.exec('CREATE TABLE "game" (id TEXT PRIMARY KEY, data TEXT NOT NULL)');
|
|
21
|
+
const ins = db.prepare('INSERT INTO "game" (id,data) VALUES (?,?)');
|
|
22
|
+
for (const r of rows) ins.run(String(r.id), serializeRow(r));
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the code did before: read the whole row, parse it, then project. */
|
|
27
|
+
function referenceRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
28
|
+
const ph = ids.map(() => '?').join(', ');
|
|
29
|
+
return db
|
|
30
|
+
.prepare(`SELECT data FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
31
|
+
.all(...ids)
|
|
32
|
+
.map((r) => project(reviveRow((r as { data: string }).data), fields));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the code does now: let SQLite narrow the row before it is ever parsed. */
|
|
36
|
+
function projectedRows(db: DatabaseSync, ids: string[], fields: string[]): Row[] {
|
|
37
|
+
const bind: unknown[] = [];
|
|
38
|
+
const col = projectedDataSql(fields, bind);
|
|
39
|
+
bind.push(...ids);
|
|
40
|
+
const ph = ids.map(() => '?').join(', ');
|
|
41
|
+
return db
|
|
42
|
+
.prepare(`SELECT ${col} FROM "game" WHERE id IN (${ph}) ORDER BY id ASC`)
|
|
43
|
+
.all(...(bind as string[]))
|
|
44
|
+
.map((r) => reviveRow((r as { data: string }).data));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FIXTURE: Row[] = [
|
|
48
|
+
{ id: 'game:1', white: 'player_name:PN_a', pgn: '[Event "x"]\n1. e4 e5', sort_index: -5 },
|
|
49
|
+
{ id: 'game:2', white: null, pgn: 'heavy', sort_index: -3 }, // STORED null
|
|
50
|
+
{ id: 'game:3', pgn: 'heavy', sort_index: -4 }, // 'white' ABSENT
|
|
51
|
+
{ id: 'game:4', white: 'w', pgn: 'heavy', sort_index: -1, meta: { a: 1, b: [2, 3] } },
|
|
52
|
+
];
|
|
53
|
+
const IDS = ['game:1', 'game:2', 'game:3', 'game:4'];
|
|
54
|
+
const FIELDS = ['white', 'sort_index', 'meta'];
|
|
55
|
+
|
|
56
|
+
describe('projectedDataSql against real SQLite', () => {
|
|
57
|
+
it('is byte-identical to parse-then-project', () => {
|
|
58
|
+
const db = makeDb(FIXTURE);
|
|
59
|
+
expect(projectedRows(db, IDS, FIELDS)).toEqual(referenceRows(db, IDS, FIELDS));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never returns the fields that were not asked for', () => {
|
|
63
|
+
// The point of the change: `pgn` must not leave SQLite, so it is never
|
|
64
|
+
// parsed, never crosses the worker boundary, never reaches the store.
|
|
65
|
+
const db = makeDb(FIXTURE);
|
|
66
|
+
for (const row of projectedRows(db, IDS, FIELDS)) expect('pgn' in row).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('keeps an absent key absent and a stored null null', () => {
|
|
70
|
+
const db = makeDb(FIXTURE);
|
|
71
|
+
const byId = new Map(projectedRows(db, IDS, FIELDS).map((r) => [r.id, r]));
|
|
72
|
+
expect('white' in byId.get('game:3')!).toBe(false); // absent stays absent
|
|
73
|
+
expect('white' in byId.get('game:2')!).toBe(true); // stored null is a real key
|
|
74
|
+
expect(byId.get('game:2')!.white).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('round-trips nested objects and arrays as JSON, not as strings', () => {
|
|
78
|
+
const db = makeDb(FIXTURE);
|
|
79
|
+
const row = projectedRows(db, IDS, FIELDS).find((r) => r.id === 'game:4')!;
|
|
80
|
+
expect(row.meta).toEqual({ a: 1, b: [2, 3] });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('round-trips binary through the {__u8} tag', () => {
|
|
84
|
+
const db = makeDb([{ id: 'game:9', blob: new Uint8Array([0, 1, 2]), pgn: 'heavy' }]);
|
|
85
|
+
const [row] = projectedRows(db, ['game:9'], ['blob']);
|
|
86
|
+
expect(row!.blob).toEqual(new Uint8Array([0, 1, 2]));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('always includes id, even when it is not in the field list', () => {
|
|
90
|
+
const db = makeDb(FIXTURE);
|
|
91
|
+
for (const row of projectedRows(db, IDS, ['sort_index'])) expect(row.id).toBeTruthy();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('yields an empty object, not NULL, for a row sharing none of the keys', () => {
|
|
95
|
+
// Without COALESCE the subquery returns NULL here and reviveRow throws.
|
|
96
|
+
const db = makeDb([{ id: 'game:8', pgn: 'heavy' }]);
|
|
97
|
+
expect(projectedRows(db, ['game:8'], ['nothing_matches'])).toEqual([{ id: 'game:8' }]);
|
|
98
|
+
});
|
|
99
|
+
});
|