@spooky-sync/core 0.0.1-canary.15 → 0.0.1-canary.151
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 +56 -0
- package/dist/index.d.ts +1192 -372
- package/dist/index.js +5772 -1270
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/sqlite-plan-sql.js +194 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +202 -0
- package/dist/types.d.ts +755 -0
- package/package.json +40 -8
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -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 +16 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +59 -20
- package/src/modules/cache/index.ts +77 -32
- 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 +206 -0
- package/src/modules/crdt/index.ts +361 -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.rebind.test.ts +147 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/index.ts +1134 -128
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +154 -0
- package/src/modules/devtools/index.ts +191 -30
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/feature-flag/index.test.ts +120 -0
- package/src/modules/feature-flag/index.ts +209 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +101 -37
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.ts +12 -5
- package/src/modules/sync/queue/queue-up.ts +29 -14
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.ts +73 -7
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.ts +1017 -62
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +182 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/cache-engine.ts +143 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/engine-factory.ts +32 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +6 -0
- package/src/services/database/local-migrator.ts +28 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +478 -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 +13 -13
- package/src/services/database/sqlite-cache-engine.test.ts +215 -0
- package/src/services/database/sqlite-cache-engine.ts +829 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +106 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +279 -0
- package/src/services/database/sqlite-select.ts +113 -0
- package/src/services/database/sqlite-worker.ts +160 -0
- package/src/services/database/surql-translate.ts +291 -0
- package/src/services/database/surreal-cache-engine.ts +139 -0
- package/src/services/logger/index.ts +6 -101
- 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 +295 -38
- 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 +136 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.ts +1000 -0
- package/src/types.ts +267 -15
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -0
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +77 -1
- package/src/spooky.ts +0 -395
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
import { applyPatch, type Operation } from 'fast-json-patch';
|
|
2
|
+
import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-builder';
|
|
3
|
+
import {
|
|
4
|
+
renderOrderSql,
|
|
5
|
+
renderWhereSql,
|
|
6
|
+
reviveRow,
|
|
7
|
+
serializeRow,
|
|
8
|
+
project,
|
|
9
|
+
} from './sqlite-plan-sql';
|
|
10
|
+
import type { Logger } from '../logger/index';
|
|
11
|
+
import type { Sp00kyConfig } from '../../types';
|
|
12
|
+
import type { SealedQuery } from '../../utils/surql';
|
|
13
|
+
import { resolveRelations, stableKey } from './relation-resolver';
|
|
14
|
+
import {
|
|
15
|
+
createDatabaseEventSystem,
|
|
16
|
+
DatabaseEventTypes,
|
|
17
|
+
type DatabaseEventSystem,
|
|
18
|
+
} from './events/index';
|
|
19
|
+
import { StaleEpochError } from './local';
|
|
20
|
+
import { translateSurql, tableOf, setPath, getPath, type SqlOp } from './surql-translate';
|
|
21
|
+
import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The statement result a pure-write op contributes to a query's results array.
|
|
25
|
+
* Single source of truth shared by the per-op path (`execOp`) and the batched
|
|
26
|
+
* fast path in `query()`, so the two can never diverge: a caller that reads a
|
|
27
|
+
* statement's output sees the same shape whether or not the transaction took the
|
|
28
|
+
* batch fast path. In particular `create()` compiles to an all-upsert tx
|
|
29
|
+
* (`createSet` + `createMutation`) and reads `resultIndex:0` for the new row and
|
|
30
|
+
* its id — the fast path previously returned empty arrays there, so the row (and
|
|
31
|
+
* its id) was lost and the reconcile crashed in `encodeRecordId`.
|
|
32
|
+
*
|
|
33
|
+
* An upsert echoes the written row (`{...data, id}`) with no read-back — the
|
|
34
|
+
* full merged row is only materialized for a LET-wrapped upsert (see the 'let'
|
|
35
|
+
* case). delete/deleteAll yield `[]`; noop yields `null`.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The `_00_*` internal tables the client relies on. The LocalMigrator DEFINEs
|
|
39
|
+
* them, but every DEFINE lowers to a noop on the SQLite engine, so they must be
|
|
40
|
+
* created physically at open (see `openInternal`) or a read-before-first-write
|
|
41
|
+
* on a fresh bucket throws "no such table". Keep in sync with the systemSchema
|
|
42
|
+
* block in `local-migrator.ts`.
|
|
43
|
+
*/
|
|
44
|
+
const SYSTEM_TABLES = [
|
|
45
|
+
'_00_stream_processor_state',
|
|
46
|
+
'_00_query',
|
|
47
|
+
'_00_preload',
|
|
48
|
+
'_00_schema',
|
|
49
|
+
'_00_pending_mutations',
|
|
50
|
+
// Server-written, synced-down meta tables (see meta_tables_client.surql).
|
|
51
|
+
// DEFINE is a noop on this engine, so without seeding them here their synced
|
|
52
|
+
// rows have no local table to land in: feature flags silently fall back to
|
|
53
|
+
// defaults and app-release update notifications never show.
|
|
54
|
+
'_00_user_feature',
|
|
55
|
+
'_00_app_release',
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
export function pureWriteOpResult(op: SqlOp): unknown {
|
|
59
|
+
switch (op.kind) {
|
|
60
|
+
case 'upsert':
|
|
61
|
+
return { ...op.data, id: stableKey(op.id) };
|
|
62
|
+
case 'noop':
|
|
63
|
+
return null;
|
|
64
|
+
default:
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Local cache backend on official SQLite-WASM in a dedicated Worker (see
|
|
71
|
+
* `sqlite-worker.ts`), with OPFS SAHPool persistence. Storage model: one table
|
|
72
|
+
* per schema table, `id TEXT PRIMARY KEY, data TEXT` where `data` is the row as
|
|
73
|
+
* JSON. Filtering/ordering use `json_extract`. Relations are decomposed by the
|
|
74
|
+
* shared {@link resolveRelations} — identical to the SurrealDB backend.
|
|
75
|
+
*
|
|
76
|
+
* Value normalization (JSON round-trip):
|
|
77
|
+
* - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
|
|
78
|
+
* - Record links / ids → their `table:id` string form (so `json_extract`
|
|
79
|
+
* comparisons and `IN` matching are consistent). NOTE: link fields therefore
|
|
80
|
+
* read back as strings, not `RecordId` instances — the one shape difference
|
|
81
|
+
* from the SurrealDB backend, to be closed with schema-driven revival + an
|
|
82
|
+
* oracle E2E in the browser.
|
|
83
|
+
*/
|
|
84
|
+
export class SqliteCacheEngine implements LocalStore {
|
|
85
|
+
private worker: Worker | null = null;
|
|
86
|
+
private seq = 0;
|
|
87
|
+
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
|
88
|
+
private storeEpoch = 0;
|
|
89
|
+
private knownTables = new Set<string>();
|
|
90
|
+
private useOpfs: boolean;
|
|
91
|
+
/** Whether `select` runs as one worker round-trip (plan executed in-worker).
|
|
92
|
+
* Flipped off at runtime if the worker script predates the `select` op
|
|
93
|
+
* (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
|
|
94
|
+
private workerSelect: boolean;
|
|
95
|
+
private events: DatabaseEventSystem = createDatabaseEventSystem();
|
|
96
|
+
private bucketId = 'anon';
|
|
97
|
+
/** Schemaless — tables are created lazily on first write; no migrator. */
|
|
98
|
+
readonly usesSurqlSchema = false;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private config: Sp00kyConfig<any>['database'],
|
|
102
|
+
private logger: Logger,
|
|
103
|
+
opts: { useOpfs?: boolean; workerSelect?: boolean } = {}
|
|
104
|
+
) {
|
|
105
|
+
this.useOpfs = opts.useOpfs ?? true;
|
|
106
|
+
this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get epoch(): number {
|
|
110
|
+
return this.storeEpoch;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get currentBucketId(): string {
|
|
114
|
+
return this.bucketId;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getConfig(): Sp00kyConfig<any>['database'] {
|
|
118
|
+
return this.config;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getEvents(): DatabaseEventSystem {
|
|
122
|
+
return this.events;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getClient(): unknown {
|
|
126
|
+
throw new Error('SqliteCacheEngine has no SurrealDB client (getClient is unavailable).');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
|
|
130
|
+
switchStore(bucketId: string): Promise<void> {
|
|
131
|
+
return this.switchBucket(bucketId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** SQLite has no switch gate window; the epoch bump alone fences stale writes. */
|
|
135
|
+
beginSwitch(): () => void {
|
|
136
|
+
return () => {};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- worker plumbing -----------------------------------------------------
|
|
140
|
+
|
|
141
|
+
private spawnWorker(): Worker {
|
|
142
|
+
// Source references the `.ts` so the monorepo's src-bundling consumers
|
|
143
|
+
// (e.g. the example app, which aliases `@spooky-sync/core` to `src`) resolve
|
|
144
|
+
// it — Vite handles `.ts` workers. For the published package, the tsdown
|
|
145
|
+
// build rewrites this to `./sqlite-worker.js` (the top-level emitted entry;
|
|
146
|
+
// see tsdown.config.ts), which the flat `dist/index.js` resolves. The worker
|
|
147
|
+
// (+ `@sqlite.org/sqlite-wasm`) still loads lazily — only when `localEngine:
|
|
148
|
+
// 'sqlite'` is used.
|
|
149
|
+
const worker = new Worker(new URL('./sqlite-worker.ts', import.meta.url), { type: 'module' });
|
|
150
|
+
worker.onmessage = (ev: MessageEvent) => {
|
|
151
|
+
const { id, ok, error, ...rest } = ev.data ?? {};
|
|
152
|
+
const p = this.pending.get(id);
|
|
153
|
+
if (!p) return;
|
|
154
|
+
this.pending.delete(id);
|
|
155
|
+
if (ok) p.resolve(rest);
|
|
156
|
+
else p.reject(new Error(error));
|
|
157
|
+
};
|
|
158
|
+
// Surface a worker crash (wasm abort / OOM) instead of leaving every pending
|
|
159
|
+
// call hung forever — reject them all with a clear error.
|
|
160
|
+
const failAll = (msg: string) => {
|
|
161
|
+
const err = new Error(`SQLite worker crashed: ${msg}`);
|
|
162
|
+
this.logger.error(
|
|
163
|
+
{ err, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
|
|
164
|
+
'Worker error'
|
|
165
|
+
);
|
|
166
|
+
for (const [, p] of this.pending) p.reject(err);
|
|
167
|
+
this.pending.clear();
|
|
168
|
+
};
|
|
169
|
+
worker.onerror = (e: ErrorEvent) => failAll(e.message || 'onerror');
|
|
170
|
+
worker.onmessageerror = () => failAll('messageerror');
|
|
171
|
+
return worker;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Serializes every worker op so reads/writes never overlap at the VFS layer
|
|
175
|
+
* (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
|
|
176
|
+
* single-flight query queue. */
|
|
177
|
+
private opQueue: Promise<unknown> = Promise.resolve();
|
|
178
|
+
|
|
179
|
+
private call<T = any>(type: string, payload?: unknown): Promise<T> {
|
|
180
|
+
const enqueuedAt = performance.now();
|
|
181
|
+
const run = () => {
|
|
182
|
+
// Time spent waiting behind other ops in the queue, not doing work.
|
|
183
|
+
getStats().queueWaitMs += performance.now() - enqueuedAt;
|
|
184
|
+
return this.rawCall<T>(type, payload);
|
|
185
|
+
};
|
|
186
|
+
const result = this.opQueue.then(run, run);
|
|
187
|
+
// Keep the chain alive regardless of individual failures.
|
|
188
|
+
this.opQueue = result.then(
|
|
189
|
+
() => undefined,
|
|
190
|
+
() => undefined
|
|
191
|
+
);
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
|
|
196
|
+
if (!this.worker) throw new Error('SqliteCacheEngine: not connected');
|
|
197
|
+
const id = ++this.seq;
|
|
198
|
+
// --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
|
|
199
|
+
const s = getStats();
|
|
200
|
+
s.roundTrips++;
|
|
201
|
+
s.byType[type] = (s.byType[type] ?? 0) + 1;
|
|
202
|
+
if (type === 'batch' && Array.isArray(payload)) {
|
|
203
|
+
s.batchStatements += payload.length;
|
|
204
|
+
s.maxBatch = Math.max(s.maxBatch, payload.length);
|
|
205
|
+
}
|
|
206
|
+
s.inFlight++;
|
|
207
|
+
s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
|
|
208
|
+
const done = () => {
|
|
209
|
+
s.inFlight--;
|
|
210
|
+
};
|
|
211
|
+
const sentAt = performance.now();
|
|
212
|
+
return new Promise<T>((resolve, reject) => {
|
|
213
|
+
this.pending.set(id, {
|
|
214
|
+
resolve: (v: T) => {
|
|
215
|
+
done();
|
|
216
|
+
// Split the round-trip: `wt` is time inside the worker's handler,
|
|
217
|
+
// the remainder is postMessage + scheduling overhead.
|
|
218
|
+
const wt = (v as { wt?: unknown } | null)?.wt;
|
|
219
|
+
if (typeof wt === 'number') {
|
|
220
|
+
s.workerMs += wt;
|
|
221
|
+
s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
|
|
222
|
+
}
|
|
223
|
+
resolve(v);
|
|
224
|
+
},
|
|
225
|
+
reject: (e: unknown) => {
|
|
226
|
+
done();
|
|
227
|
+
reject(e);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
this.worker!.postMessage({ id, type, payload });
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
|
|
236
|
+
* {@link call}) so it can run as the body of an already-queued opQueue entry
|
|
237
|
+
* without re-queuing onto itself. Callers must run it through the opQueue.
|
|
238
|
+
*/
|
|
239
|
+
private async openInternal(bucketId: string): Promise<void> {
|
|
240
|
+
this.worker = this.spawnWorker();
|
|
241
|
+
// Seed the `_00_*` system tables as part of `open` (worker-side, one round
|
|
242
|
+
// trip). The LocalMigrator DEFINEs them, but `translateSurql` lowers every
|
|
243
|
+
// DEFINE to a noop on this engine (SQLite has no DDL vocabulary), so
|
|
244
|
+
// provisioning never actually creates them — they were only made lazily on
|
|
245
|
+
// first WRITE. A fresh bucket (e.g. right after signup) that READS one first
|
|
246
|
+
// (the sync layer selects `_00_query` before any row lands) hit
|
|
247
|
+
// "no such table: _00_query" and the client wedged on "Loading database".
|
|
248
|
+
// Creating them inside `open` guarantees any access order is safe without
|
|
249
|
+
// adding ops to the engine's queue.
|
|
250
|
+
const { persisted } = await this.rawCall<{ persisted: boolean }>('open', {
|
|
251
|
+
dbName: bucketId,
|
|
252
|
+
useOpfs: this.useOpfs,
|
|
253
|
+
systemTables: SYSTEM_TABLES,
|
|
254
|
+
});
|
|
255
|
+
this.knownTables.clear();
|
|
256
|
+
for (const t of SYSTEM_TABLES) this.knownTables.add(t);
|
|
257
|
+
this.bucketId = bucketId;
|
|
258
|
+
this.logger.info(
|
|
259
|
+
{ bucketId, persisted, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
|
|
260
|
+
persisted ? 'SQLite OPFS store opened' : 'SQLite in-memory store opened (no OPFS)'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
|
|
265
|
+
* chaining) so it can't interleave with reads/writes at the worker. */
|
|
266
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
267
|
+
const result = this.opQueue.then(fn, fn);
|
|
268
|
+
this.opQueue = result.then(
|
|
269
|
+
() => undefined,
|
|
270
|
+
() => undefined
|
|
271
|
+
);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async connect(bucketId: string): Promise<void> {
|
|
276
|
+
// Serialize through the opQueue so an op racing boot can't dispatch to a
|
|
277
|
+
// half-open worker.
|
|
278
|
+
await this.enqueue(() => this.openInternal(bucketId));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async switchBucket(bucketId: string): Promise<void> {
|
|
282
|
+
this.storeEpoch++;
|
|
283
|
+
// Run close → terminate → reopen as ONE opQueue entry. Previously `close`
|
|
284
|
+
// and the new `open` were separate entries, so a query's `exec` (e.g. the
|
|
285
|
+
// query re-registration fired by an auth/bucket change) could slot in
|
|
286
|
+
// between and dispatch to the just-closed worker → "sqlite: DB not open".
|
|
287
|
+
// As a single entry, every other op runs fully before the close or after
|
|
288
|
+
// the reopen — never against a closed DB.
|
|
289
|
+
await this.enqueue(async () => {
|
|
290
|
+
if (this.worker) {
|
|
291
|
+
try {
|
|
292
|
+
await this.rawCall('close');
|
|
293
|
+
} catch {
|
|
294
|
+
/* ignore */
|
|
295
|
+
}
|
|
296
|
+
this.worker.terminate();
|
|
297
|
+
this.worker = null;
|
|
298
|
+
}
|
|
299
|
+
await this.openInternal(bucketId);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async close(): Promise<void> {
|
|
304
|
+
if (!this.worker) return;
|
|
305
|
+
try {
|
|
306
|
+
await this.call('close');
|
|
307
|
+
} catch {
|
|
308
|
+
/* ignore */
|
|
309
|
+
}
|
|
310
|
+
this.worker.terminate();
|
|
311
|
+
this.worker = null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private async ensureTable(table: string): Promise<void> {
|
|
315
|
+
if (this.knownTables.has(table)) return;
|
|
316
|
+
await this.call('run', {
|
|
317
|
+
sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
|
|
318
|
+
});
|
|
319
|
+
this.knownTables.add(table);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async execRows(sql: string, bind: unknown[]): Promise<Row[]> {
|
|
323
|
+
const { rows } = await this.call<{ rows: { data: string }[] }>('exec', { sql, bind });
|
|
324
|
+
const s = getStats();
|
|
325
|
+
const t0 = performance.now();
|
|
326
|
+
const out = (rows ?? []).map((r) => {
|
|
327
|
+
s.bytesParsed += r.data.length;
|
|
328
|
+
return reviveRow(r.data);
|
|
329
|
+
});
|
|
330
|
+
s.parseMs += performance.now() - t0;
|
|
331
|
+
s.rowsParsed += out.length;
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- reads ---------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
async select(plan: QueryPlan, params: Record<string, unknown> = {}): Promise<Row[]> {
|
|
338
|
+
if (this.workerSelect) {
|
|
339
|
+
// ONE round-trip: the worker executes the whole plan (table creation,
|
|
340
|
+
// base select, relation tree, JSON parse) and returns structured-clone
|
|
341
|
+
// rows. The legacy path below pays a postMessage hop per table/relation
|
|
342
|
+
// level plus main-thread parsing — the dominant first-load cost.
|
|
343
|
+
//
|
|
344
|
+
// Normalize before postMessage: class-instance VALUES (RecordId & co) →
|
|
345
|
+
// their `stableKey` string. structuredClone strips a class instance to a
|
|
346
|
+
// bare plain object — and surrealdb's RecordId keeps its data behind
|
|
347
|
+
// getters (no own properties), so it clones to `{}`: the worker would
|
|
348
|
+
// filter on garbage. Applies to params AND to values baked inside the
|
|
349
|
+
// plan (where nodes, relation sub-wheres, window ids).
|
|
350
|
+
// Param KEYS must pass through untouched — `comparisonSql` resolves
|
|
351
|
+
// `paramRef` via hasOwnProperty, and a dropped key silently falls back to
|
|
352
|
+
// the baked literal (the crossed-results class fixed in aa4af79b).
|
|
353
|
+
const normPlan = normalizePlanForClone(plan);
|
|
354
|
+
const normParams: Record<string, unknown> = {};
|
|
355
|
+
for (const [k, v] of Object.entries(params)) normParams[k] = toCloneSafe(v);
|
|
356
|
+
try {
|
|
357
|
+
const res = await this.call<{ rows: Row[]; relationFetches?: number }>('select', {
|
|
358
|
+
plan: normPlan,
|
|
359
|
+
params: normParams,
|
|
360
|
+
});
|
|
361
|
+
getStats().relationFetches += res.relationFetches ?? 0;
|
|
362
|
+
return res.rows ?? [];
|
|
363
|
+
} catch (err) {
|
|
364
|
+
// Stale worker script without the 'select' op: fall back for good.
|
|
365
|
+
if (err instanceof Error && err.message.includes('unknown message')) {
|
|
366
|
+
this.workerSelect = false;
|
|
367
|
+
this.logger.warn(
|
|
368
|
+
{ err, Category: 'sp00ky-client::SqliteCacheEngine::select' },
|
|
369
|
+
'Worker lacks select op (stale script?) — falling back to multi-hop select'
|
|
370
|
+
);
|
|
371
|
+
} else {
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return this.selectLegacy(plan, params);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Pre-worker-select path: one worker round-trip per table/relation level,
|
|
380
|
+
* rows parsed on the main thread. Kept as the `workerSelect:false` escape
|
|
381
|
+
* hatch and the stale-worker fallback. */
|
|
382
|
+
private async selectLegacy(
|
|
383
|
+
plan: QueryPlan,
|
|
384
|
+
params: Record<string, unknown> = {}
|
|
385
|
+
): Promise<Row[]> {
|
|
386
|
+
// Window materialization: base rows are exactly `plan.ids`, ordered.
|
|
387
|
+
if (plan.ids) {
|
|
388
|
+
const result = await this.selectByIds(plan.table, plan.ids, {
|
|
389
|
+
select: plan.select,
|
|
390
|
+
orderBy: plan.orderBy,
|
|
391
|
+
});
|
|
392
|
+
await resolveRelations(result, plan.relations, this);
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
await this.ensureTable(plan.table);
|
|
396
|
+
const bind: unknown[] = [];
|
|
397
|
+
const proj = 'data';
|
|
398
|
+
let sql = `SELECT ${proj} FROM "${plan.table}"`;
|
|
399
|
+
if (plan.where && plan.where.length > 0) {
|
|
400
|
+
sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
|
|
401
|
+
}
|
|
402
|
+
if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
|
|
403
|
+
if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
|
|
404
|
+
if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
|
|
405
|
+
const rows = await this.execRows(sql, bind);
|
|
406
|
+
// Optional projection trimming to match `SELECT <fields>`.
|
|
407
|
+
const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
|
|
408
|
+
await resolveRelations(projected, plan.relations, this);
|
|
409
|
+
return projected;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async fetchRelation(req: RelationFetch): Promise<Row[]> {
|
|
413
|
+
getStats().relationFetches++;
|
|
414
|
+
await this.ensureTable(req.table);
|
|
415
|
+
const keys = req.keys.map(stableKey);
|
|
416
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
417
|
+
const bind: unknown[] = [...keys];
|
|
418
|
+
const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
|
|
419
|
+
let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
|
|
420
|
+
if (req.where && req.where.length > 0) {
|
|
421
|
+
sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
|
|
422
|
+
}
|
|
423
|
+
if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
|
|
424
|
+
const rows = await this.execRows(sql, bind);
|
|
425
|
+
return req.select ? rows.map((r) => project(r, req.select!)) : rows;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async selectByIds(
|
|
429
|
+
table: string,
|
|
430
|
+
ids: Id[],
|
|
431
|
+
opts?: { select?: string[]; orderBy?: OrderBy }
|
|
432
|
+
): Promise<Row[]> {
|
|
433
|
+
if (ids.length === 0) return [];
|
|
434
|
+
await this.ensureTable(table);
|
|
435
|
+
const keys = ids.map(stableKey);
|
|
436
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
437
|
+
let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
|
|
438
|
+
if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
|
|
439
|
+
let rows = await this.execRows(sql, keys);
|
|
440
|
+
if (!opts?.orderBy || opts.orderBy.length === 0) {
|
|
441
|
+
const pos = new Map(keys.map((k, i) => [k, i]));
|
|
442
|
+
rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
|
|
443
|
+
}
|
|
444
|
+
return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async getById(table: string, id: Id): Promise<Row | null> {
|
|
448
|
+
await this.ensureTable(table);
|
|
449
|
+
const rows = await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]);
|
|
450
|
+
return rows[0] ?? null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---- writes --------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
|
|
456
|
+
await this.ensureTable(table);
|
|
457
|
+
const key = stableKey(id);
|
|
458
|
+
if (mode === 'merge') {
|
|
459
|
+
// Merge in-SQL via json_patch (RFC7396 = MERGE semantics): on insert store
|
|
460
|
+
// the row, on conflict shallow-merge. Serialize once, reuse for VALUES and
|
|
461
|
+
// the patch. No read-modify-write round-trip. (RFC7396: null deletes key.)
|
|
462
|
+
const full = serializeRow({ ...data, id: key });
|
|
463
|
+
await this.call('run', {
|
|
464
|
+
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
465
|
+
bind: [key, full, full],
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await this.call('run', {
|
|
470
|
+
sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
|
471
|
+
bind: [key, serializeRow({ ...data, id: key })],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
|
|
476
|
+
// RFC6902 (fast-json-patch) applied read-modify-write — SQLite's json_patch
|
|
477
|
+
// is RFC7396 merge-patch and would misinterpret op arrays.
|
|
478
|
+
const existing = (await this.getById(table, id)) ?? { id: stableKey(id) };
|
|
479
|
+
const next = applyPatch(existing, patches as Operation[]).newDocument as Row;
|
|
480
|
+
await this.upsert(table, id, next, 'replace');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async delete(table: string, id: Id): Promise<void> {
|
|
484
|
+
await this.ensureTable(table);
|
|
485
|
+
await this.call('run', { sql: `DELETE FROM "${table}" WHERE id = ?`, bind: [stableKey(id)] });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- SurrealQL-vocabulary shim (LocalStore compatibility) ---------------
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Execute a raw SurrealQL statement by translating the client's bounded
|
|
492
|
+
* vocabulary to verbs (see `surql-translate.ts`). Returns results shaped like
|
|
493
|
+
* SurrealDB's `.query()` (one element per statement; a tx prepends a `null`
|
|
494
|
+
* begin-result so `surql.seal` extraction lines up). Epoch-fences writes.
|
|
495
|
+
*/
|
|
496
|
+
async query<T extends unknown[]>(
|
|
497
|
+
sql: string,
|
|
498
|
+
vars: Record<string, unknown> = {},
|
|
499
|
+
opts?: { epoch?: number }
|
|
500
|
+
): Promise<T> {
|
|
501
|
+
if (opts?.epoch !== undefined && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
|
|
502
|
+
const start = performance.now();
|
|
503
|
+
try {
|
|
504
|
+
const { transaction, ops } = translateSurql(sql, vars);
|
|
505
|
+
let shaped: unknown[];
|
|
506
|
+
// FAST PATH: a pure-write transaction (bulk sync-down is one
|
|
507
|
+
// `tx([upsertMerge…])`) compiles to a SINGLE worker `batch` message run in
|
|
508
|
+
// one SQLite transaction — instead of 1-2 worker round-trips PER row. This
|
|
509
|
+
// is the dominant sync-down cost; per-op execution here caused the churn
|
|
510
|
+
// OOM. Mixed txs (LET/RETURN single-record mutations) keep the per-op path.
|
|
511
|
+
if (
|
|
512
|
+
transaction &&
|
|
513
|
+
ops.every(
|
|
514
|
+
(o) =>
|
|
515
|
+
o.kind === 'upsert' ||
|
|
516
|
+
o.kind === 'delete' ||
|
|
517
|
+
o.kind === 'deleteAll' ||
|
|
518
|
+
o.kind === 'noop'
|
|
519
|
+
)
|
|
520
|
+
) {
|
|
521
|
+
await this.runWriteBatch(ops);
|
|
522
|
+
// Shape each statement's result the SAME as the per-op path (`execOp`),
|
|
523
|
+
// so a caller reading a statement's output still works after taking the
|
|
524
|
+
// batch fast path. A single `create()` compiles to an all-upsert tx
|
|
525
|
+
// (createSet + createMutation) and reads `resultIndex:0` for the new
|
|
526
|
+
// row + its id; returning empty arrays here dropped that row (id became
|
|
527
|
+
// undefined → the reconcile crashed in `encodeRecordId`). The
|
|
528
|
+
// single-batch write is kept — this only rebuilds the return value.
|
|
529
|
+
shaped = [null, ...ops.map(pureWriteOpResult)];
|
|
530
|
+
} else {
|
|
531
|
+
const results: unknown[] = [];
|
|
532
|
+
// Per-query scope holds `LET $var = (...)` bindings for later statements
|
|
533
|
+
// (e.g. `RETURN { target: $updated }`).
|
|
534
|
+
const scope: Record<string, unknown> = {};
|
|
535
|
+
for (const op of ops) results.push(await this.execOp(op, scope, vars));
|
|
536
|
+
shaped = transaction ? [null, ...results] : results;
|
|
537
|
+
}
|
|
538
|
+
this.events.emit(DatabaseEventTypes.LocalQuery, {
|
|
539
|
+
query: sql,
|
|
540
|
+
vars,
|
|
541
|
+
duration: performance.now() - start,
|
|
542
|
+
success: true,
|
|
543
|
+
timestamp: Date.now(),
|
|
544
|
+
});
|
|
545
|
+
return shaped as unknown as T;
|
|
546
|
+
} catch (err) {
|
|
547
|
+
this.events.emit(DatabaseEventTypes.LocalQuery, {
|
|
548
|
+
query: sql,
|
|
549
|
+
vars,
|
|
550
|
+
duration: performance.now() - start,
|
|
551
|
+
success: false,
|
|
552
|
+
error: err instanceof Error ? err.message : String(err),
|
|
553
|
+
timestamp: Date.now(),
|
|
554
|
+
});
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async execute<R>(
|
|
560
|
+
query: SealedQuery<R>,
|
|
561
|
+
vars?: Record<string, unknown>,
|
|
562
|
+
opts?: { epoch?: number }
|
|
563
|
+
): Promise<R> {
|
|
564
|
+
const raw = await this.query<unknown[]>(query.sql, vars, opts);
|
|
565
|
+
return query.extract(raw);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
queryUngated<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
|
|
569
|
+
return this.query<T>(sql, vars);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async execOp(
|
|
573
|
+
op: SqlOp,
|
|
574
|
+
scope: Record<string, unknown>,
|
|
575
|
+
vars: Record<string, unknown>
|
|
576
|
+
): Promise<unknown> {
|
|
577
|
+
switch (op.kind) {
|
|
578
|
+
case 'getById': {
|
|
579
|
+
const row = await this.getById(tableOf(op.id), op.id);
|
|
580
|
+
if (op.value) return row ? (row[op.value] ?? null) : null;
|
|
581
|
+
return row ? (op.select ? project(row, op.select) : row) : null;
|
|
582
|
+
}
|
|
583
|
+
case 'selectByIds': {
|
|
584
|
+
if (op.ids.length === 0) return [];
|
|
585
|
+
let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
|
|
586
|
+
select: op.select,
|
|
587
|
+
orderBy: op.orderBy,
|
|
588
|
+
});
|
|
589
|
+
if (op.value) return rows.map((r) => r[op.value!]);
|
|
590
|
+
return rows;
|
|
591
|
+
}
|
|
592
|
+
case 'selectTable': {
|
|
593
|
+
const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
|
|
594
|
+
if (op.value) return rows.map((r) => r[op.value!]);
|
|
595
|
+
return op.select ? rows.map((r) => project(r, op.select!)) : rows;
|
|
596
|
+
}
|
|
597
|
+
case 'upsert':
|
|
598
|
+
await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
|
|
599
|
+
// Cheap return — no read-back. The full merged row is only needed by a
|
|
600
|
+
// LET-wrapped upsert, which reads it back in the 'let' case below. This
|
|
601
|
+
// avoids an extra worker round-trip + full-row parse on EVERY sync-down
|
|
602
|
+
// write (the hot path under rapid churn). Shared with the batch fast
|
|
603
|
+
// path so the two never drift.
|
|
604
|
+
return pureWriteOpResult(op);
|
|
605
|
+
case 'updateSet': {
|
|
606
|
+
const existing = (await this.getById(tableOf(op.id), op.id)) ?? { id: stableKey(op.id) };
|
|
607
|
+
for (const { path, op: setOp, value } of op.sets) {
|
|
608
|
+
if (setOp === '+=' || setOp === '-=') {
|
|
609
|
+
const cur = Number(getPath(existing, path) ?? 0);
|
|
610
|
+
const delta = Number(value ?? 0);
|
|
611
|
+
setPath(existing, path, setOp === '+=' ? cur + delta : cur - delta);
|
|
612
|
+
} else {
|
|
613
|
+
setPath(existing, path, value);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
await this.upsert(tableOf(op.id), op.id, existing, 'replace');
|
|
617
|
+
return op.returnNone ? null : existing;
|
|
618
|
+
}
|
|
619
|
+
case 'delete':
|
|
620
|
+
await this.delete(tableOf(op.id), op.id);
|
|
621
|
+
return pureWriteOpResult(op);
|
|
622
|
+
case 'deleteAll':
|
|
623
|
+
await this.ensureTable(op.table);
|
|
624
|
+
await this.call('run', { sql: `DELETE FROM "${op.table}"` });
|
|
625
|
+
return pureWriteOpResult(op);
|
|
626
|
+
case 'let': {
|
|
627
|
+
let result = await this.execOp(op.inner, scope, vars);
|
|
628
|
+
// A LET-bound UPSERT must expose the FULL merged row (e.g.
|
|
629
|
+
// `RETURN { target: $updated }`), so read it back here — only here,
|
|
630
|
+
// not on every upsert.
|
|
631
|
+
if (op.inner.kind === 'upsert') {
|
|
632
|
+
result = (await this.getById(tableOf(op.inner.id), op.inner.id)) ?? result;
|
|
633
|
+
}
|
|
634
|
+
scope[op.var] = result;
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
case 'return': {
|
|
638
|
+
const obj: Row = {};
|
|
639
|
+
for (const { key, var: v } of op.entries) {
|
|
640
|
+
obj[key] = v in scope ? scope[v] : vars[v];
|
|
641
|
+
}
|
|
642
|
+
return obj;
|
|
643
|
+
}
|
|
644
|
+
case 'noop':
|
|
645
|
+
return pureWriteOpResult(op);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Compile a pure-write op list to ONE worker `batch` (single SQLite
|
|
651
|
+
* transaction). Ensures each touched table exists, then one statement per op.
|
|
652
|
+
* Merges happen in-SQL via json_patch — no read-back round-trips.
|
|
653
|
+
*/
|
|
654
|
+
private async runWriteBatch(ops: SqlOp[]): Promise<void> {
|
|
655
|
+
const stmts: { sql: string; bind?: unknown[] }[] = [];
|
|
656
|
+
const tables = new Set<string>();
|
|
657
|
+
const ensure = (t: string) => {
|
|
658
|
+
if (!tables.has(t)) {
|
|
659
|
+
tables.add(t);
|
|
660
|
+
this.knownTables.add(t);
|
|
661
|
+
stmts.push({
|
|
662
|
+
sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
for (const op of ops) {
|
|
667
|
+
if (op.kind === 'upsert') {
|
|
668
|
+
const t = tableOf(op.id);
|
|
669
|
+
const key = stableKey(op.id);
|
|
670
|
+
ensure(t);
|
|
671
|
+
if (op.mode === 'merge') {
|
|
672
|
+
// Serialize ONCE and reuse for both VALUES (fresh insert) and the
|
|
673
|
+
// json_patch (merge). Patching with `id` is a harmless no-op set, so
|
|
674
|
+
// the full row doubles as the delta — halves per-row stringify cost.
|
|
675
|
+
const full = serializeRow({ ...op.data, id: key });
|
|
676
|
+
stmts.push({
|
|
677
|
+
sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
|
|
678
|
+
bind: [key, full, full],
|
|
679
|
+
});
|
|
680
|
+
} else {
|
|
681
|
+
stmts.push({
|
|
682
|
+
sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
|
|
683
|
+
bind: [key, serializeRow({ ...op.data, id: key })],
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
} else if (op.kind === 'delete') {
|
|
687
|
+
const t = tableOf(op.id);
|
|
688
|
+
ensure(t);
|
|
689
|
+
stmts.push({ sql: `DELETE FROM "${t}" WHERE id = ?`, bind: [stableKey(op.id)] });
|
|
690
|
+
} else if (op.kind === 'deleteAll') {
|
|
691
|
+
ensure(op.table);
|
|
692
|
+
stmts.push({ sql: `DELETE FROM "${op.table}"` });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (stmts.length > 0) await this.call('batch', stmts);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async rawSelectTable(
|
|
699
|
+
table: string,
|
|
700
|
+
where?: WhereNode[],
|
|
701
|
+
orderBy?: OrderBy
|
|
702
|
+
): Promise<Row[]> {
|
|
703
|
+
await this.ensureTable(table);
|
|
704
|
+
const bind: unknown[] = [];
|
|
705
|
+
let sql = `SELECT data FROM "${table}"`;
|
|
706
|
+
if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
|
|
707
|
+
if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
|
|
708
|
+
return this.execRows(sql, bind);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
|
|
712
|
+
// Verbs run in order on the single worker message channel. `patch` needs a
|
|
713
|
+
// read-modify-write round-trip, so a single BEGIN/COMMIT batch cannot wrap
|
|
714
|
+
// the whole closure; sequential execution is sufficient for the current
|
|
715
|
+
// single-record write sites.
|
|
716
|
+
const tx: EngineTx = {
|
|
717
|
+
upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
|
|
718
|
+
patch: (t, id, p) => this.patch(t, id, p),
|
|
719
|
+
delete: (t, id) => this.delete(t, id),
|
|
720
|
+
};
|
|
721
|
+
return fn(tx);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ==================== instrumentation ====================
|
|
726
|
+
|
|
727
|
+
interface SqliteStats {
|
|
728
|
+
roundTrips: number;
|
|
729
|
+
batchStatements: number;
|
|
730
|
+
maxBatch: number;
|
|
731
|
+
inFlight: number;
|
|
732
|
+
maxInFlight: number;
|
|
733
|
+
byType: Record<string, number>;
|
|
734
|
+
/** Time ops spent waiting behind the opQueue before dispatch. */
|
|
735
|
+
queueWaitMs: number;
|
|
736
|
+
/** Time inside the worker's message handler (actual SQLite work). */
|
|
737
|
+
workerMs: number;
|
|
738
|
+
/** Round-trip time minus workerMs: postMessage + scheduling overhead. */
|
|
739
|
+
rpcOverheadMs: number;
|
|
740
|
+
/** Main-thread JSON parse/revive of returned rows. */
|
|
741
|
+
parseMs: number;
|
|
742
|
+
rowsParsed: number;
|
|
743
|
+
bytesParsed: number;
|
|
744
|
+
/** Relation-resolver fan-out fetches (one worker round-trip each). */
|
|
745
|
+
relationFetches: number;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const EMPTY_STATS: SqliteStats = {
|
|
749
|
+
roundTrips: 0,
|
|
750
|
+
batchStatements: 0,
|
|
751
|
+
maxBatch: 0,
|
|
752
|
+
inFlight: 0,
|
|
753
|
+
maxInFlight: 0,
|
|
754
|
+
byType: {},
|
|
755
|
+
queueWaitMs: 0,
|
|
756
|
+
workerMs: 0,
|
|
757
|
+
rpcOverheadMs: 0,
|
|
758
|
+
parseMs: 0,
|
|
759
|
+
rowsParsed: 0,
|
|
760
|
+
bytesParsed: 0,
|
|
761
|
+
relationFetches: 0,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/** Live stats, inspectable in the browser console via `__sqliteStats`. Counts
|
|
765
|
+
* worker round-trips (the sync-down cost driver), batch sizes, queue depth
|
|
766
|
+
* (`maxInFlight`), and the latency split of each round-trip (queue wait vs
|
|
767
|
+
* worker time vs RPC overhead vs main-thread row parsing) so first-load cost
|
|
768
|
+
* can be measured rather than guessed. */
|
|
769
|
+
function getStats(): SqliteStats {
|
|
770
|
+
const g = globalThis as unknown as { __sqliteStats?: SqliteStats };
|
|
771
|
+
if (!g.__sqliteStats) {
|
|
772
|
+
g.__sqliteStats = { ...EMPTY_STATS, byType: {} };
|
|
773
|
+
} else {
|
|
774
|
+
// Backfill fields added since the object was created (HMR / older bundle).
|
|
775
|
+
for (const [k, v] of Object.entries(EMPTY_STATS)) {
|
|
776
|
+
if ((g.__sqliteStats as any)[k] === undefined) (g.__sqliteStats as any)[k] = v;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return g.__sqliteStats;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// SQL rendering + row (de)serialization live in `sqlite-plan-sql.ts`, shared
|
|
783
|
+
// with the worker (worker-side plan execution renders the same SQL).
|
|
784
|
+
|
|
785
|
+
// ==================== structured-clone normalization ====================
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Make a bind/param value safe to cross the worker boundary. structuredClone
|
|
789
|
+
* keeps plain data intact but strips a CLASS instance to a bare object — and
|
|
790
|
+
* surrealdb's `RecordId` stores its fields behind getters (zero own
|
|
791
|
+
* properties), so it clones to `{}`. Convert such instances to their
|
|
792
|
+
* `stableKey` string (the exact value `scalar()` would bind on the main
|
|
793
|
+
* thread), leave everything clone-representable untouched.
|
|
794
|
+
*/
|
|
795
|
+
function toCloneSafe(v: unknown): unknown {
|
|
796
|
+
if (v === null || typeof v !== 'object') return v;
|
|
797
|
+
if (Array.isArray(v) || v instanceof Uint8Array || v instanceof Date) return v;
|
|
798
|
+
const proto = Object.getPrototypeOf(v);
|
|
799
|
+
if (proto === Object.prototype || proto === null) return v;
|
|
800
|
+
return stableKey(v);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function normalizeWhereForClone(nodes: WhereNode[] | undefined): WhereNode[] | undefined {
|
|
804
|
+
if (!nodes) return nodes;
|
|
805
|
+
return nodes.map((n) =>
|
|
806
|
+
'or' in n
|
|
807
|
+
? { or: n.or.map((c) => ({ ...c, value: toCloneSafe(c.value) })) }
|
|
808
|
+
: { ...n, value: toCloneSafe(n.value) }
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function normalizeRelationForClone(r: RelationPlan): RelationPlan {
|
|
813
|
+
return {
|
|
814
|
+
...r,
|
|
815
|
+
where: normalizeWhereForClone(r.where),
|
|
816
|
+
relations: r.relations?.map(normalizeRelationForClone),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Normalize every baked value a plan carries (where trees, window ids) for
|
|
821
|
+
* the postMessage to the worker's `select` op. */
|
|
822
|
+
function normalizePlanForClone(plan: QueryPlan): QueryPlan {
|
|
823
|
+
return {
|
|
824
|
+
...plan,
|
|
825
|
+
ids: plan.ids?.map(stableKey),
|
|
826
|
+
where: normalizeWhereForClone(plan.where),
|
|
827
|
+
relations: plan.relations?.map(normalizeRelationForClone),
|
|
828
|
+
};
|
|
829
|
+
}
|