@spooky-sync/core 0.0.1-canary.17 → 0.0.1-canary.170

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.
Files changed (139) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2046 -365
  3. package/dist/index.js +11016 -2476
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +420 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +997 -0
  12. package/package.json +14 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +94 -18
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +4 -4
  17. package/src/build-globals.d.ts +12 -0
  18. package/src/events/events.test.ts +2 -1
  19. package/src/events/index.ts +3 -0
  20. package/src/index.ts +24 -2
  21. package/src/modules/app-release/index.test.ts +125 -0
  22. package/src/modules/app-release/index.ts +201 -0
  23. package/src/modules/auth/events/index.ts +2 -1
  24. package/src/modules/auth/index.ts +59 -20
  25. package/src/modules/cache/index.ts +112 -32
  26. package/src/modules/cache/types.ts +2 -2
  27. package/src/modules/crdt/crdt-field.ts +294 -0
  28. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  29. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  30. package/src/modules/crdt/index.ts +463 -0
  31. package/src/modules/crdt/loro-loader.ts +25 -0
  32. package/src/modules/data/data.hydration.test.ts +142 -0
  33. package/src/modules/data/data.membership.test.ts +297 -0
  34. package/src/modules/data/data.rebind.test.ts +147 -0
  35. package/src/modules/data/data.run.test.ts +113 -0
  36. package/src/modules/data/data.status.test.ts +249 -0
  37. package/src/modules/data/id-set-plan.test.ts +122 -0
  38. package/src/modules/data/index.ts +1403 -129
  39. package/src/modules/data/mutation-id.test.ts +25 -0
  40. package/src/modules/data/mutation-id.ts +35 -0
  41. package/src/modules/data/window-query.test.ts +52 -0
  42. package/src/modules/data/window-query.ts +194 -0
  43. package/src/modules/devtools/flags.ts +349 -0
  44. package/src/modules/devtools/index.ts +382 -37
  45. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  46. package/src/modules/devtools/storage-info.test.ts +79 -0
  47. package/src/modules/devtools/storage-info.ts +168 -0
  48. package/src/modules/devtools/versions.test.ts +74 -0
  49. package/src/modules/devtools/versions.ts +81 -0
  50. package/src/modules/feature-flag/index.test.ts +251 -0
  51. package/src/modules/feature-flag/index.ts +308 -0
  52. package/src/modules/ref-tables.test.ts +91 -0
  53. package/src/modules/ref-tables.ts +88 -0
  54. package/src/modules/sync/engine.ts +101 -37
  55. package/src/modules/sync/events/index.ts +9 -2
  56. package/src/modules/sync/queue/queue-down.ts +12 -5
  57. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  58. package/src/modules/sync/queue/queue-up.ts +216 -56
  59. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  60. package/src/modules/sync/scheduler.ts +77 -9
  61. package/src/modules/sync/sync.health.test.ts +149 -0
  62. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  63. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  64. package/src/modules/sync/sync.subquery.test.ts +82 -0
  65. package/src/modules/sync/sync.ts +1456 -92
  66. package/src/modules/sync/utils.test.ts +269 -2
  67. package/src/modules/sync/utils.ts +182 -17
  68. package/src/otel/index.ts +127 -0
  69. package/src/services/blobs/blob-cache.test.ts +359 -0
  70. package/src/services/blobs/blob-cache.ts +603 -0
  71. package/src/services/blobs/blob-manifest.ts +227 -0
  72. package/src/services/blobs/blob-store.test.ts +77 -0
  73. package/src/services/blobs/blob-store.ts +359 -0
  74. package/src/services/blobs/blob.fixture.ts +90 -0
  75. package/src/services/blobs/index.ts +70 -0
  76. package/src/services/database/cache-engine.ts +160 -0
  77. package/src/services/database/connection-supervisor.test.ts +289 -0
  78. package/src/services/database/connection-supervisor.ts +415 -0
  79. package/src/services/database/database.query-timeout.test.ts +83 -0
  80. package/src/services/database/database.ts +32 -12
  81. package/src/services/database/engine-factory.ts +33 -0
  82. package/src/services/database/events/index.ts +2 -1
  83. package/src/services/database/index.ts +7 -0
  84. package/src/services/database/local-migrator.ts +30 -27
  85. package/src/services/database/local.test.ts +64 -0
  86. package/src/services/database/local.ts +478 -67
  87. package/src/services/database/plan-render.test.ts +159 -0
  88. package/src/services/database/plan-render.ts +108 -0
  89. package/src/services/database/relation-resolver.test.ts +413 -0
  90. package/src/services/database/relation-resolver.ts +0 -0
  91. package/src/services/database/remote.ts +110 -14
  92. package/src/services/database/sqlite-cache-engine.test.ts +464 -0
  93. package/src/services/database/sqlite-cache-engine.ts +1159 -0
  94. package/src/services/database/sqlite-open.test.ts +150 -0
  95. package/src/services/database/sqlite-open.ts +164 -0
  96. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  97. package/src/services/database/sqlite-plan-sql.ts +106 -0
  98. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  99. package/src/services/database/sqlite-select.test.ts +246 -0
  100. package/src/services/database/sqlite-select.ts +113 -0
  101. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  102. package/src/services/database/sqlite-transport.ts +219 -0
  103. package/src/services/database/sqlite-worker.ts +437 -0
  104. package/src/services/database/surql-translate.ts +291 -0
  105. package/src/services/database/surreal-cache-engine.ts +141 -0
  106. package/src/services/logger/index.ts +6 -109
  107. package/src/services/persistence/localstorage.ts +2 -2
  108. package/src/services/persistence/resilient.ts +11 -4
  109. package/src/services/persistence/surrealdb.ts +10 -10
  110. package/src/services/stream-processor/index.ts +444 -52
  111. package/src/services/stream-processor/permissions.test.ts +47 -0
  112. package/src/services/stream-processor/permissions.ts +53 -0
  113. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  114. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  115. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  116. package/src/services/stream-processor/wasm-types.ts +23 -2
  117. package/src/services/tabs/broker-client.ts +283 -0
  118. package/src/services/tabs/broker.test.ts +278 -0
  119. package/src/services/tabs/coordinator.test.ts +244 -0
  120. package/src/services/tabs/coordinator.ts +576 -0
  121. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  122. package/src/services/tabs/leader-locks.ts +75 -0
  123. package/src/services/tabs/protocol.ts +242 -0
  124. package/src/services/tabs/support.ts +36 -0
  125. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  126. package/src/sp00ky.auth-order.test.ts +92 -0
  127. package/src/sp00ky.init-query.test.ts +183 -0
  128. package/src/sp00ky.ts +1424 -0
  129. package/src/types.ts +467 -15
  130. package/src/utils/error-classification.test.ts +44 -0
  131. package/src/utils/error-classification.ts +7 -0
  132. package/src/utils/index.ts +73 -13
  133. package/src/utils/parser.ts +3 -2
  134. package/src/utils/semver.test.ts +32 -0
  135. package/src/utils/semver.ts +30 -0
  136. package/src/utils/surql.ts +30 -18
  137. package/src/utils/withRetry.test.ts +1 -1
  138. package/tsdown.config.ts +86 -1
  139. package/src/spooky.ts +0 -395
@@ -0,0 +1,1159 @@
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, StorageHealth } 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 {
22
+ BrokerPortClosedError,
23
+ PortSqliteTransport,
24
+ WorkerSqliteTransport,
25
+ type SqliteTransport,
26
+ } from './sqlite-transport';
27
+ import { PROMOTION_OPEN_OPTIONS } from './sqlite-open';
28
+ import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
29
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
30
+
31
+ /**
32
+ * The statement result a pure-write op contributes to a query's results array.
33
+ * Single source of truth shared by the per-op path (`execOp`) and the batched
34
+ * fast path in `query()`, so the two can never diverge: a caller that reads a
35
+ * statement's output sees the same shape whether or not the transaction took the
36
+ * batch fast path. In particular `create()` compiles to an all-upsert tx
37
+ * (`createSet` + `createMutation`) and reads `resultIndex:0` for the new row and
38
+ * its id — the fast path previously returned empty arrays there, so the row (and
39
+ * its id) was lost and the reconcile crashed in `encodeRecordId`.
40
+ *
41
+ * An upsert echoes the written row (`{...data, id}`) with no read-back — the
42
+ * full merged row is only materialized for a LET-wrapped upsert (see the 'let'
43
+ * case). delete/deleteAll yield `[]`; noop yields `null`.
44
+ */
45
+ /**
46
+ * The `_00_*` internal tables the client relies on. The LocalMigrator DEFINEs
47
+ * them, but every DEFINE lowers to a noop on the SQLite engine, so they must be
48
+ * created physically at open (see `openInternal`) or a read-before-first-write
49
+ * on a fresh bucket throws "no such table". Keep in sync with the systemSchema
50
+ * block in `local-migrator.ts`.
51
+ */
52
+ const SYSTEM_TABLES = [
53
+ '_00_stream_processor_state',
54
+ '_00_query',
55
+ '_00_preload',
56
+ '_00_window',
57
+ '_00_schema',
58
+ '_00_pending_mutations',
59
+ // Blob cache manifest. Read before its first write on every boot (reconcile
60
+ // asks for the ids it found in OPFS), so it has to exist up front like the
61
+ // rest — otherwise the very first reconcile throws and the cache runs cold.
62
+ '_00_blob',
63
+ // Server-written, synced-down meta tables (see meta_tables_client.surql).
64
+ // DEFINE is a noop on this engine, so without seeding them here their synced
65
+ // rows have no local table to land in: feature flags silently fall back to
66
+ // defaults and app-release update notifications never show.
67
+ '_00_user_feature',
68
+ '_00_app_release',
69
+ ] as const;
70
+
71
+ export function pureWriteOpResult(op: SqlOp): unknown {
72
+ switch (op.kind) {
73
+ case 'upsert':
74
+ return { ...op.data, id: stableKey(op.id) };
75
+ case 'noop':
76
+ return null;
77
+ default:
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Local cache backend on official SQLite-WASM in a dedicated Worker (see
84
+ * `sqlite-worker.ts`), with OPFS SAHPool persistence. Storage model: one table
85
+ * per schema table, `id TEXT PRIMARY KEY, data TEXT` where `data` is the row as
86
+ * JSON. Filtering/ordering use `json_extract`. Relations are decomposed by the
87
+ * shared {@link resolveRelations} — identical to the SurrealDB backend.
88
+ *
89
+ * Value normalization (JSON round-trip):
90
+ * - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
91
+ * - Record links / ids → their `table:id` string form (so `json_extract`
92
+ * comparisons and `IN` matching are consistent). NOTE: link fields therefore
93
+ * read back as strings, not `RecordId` instances — the one shape difference
94
+ * from the SurrealDB backend, to be closed with schema-driven revival + an
95
+ * oracle E2E in the browser.
96
+ */
97
+ export class SqliteCacheEngine implements LocalStore {
98
+ /** The wire to the SQLite worker: an owned dedicated Worker (solo/leader) or
99
+ * a MessagePort into another tab's worker (follower). See sqlite-transport. */
100
+ private transport: SqliteTransport | null = null;
101
+ private storeEpoch = 0;
102
+ private knownTables = new Set<string>();
103
+ private useOpfs: boolean;
104
+ /** Whether `select` runs as one worker round-trip (plan executed in-worker).
105
+ * Flipped off at runtime if the worker script predates the `select` op
106
+ * (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
107
+ private workerSelect: boolean;
108
+ /** What `workerSelect` was at construction, so DevTools can tell a runtime
109
+ * downgrade (configured true, effective false) from a configured-off. */
110
+ private workerSelectConfigured: boolean;
111
+ private events: DatabaseEventSystem = createDatabaseEventSystem();
112
+ private bucketId = 'anon';
113
+ /** Durability of the local store, set on every open. A plain Set of callbacks
114
+ * rather than a `DatabaseEventSystem` event: this changes at most once per
115
+ * open, and the typed event map is about query traffic. */
116
+ private storageHealthValue: StorageHealth = { status: 'unknown', fallback: false };
117
+ private storageHealthSubs = new Set<(health: StorageHealth) => void>();
118
+ /** Schemaless — tables are created lazily on first write; no migrator. */
119
+ readonly usesSurqlSchema = false;
120
+
121
+ readonly engineKind = 'sqlite' as const;
122
+
123
+ /** Shared-tabs mode: the engine's transport is swapped at runtime by the
124
+ * TabsCoordinator (owner worker as leader, leader's port as follower). */
125
+ private shared: boolean;
126
+ /** Leaderless parking (shared mode): ops entering the opQueue await this
127
+ * gate until a new role lands or the timeout rejects them. */
128
+ private roleGate: { promise: Promise<void>; release: () => void } | null = null;
129
+ private roleGateTimer: ReturnType<typeof setTimeout> | null = null;
130
+
131
+ constructor(
132
+ private config: Sp00kyConfig<any>['database'],
133
+ private logger: Logger,
134
+ opts: { useOpfs?: boolean; workerSelect?: boolean; shared?: boolean } = {}
135
+ ) {
136
+ this.useOpfs = opts.useOpfs ?? true;
137
+ this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
138
+ this.workerSelectConfigured = this.workerSelect;
139
+ this.shared = opts.shared ?? false;
140
+ }
141
+
142
+ get epoch(): number {
143
+ return this.storeEpoch;
144
+ }
145
+
146
+ get currentBucketId(): string {
147
+ return this.bucketId;
148
+ }
149
+
150
+ get storageHealth(): StorageHealth {
151
+ return this.storageHealthValue;
152
+ }
153
+
154
+ /** Fires immediately with the current snapshot (the store opens during
155
+ * `connect()`, before app components mount, so a late subscriber must still
156
+ * learn a fallback happened), then on every change. */
157
+ subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
158
+ cb(this.storageHealthValue);
159
+ this.storageHealthSubs.add(cb);
160
+ return () => {
161
+ this.storageHealthSubs.delete(cb);
162
+ };
163
+ }
164
+
165
+ private setStorageHealth(health: StorageHealth): void {
166
+ this.storageHealthValue = health;
167
+ for (const cb of this.storageHealthSubs) cb(health);
168
+ }
169
+
170
+ getConfig(): Sp00kyConfig<any>['database'] {
171
+ return this.config;
172
+ }
173
+
174
+ /**
175
+ * Storage numbers for the DevTools Storage tab. Uses {@link call} so the
176
+ * reads serialize with regular traffic (no SQLITE_BUSY). Never throws — the
177
+ * worker may be mid bucket-switch; a failure lands in `error` instead.
178
+ */
179
+ async getStorageDiagnostics(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics> {
180
+ const diag: EngineStorageDiagnostics = {
181
+ engine: 'sqlite',
182
+ bucketId: this.bucketId,
183
+ useOpfs: this.useOpfs,
184
+ workerSelectConfigured: this.workerSelectConfigured,
185
+ workerSelectEffective: this.workerSelect,
186
+ };
187
+ try {
188
+ const { rows } = await this.call<{ rows: { bytes: number; freelist: number }[] }>('exec', {
189
+ sql:
190
+ 'SELECT (SELECT * FROM pragma_page_count()) * (SELECT * FROM pragma_page_size()) AS bytes, ' +
191
+ '(SELECT * FROM pragma_freelist_count()) * (SELECT * FROM pragma_page_size()) AS freelist',
192
+ });
193
+ diag.dbSizeBytes = rows?.[0]?.bytes;
194
+ diag.freelistBytes = rows?.[0]?.freelist;
195
+ if (opts?.tableCounts) {
196
+ const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
197
+ sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
198
+ });
199
+ const names = (tables ?? []).map((r) => r.name);
200
+ if (names.length) {
201
+ // Names come from sqlite_master itself; double-quoting is enough.
202
+ const sql = names
203
+ .map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, '""')}"`)
204
+ .join(' UNION ALL ');
205
+ const { rows: counts } = await this.call<{ rows: { t: string; n: number }[] }>('exec', {
206
+ sql,
207
+ });
208
+ diag.tableCounts = (counts ?? []).map((r) => ({ table: r.t, rows: r.n }));
209
+ } else {
210
+ diag.tableCounts = [];
211
+ }
212
+ }
213
+ } catch (e) {
214
+ diag.error = e instanceof Error ? e.message : String(e);
215
+ }
216
+ return diag;
217
+ }
218
+
219
+ getEvents(): DatabaseEventSystem {
220
+ return this.events;
221
+ }
222
+
223
+ getClient(): unknown {
224
+ throw new Error('SqliteCacheEngine has no SurrealDB client (getClient is unavailable).');
225
+ }
226
+
227
+ /** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
228
+ switchStore(bucketId: string): Promise<void> {
229
+ return this.switchBucket(bucketId);
230
+ }
231
+
232
+ /** SQLite has no switch gate window; the epoch bump alone fences stale writes. */
233
+ beginSwitch(): () => void {
234
+ return () => {};
235
+ }
236
+
237
+ // ---- worker plumbing -----------------------------------------------------
238
+
239
+ /** Serializes every worker op so reads/writes never overlap at the VFS layer
240
+ * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
241
+ * single-flight query queue. (The worker keeps its own chain too, for
242
+ * multi-client mode; this one additionally provides the queue-wait stat and
243
+ * the boot/switch atomicity below.) */
244
+ private opQueue: Promise<unknown> = Promise.resolve();
245
+
246
+ private call<T = any>(type: string, payload?: unknown): Promise<T> {
247
+ const enqueuedAt = performance.now();
248
+ const run = async () => {
249
+ // Leaderless window (shared mode): park behind the role gate instead of
250
+ // failing; a new leader releases it, the timeout rejects it.
251
+ if (this.roleGate) await this.roleGate.promise;
252
+ // Time spent waiting behind other ops in the queue, not doing work.
253
+ getStats().queueWaitMs += performance.now() - enqueuedAt;
254
+ return this.rawCall<T>(type, payload);
255
+ };
256
+ const result = this.opQueue.then(run, run);
257
+ // Keep the chain alive regardless of individual failures.
258
+ this.opQueue = result.then(
259
+ () => undefined,
260
+ () => undefined
261
+ );
262
+ return result;
263
+ }
264
+
265
+ private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
266
+ if (!this.transport) throw new Error('SqliteCacheEngine: not connected');
267
+ // --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
268
+ const s = getStats();
269
+ s.roundTrips++;
270
+ s.byType[type] = (s.byType[type] ?? 0) + 1;
271
+ if (type === 'batch' && Array.isArray(payload)) {
272
+ s.batchStatements += payload.length;
273
+ s.maxBatch = Math.max(s.maxBatch, payload.length);
274
+ }
275
+ s.inFlight++;
276
+ s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
277
+ if (this.transport.kind === 'port') s.proxiedOps = (s.proxiedOps ?? 0) + 1;
278
+ const sentAt = performance.now();
279
+ return this.transport.call<T>(type, payload).then(
280
+ (v: T) => {
281
+ s.inFlight--;
282
+ // Split the round-trip: `wt` is time inside the worker's handler,
283
+ // the remainder is postMessage + scheduling overhead.
284
+ const wt = (v as { wt?: unknown } | null)?.wt;
285
+ if (typeof wt === 'number') {
286
+ s.workerMs += wt;
287
+ s.rpcOverheadMs += Math.max(0, performance.now() - sentAt - wt);
288
+ }
289
+ return v;
290
+ },
291
+ (e: unknown) => {
292
+ s.inFlight--;
293
+ throw e;
294
+ }
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Spawn the worker and open `bucketId`'s DB. Uses {@link rawCall} (NOT
300
+ * {@link call}) so it can run as the body of an already-queued opQueue entry
301
+ * without re-queuing onto itself. Callers must run it through the opQueue.
302
+ */
303
+ /** Seam for tests: swap in a fake transport instead of a real Worker. */
304
+ protected createTransport(): SqliteTransport {
305
+ return new WorkerSqliteTransport(this.logger);
306
+ }
307
+
308
+ private async openInternal(
309
+ bucketId: string,
310
+ extras?: {
311
+ workerLockName?: string;
312
+ openOptions?: { maxAttempts?: number; backoffMs?: number[]; disallowMemoryFallback?: boolean };
313
+ }
314
+ ): Promise<void> {
315
+ if (!this.transport || this.transport.kind !== 'worker' || !this.transport.connected) {
316
+ this.transport = this.createTransport();
317
+ if (this.transport instanceof WorkerSqliteTransport) {
318
+ // The worker fencing itself (leadership stolen from a frozen tab that
319
+ // thawed) is a leader-loss: park ops until the broker re-adopts us.
320
+ this.transport.onLockLost = (reason) => {
321
+ this.transport = null;
322
+ this.storeEpoch++;
323
+ this.openRoleGate(`worker fenced: ${reason}`);
324
+ };
325
+ }
326
+ }
327
+ // Seed the `_00_*` system tables as part of `open` (worker-side, one round
328
+ // trip). The LocalMigrator DEFINEs them, but `translateSurql` lowers every
329
+ // DEFINE to a noop on this engine (SQLite has no DDL vocabulary), so
330
+ // provisioning never actually creates them — they were only made lazily on
331
+ // first WRITE. A fresh bucket (e.g. right after signup) that READS one first
332
+ // (the sync layer selects `_00_query` before any row lands) hit
333
+ // "no such table: _00_query" and the client wedged on "Loading database".
334
+ // Creating them inside `open` guarantees any access order is safe without
335
+ // adding ops to the engine's queue.
336
+ // `opfsError` is absent from a worker bundle older than this field, which
337
+ // just reads as "no reason given" rather than breaking the open.
338
+ const { persisted, opfsError } = await this.rawCall<{
339
+ persisted: boolean;
340
+ opfsError?: string;
341
+ }>('open', {
342
+ dbName: bucketId,
343
+ useOpfs: this.useOpfs,
344
+ systemTables: SYSTEM_TABLES,
345
+ ...(extras?.workerLockName ? { workerLockName: extras.workerLockName } : {}),
346
+ ...(extras?.openOptions ? { openOptions: extras.openOptions } : {}),
347
+ });
348
+ this.knownTables.clear();
349
+ for (const t of SYSTEM_TABLES) this.knownTables.add(t);
350
+ this.bucketId = bucketId;
351
+ // Durability was requested but could not be had: the store is in RAM, so it
352
+ // loses local writes on reload and can OOM a wasm-heavy renderer. Report it
353
+ // (the worker also console.errors, since host apps may run pino at `fatal`)
354
+ // and publish it so the app can warn the user.
355
+ const fellBack = this.useOpfs && !persisted;
356
+ // Omit `error` rather than setting it to `undefined`: the devtools
357
+ // serializer renders an undefined value as the STRING 'undefined'.
358
+ const health: StorageHealth = {
359
+ status: persisted ? 'persistent' : 'memory',
360
+ fallback: fellBack,
361
+ };
362
+ if (fellBack && opfsError) health.error = opfsError;
363
+ if (this.shared) health.role = this.roleLabel;
364
+ this.setStorageHealth(health);
365
+ const stats = getStats();
366
+ stats.persisted = persisted;
367
+ if (fellBack && opfsError) stats.opfsError = opfsError;
368
+ else delete stats.opfsError;
369
+ if (fellBack) {
370
+ this.logger.error(
371
+ { bucketId, opfsError, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
372
+ 'SQLite OPFS persistence failed; store is IN MEMORY and will not survive reload'
373
+ );
374
+ } else {
375
+ this.logger.info(
376
+ { bucketId, persisted, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
377
+ persisted ? 'SQLite OPFS store opened' : 'SQLite in-memory store opened (as configured)'
378
+ );
379
+ }
380
+ }
381
+
382
+ /** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
383
+ * chaining) so it can't interleave with reads/writes at the worker. */
384
+ private enqueue<T>(fn: () => Promise<T>): Promise<T> {
385
+ const result = this.opQueue.then(fn, fn);
386
+ this.opQueue = result.then(
387
+ () => undefined,
388
+ () => undefined
389
+ );
390
+ return result;
391
+ }
392
+
393
+ async connect(bucketId: string): Promise<void> {
394
+ // Serialize through the opQueue so an op racing boot can't dispatch to a
395
+ // half-open worker.
396
+ await this.enqueue(() => this.openInternal(bucketId));
397
+ }
398
+
399
+ async switchBucket(bucketId: string): Promise<void> {
400
+ this.storeEpoch++;
401
+ // Run close → terminate → reopen as ONE opQueue entry. Previously `close`
402
+ // and the new `open` were separate entries, so a query's `exec` (e.g. the
403
+ // query re-registration fired by an auth/bucket change) could slot in
404
+ // between and dispatch to the just-closed worker → "sqlite: DB not open".
405
+ // As a single entry, every other op runs fully before the close or after
406
+ // the reopen — never against a closed DB.
407
+ await this.enqueue(async () => {
408
+ if (this.transport) {
409
+ try {
410
+ await this.rawCall('close');
411
+ } catch {
412
+ /* ignore */
413
+ }
414
+ this.transport.close('bucket switch');
415
+ this.transport = null;
416
+ }
417
+ await this.openInternal(bucketId);
418
+ });
419
+ }
420
+
421
+ async close(): Promise<void> {
422
+ if (!this.transport) return;
423
+ try {
424
+ await this.call('close');
425
+ } catch {
426
+ /* ignore */
427
+ }
428
+ this.transport.close('engine closed');
429
+ this.transport = null;
430
+ }
431
+
432
+ // ---- shared-tabs role modes ------------------------------------------------
433
+ // The TabsCoordinator drives these; solo mode never touches them. The engine
434
+ // object is never replaced across role changes, so `storeEpoch` stays one
435
+ // monotonic per-tab counter and every existing fencing consumer keeps
436
+ // working unchanged.
437
+
438
+ /** Which role the current transport represents, for StorageHealth. */
439
+ private roleLabel: 'leader' | 'follower' | 'solo' = 'solo';
440
+ /** True once this engine has had a usable store at least once; role changes
441
+ * after that point invalidate in-flight reads and must bump the epoch. */
442
+ private hadStore = false;
443
+
444
+ /**
445
+ * Role transitions run on their OWN chain, never on the opQueue: parked ops
446
+ * sit INSIDE opQueue entries waiting for the role gate, so a transition
447
+ * queued behind them could never run to release them (deadlock). Transitions
448
+ * are safe off-queue because in-flight ops on a dead transport were already
449
+ * rejected, parked ops only resume after the transition completes, and the
450
+ * worker serializes everything worker-side anyway.
451
+ */
452
+ private transitionChain: Promise<unknown> = Promise.resolve();
453
+
454
+ private chainTransition<T>(fn: () => Promise<T>): Promise<T> {
455
+ const result = this.transitionChain.then(fn, fn);
456
+ this.transitionChain = result.then(
457
+ () => undefined,
458
+ () => undefined
459
+ );
460
+ return result;
461
+ }
462
+
463
+ private openRoleGate(reason: string): void {
464
+ if (this.roleGate) return;
465
+ let release!: () => void;
466
+ let rejectFn!: (e: Error) => void;
467
+ const promise = new Promise<void>((res, rej) => {
468
+ release = res;
469
+ rejectFn = rej;
470
+ });
471
+ // Swallow the timeout rejection for waiters that already resolved.
472
+ promise.catch(() => {});
473
+ this.roleGate = { promise, release };
474
+ this.roleGateTimer = setTimeout(() => {
475
+ // Nothing adopted us in time: reject the parked ops (retryable) and drop
476
+ // the gate so later calls fail fast with 'not connected'.
477
+ rejectFn(new BrokerPortClosedError(`no leader adopted this tab: ${reason}`));
478
+ this.roleGate = null;
479
+ this.roleGateTimer = null;
480
+ }, 20_000);
481
+ }
482
+
483
+ private closeRoleGate(): void {
484
+ if (this.roleGateTimer) clearTimeout(this.roleGateTimer);
485
+ this.roleGateTimer = null;
486
+ this.roleGate?.release();
487
+ this.roleGate = null;
488
+ }
489
+
490
+ /**
491
+ * Become the store owner (leader). Boot and failover share this path; a
492
+ * failover (a previous transport existed) bumps the epoch FIRST so every
493
+ * in-flight chain that captured the old epoch fences itself. `resumeHeld`
494
+ * keeps the live worker after a broker restart and only rolls the
495
+ * per-leadership lock forward.
496
+ */
497
+ async adoptOwner(
498
+ bucketId: string,
499
+ opts: {
500
+ workerLockName: string;
501
+ allowMemoryFallback: boolean;
502
+ resumeHeld: boolean;
503
+ }
504
+ ): Promise<StorageHealth> {
505
+ this.roleLabel = 'leader';
506
+ return this.chainTransition(async () => {
507
+ if (
508
+ opts.resumeHeld &&
509
+ this.transport?.kind === 'worker' &&
510
+ this.transport.connected &&
511
+ this.bucketId === bucketId
512
+ ) {
513
+ await this.rawCall('relock', { workerLockName: opts.workerLockName });
514
+ this.closeRoleGate();
515
+ return this.storageHealthValue;
516
+ }
517
+ if (this.hadStore) this.storeEpoch++;
518
+ if (this.transport) {
519
+ try {
520
+ if (this.transport.kind === 'worker') await this.rawCall('close');
521
+ } catch {
522
+ /* ignore */
523
+ }
524
+ this.transport.close('adopting ownership');
525
+ this.transport = null;
526
+ }
527
+ await this.openInternal(bucketId, {
528
+ workerLockName: opts.workerLockName,
529
+ openOptions: {
530
+ ...PROMOTION_OPEN_OPTIONS,
531
+ disallowMemoryFallback: !opts.allowMemoryFallback,
532
+ },
533
+ });
534
+ // Leader wipe-on-pool-open: local `_00_query` rows from earlier sessions
535
+ // are dead (query hashes are session-salted) and this is the one moment
536
+ // no other tab is attached, so clearing here replaces the per-switch
537
+ // DELETE that solo mode does in doSwitchBucket. Followers never wipe.
538
+ try {
539
+ await this.rawCall('run', { sql: 'DELETE FROM "_00_query"' });
540
+ } catch {
541
+ /* fresh bucket: table just seeded, nothing to wipe */
542
+ }
543
+ getStats().roleChanges = (getStats().roleChanges ?? 0) + 1;
544
+ this.hadStore = true;
545
+ this.closeRoleGate();
546
+ return this.storageHealthValue;
547
+ });
548
+ }
549
+
550
+ /** Attach to a leader's worker through `dbPort` (follower). */
551
+ async adoptAttached(
552
+ dbPort: MessagePort,
553
+ snapshot: { bucketId: string; storageHealth: StorageHealth },
554
+ onPortDead: (reason: string) => void
555
+ ): Promise<void> {
556
+ this.roleLabel = 'follower';
557
+ return this.chainTransition(async () => {
558
+ if (this.hadStore) this.storeEpoch++;
559
+ this.transport?.close('adopting leader port');
560
+ this.transport = new PortSqliteTransport(dbPort, onPortDead, this.logger);
561
+ this.bucketId = snapshot.bucketId;
562
+ // The shared store exists and is seeded; mirror the owner's bookkeeping.
563
+ this.knownTables.clear();
564
+ for (const t of SYSTEM_TABLES) this.knownTables.add(t);
565
+ const health: StorageHealth = { ...snapshot.storageHealth, role: 'follower' };
566
+ this.setStorageHealth(health);
567
+ const stats = getStats();
568
+ stats.persisted = snapshot.storageHealth.status === 'persistent';
569
+ stats.roleChanges = (stats.roleChanges ?? 0) + 1;
570
+ delete stats.opfsError;
571
+ this.hadStore = true;
572
+ this.closeRoleGate();
573
+ });
574
+ }
575
+
576
+ /** Demoted while owning the store (zombie thaw, stale promotion): tear the
577
+ * worker down so its OPFS handles free up, then park until re-adopted. */
578
+ async releaseOwnership(): Promise<void> {
579
+ await this.chainTransition(async () => {
580
+ if (this.transport) {
581
+ try {
582
+ if (this.transport.kind === 'worker') {
583
+ await (this.transport as WorkerSqliteTransport).shutdown();
584
+ }
585
+ } catch {
586
+ /* worker may already be fenced/dead */
587
+ }
588
+ this.transport.close('ownership released');
589
+ this.transport = null;
590
+ }
591
+ this.storeEpoch++;
592
+ this.openRoleGate('ownership released');
593
+ });
594
+ }
595
+
596
+ /** The leader (or its port) died. Called from the port-dead callback and the
597
+ * coordinator; NOT enqueued, so in-flight ops reject immediately instead of
598
+ * waiting behind whatever is stuck. */
599
+ onLeaderLost(reason: string): void {
600
+ if (this.transport?.kind === 'port') {
601
+ this.transport.close(reason);
602
+ this.transport = null;
603
+ }
604
+ this.storeEpoch++;
605
+ this.openRoleGate(reason);
606
+ }
607
+
608
+ /** Leader side: forward a follower's dbPort into the owned worker. */
609
+ async exposeClientPort(clientId: string, port: MessagePort): Promise<void> {
610
+ if (this.transport?.kind !== 'worker') {
611
+ throw new Error('SqliteCacheEngine: not the store owner');
612
+ }
613
+ await (this.transport as WorkerSqliteTransport).addClientPort(clientId, port);
614
+ }
615
+
616
+ async removeClientPort(clientId: string): Promise<void> {
617
+ if (this.transport?.kind !== 'worker') return;
618
+ await (this.transport as WorkerSqliteTransport).removeClientPort(clientId);
619
+ }
620
+
621
+ /** Graceful pagehide as owner: release OPFS handles NOW so the next leader
622
+ * does not race the browser's worker GC. */
623
+ async shutdownOwnedWorker(): Promise<void> {
624
+ if (this.transport?.kind !== 'worker') return;
625
+ try {
626
+ await (this.transport as WorkerSqliteTransport).shutdown();
627
+ } catch {
628
+ /* ignore */
629
+ }
630
+ this.transport.close('shutdown');
631
+ this.transport = null;
632
+ }
633
+
634
+ private async ensureTable(table: string): Promise<void> {
635
+ if (this.knownTables.has(table)) return;
636
+ await this.call('run', {
637
+ sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
638
+ });
639
+ this.knownTables.add(table);
640
+ }
641
+
642
+ private async execRows(sql: string, bind: unknown[]): Promise<Row[]> {
643
+ const { rows } = await this.call<{ rows: { data: string }[] }>('exec', { sql, bind });
644
+ const s = getStats();
645
+ const t0 = performance.now();
646
+ const out = (rows ?? []).map((r) => {
647
+ s.bytesParsed += r.data.length;
648
+ return reviveRow(r.data);
649
+ });
650
+ s.parseMs += performance.now() - t0;
651
+ s.rowsParsed += out.length;
652
+ return out;
653
+ }
654
+
655
+ // ---- reads ---------------------------------------------------------------
656
+
657
+ async select(plan: QueryPlan, params: Record<string, unknown> = {}): Promise<Row[]> {
658
+ if (this.workerSelect) {
659
+ // ONE round-trip: the worker executes the whole plan (table creation,
660
+ // base select, relation tree, JSON parse) and returns structured-clone
661
+ // rows. The legacy path below pays a postMessage hop per table/relation
662
+ // level plus main-thread parsing — the dominant first-load cost.
663
+ //
664
+ // Normalize before postMessage: class-instance VALUES (RecordId & co) →
665
+ // their `stableKey` string. structuredClone strips a class instance to a
666
+ // bare plain object — and surrealdb's RecordId keeps its data behind
667
+ // getters (no own properties), so it clones to `{}`: the worker would
668
+ // filter on garbage. Applies to params AND to values baked inside the
669
+ // plan (where nodes, relation sub-wheres, window ids).
670
+ // Param KEYS must pass through untouched — `comparisonSql` resolves
671
+ // `paramRef` via hasOwnProperty, and a dropped key silently falls back to
672
+ // the baked literal (the crossed-results class fixed in aa4af79b).
673
+ const normPlan = normalizePlanForClone(plan);
674
+ const normParams: Record<string, unknown> = {};
675
+ for (const [k, v] of Object.entries(params)) normParams[k] = toCloneSafe(v);
676
+ try {
677
+ const res = await this.call<{ rows: Row[]; relationFetches?: number }>('select', {
678
+ plan: normPlan,
679
+ params: normParams,
680
+ });
681
+ getStats().relationFetches += res.relationFetches ?? 0;
682
+ return res.rows ?? [];
683
+ } catch (err) {
684
+ // Stale worker script without the 'select' op: fall back for good.
685
+ if (err instanceof Error && err.message.includes('unknown message')) {
686
+ this.workerSelect = false;
687
+ this.logger.warn(
688
+ { err, Category: 'sp00ky-client::SqliteCacheEngine::select' },
689
+ 'Worker lacks select op (stale script?) — falling back to multi-hop select'
690
+ );
691
+ } else {
692
+ throw err;
693
+ }
694
+ }
695
+ }
696
+ return this.selectLegacy(plan, params);
697
+ }
698
+
699
+ /** Pre-worker-select path: one worker round-trip per table/relation level,
700
+ * rows parsed on the main thread. Kept as the `workerSelect:false` escape
701
+ * hatch and the stale-worker fallback. */
702
+ private async selectLegacy(
703
+ plan: QueryPlan,
704
+ params: Record<string, unknown> = {}
705
+ ): Promise<Row[]> {
706
+ // Window materialization: base rows are exactly `plan.ids`, ordered.
707
+ if (plan.ids) {
708
+ const result = await this.selectByIds(plan.table, plan.ids, {
709
+ select: plan.select,
710
+ orderBy: plan.orderBy,
711
+ });
712
+ await resolveRelations(result, plan.relations, this);
713
+ return result;
714
+ }
715
+ await this.ensureTable(plan.table);
716
+ const bind: unknown[] = [];
717
+ const proj = 'data';
718
+ let sql = `SELECT ${proj} FROM "${plan.table}"`;
719
+ if (plan.where && plan.where.length > 0) {
720
+ sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
721
+ }
722
+ if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
723
+ if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
724
+ if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
725
+ const rows = await this.execRows(sql, bind);
726
+ // Optional projection trimming to match `SELECT <fields>`.
727
+ const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
728
+ await resolveRelations(projected, plan.relations, this);
729
+ return projected;
730
+ }
731
+
732
+ async fetchRelation(req: RelationFetch): Promise<Row[]> {
733
+ getStats().relationFetches++;
734
+ await this.ensureTable(req.table);
735
+ const keys = req.keys.map(stableKey);
736
+ const placeholders = keys.map(() => '?').join(', ');
737
+ const bind: unknown[] = [...keys];
738
+ const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
739
+ let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
740
+ if (req.where && req.where.length > 0) {
741
+ sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
742
+ }
743
+ if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
744
+ const rows = await this.execRows(sql, bind);
745
+ return req.select ? rows.map((r) => project(r, req.select!)) : rows;
746
+ }
747
+
748
+ async selectByIds(
749
+ table: string,
750
+ ids: Id[],
751
+ opts?: { select?: string[]; orderBy?: OrderBy }
752
+ ): Promise<Row[]> {
753
+ if (ids.length === 0) return [];
754
+ await this.ensureTable(table);
755
+ const keys = ids.map(stableKey);
756
+ const placeholders = keys.map(() => '?').join(', ');
757
+ let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
758
+ if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
759
+ let rows = await this.execRows(sql, keys);
760
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
761
+ const pos = new Map(keys.map((k, i) => [k, i]));
762
+ rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
763
+ }
764
+ return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
765
+ }
766
+
767
+ async getById(table: string, id: Id): Promise<Row | null> {
768
+ await this.ensureTable(table);
769
+ const rows = await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]);
770
+ return rows[0] ?? null;
771
+ }
772
+
773
+ // ---- writes --------------------------------------------------------------
774
+
775
+ async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
776
+ await this.ensureTable(table);
777
+ const key = stableKey(id);
778
+ if (mode === 'merge') {
779
+ // Merge in-SQL via json_patch (RFC7396 = MERGE semantics): on insert store
780
+ // the row, on conflict shallow-merge. Serialize once, reuse for VALUES and
781
+ // the patch. No read-modify-write round-trip. (RFC7396: null deletes key.)
782
+ const full = serializeRow({ ...data, id: key });
783
+ await this.call('run', {
784
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
785
+ bind: [key, full, full],
786
+ });
787
+ return;
788
+ }
789
+ await this.call('run', {
790
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
791
+ bind: [key, serializeRow({ ...data, id: key })],
792
+ });
793
+ }
794
+
795
+ async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
796
+ // RFC6902 (fast-json-patch) applied read-modify-write — SQLite's json_patch
797
+ // is RFC7396 merge-patch and would misinterpret op arrays.
798
+ const existing = (await this.getById(table, id)) ?? { id: stableKey(id) };
799
+ const next = applyPatch(existing, patches as Operation[]).newDocument as Row;
800
+ await this.upsert(table, id, next, 'replace');
801
+ }
802
+
803
+ async delete(table: string, id: Id): Promise<void> {
804
+ await this.ensureTable(table);
805
+ await this.call('run', { sql: `DELETE FROM "${table}" WHERE id = ?`, bind: [stableKey(id)] });
806
+ }
807
+
808
+ // ---- SurrealQL-vocabulary shim (LocalStore compatibility) ---------------
809
+
810
+ /**
811
+ * Execute a raw SurrealQL statement by translating the client's bounded
812
+ * vocabulary to verbs (see `surql-translate.ts`). Returns results shaped like
813
+ * SurrealDB's `.query()` (one element per statement; a tx prepends a `null`
814
+ * begin-result so `surql.seal` extraction lines up). Epoch-fences writes.
815
+ */
816
+ async query<T extends unknown[]>(
817
+ sql: string,
818
+ vars: Record<string, unknown> = {},
819
+ opts?: { epoch?: number }
820
+ ): Promise<T> {
821
+ if (opts?.epoch !== undefined && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
822
+ const start = performance.now();
823
+ try {
824
+ const { transaction, ops } = translateSurql(sql, vars);
825
+ let shaped: unknown[];
826
+ // FAST PATH: a pure-write transaction (bulk sync-down is one
827
+ // `tx([upsertMerge…])`) compiles to a SINGLE worker `batch` message run in
828
+ // one SQLite transaction — instead of 1-2 worker round-trips PER row. This
829
+ // is the dominant sync-down cost; per-op execution here caused the churn
830
+ // OOM. Mixed txs (LET/RETURN single-record mutations) keep the per-op path.
831
+ if (
832
+ transaction &&
833
+ ops.every(
834
+ (o) =>
835
+ o.kind === 'upsert' ||
836
+ o.kind === 'delete' ||
837
+ o.kind === 'deleteAll' ||
838
+ o.kind === 'noop'
839
+ )
840
+ ) {
841
+ await this.runWriteBatch(ops);
842
+ // Shape each statement's result the SAME as the per-op path (`execOp`),
843
+ // so a caller reading a statement's output still works after taking the
844
+ // batch fast path. A single `create()` compiles to an all-upsert tx
845
+ // (createSet + createMutation) and reads `resultIndex:0` for the new
846
+ // row + its id; returning empty arrays here dropped that row (id became
847
+ // undefined → the reconcile crashed in `encodeRecordId`). The
848
+ // single-batch write is kept — this only rebuilds the return value.
849
+ shaped = [null, ...ops.map(pureWriteOpResult)];
850
+ } else {
851
+ const results: unknown[] = [];
852
+ // Per-query scope holds `LET $var = (...)` bindings for later statements
853
+ // (e.g. `RETURN { target: $updated }`).
854
+ const scope: Record<string, unknown> = {};
855
+ for (const op of ops) results.push(await this.execOp(op, scope, vars));
856
+ shaped = transaction ? [null, ...results] : results;
857
+ }
858
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
859
+ query: sql,
860
+ vars,
861
+ duration: performance.now() - start,
862
+ success: true,
863
+ timestamp: Date.now(),
864
+ });
865
+ return shaped as unknown as T;
866
+ } catch (err) {
867
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
868
+ query: sql,
869
+ vars,
870
+ duration: performance.now() - start,
871
+ success: false,
872
+ error: err instanceof Error ? err.message : String(err),
873
+ timestamp: Date.now(),
874
+ });
875
+ throw err;
876
+ }
877
+ }
878
+
879
+ async execute<R>(
880
+ query: SealedQuery<R>,
881
+ vars?: Record<string, unknown>,
882
+ opts?: { epoch?: number }
883
+ ): Promise<R> {
884
+ const raw = await this.query<unknown[]>(query.sql, vars, opts);
885
+ return query.extract(raw);
886
+ }
887
+
888
+ queryUngated<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
889
+ return this.query<T>(sql, vars);
890
+ }
891
+
892
+ private async execOp(
893
+ op: SqlOp,
894
+ scope: Record<string, unknown>,
895
+ vars: Record<string, unknown>
896
+ ): Promise<unknown> {
897
+ switch (op.kind) {
898
+ case 'getById': {
899
+ const row = await this.getById(tableOf(op.id), op.id);
900
+ if (op.value) return row ? (row[op.value] ?? null) : null;
901
+ return row ? (op.select ? project(row, op.select) : row) : null;
902
+ }
903
+ case 'selectByIds': {
904
+ if (op.ids.length === 0) return [];
905
+ let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
906
+ select: op.select,
907
+ orderBy: op.orderBy,
908
+ });
909
+ if (op.value) return rows.map((r) => r[op.value!]);
910
+ return rows;
911
+ }
912
+ case 'selectTable': {
913
+ const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
914
+ if (op.value) return rows.map((r) => r[op.value!]);
915
+ return op.select ? rows.map((r) => project(r, op.select!)) : rows;
916
+ }
917
+ case 'upsert':
918
+ await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
919
+ // Cheap return — no read-back. The full merged row is only needed by a
920
+ // LET-wrapped upsert, which reads it back in the 'let' case below. This
921
+ // avoids an extra worker round-trip + full-row parse on EVERY sync-down
922
+ // write (the hot path under rapid churn). Shared with the batch fast
923
+ // path so the two never drift.
924
+ return pureWriteOpResult(op);
925
+ case 'updateSet': {
926
+ const existing = (await this.getById(tableOf(op.id), op.id)) ?? { id: stableKey(op.id) };
927
+ for (const { path, op: setOp, value } of op.sets) {
928
+ if (setOp === '+=' || setOp === '-=') {
929
+ const cur = Number(getPath(existing, path) ?? 0);
930
+ const delta = Number(value ?? 0);
931
+ setPath(existing, path, setOp === '+=' ? cur + delta : cur - delta);
932
+ } else {
933
+ setPath(existing, path, value);
934
+ }
935
+ }
936
+ await this.upsert(tableOf(op.id), op.id, existing, 'replace');
937
+ return op.returnNone ? null : existing;
938
+ }
939
+ case 'delete':
940
+ await this.delete(tableOf(op.id), op.id);
941
+ return pureWriteOpResult(op);
942
+ case 'deleteAll':
943
+ await this.ensureTable(op.table);
944
+ await this.call('run', { sql: `DELETE FROM "${op.table}"` });
945
+ return pureWriteOpResult(op);
946
+ case 'let': {
947
+ let result = await this.execOp(op.inner, scope, vars);
948
+ // A LET-bound UPSERT must expose the FULL merged row (e.g.
949
+ // `RETURN { target: $updated }`), so read it back here — only here,
950
+ // not on every upsert.
951
+ if (op.inner.kind === 'upsert') {
952
+ result = (await this.getById(tableOf(op.inner.id), op.inner.id)) ?? result;
953
+ }
954
+ scope[op.var] = result;
955
+ return result;
956
+ }
957
+ case 'return': {
958
+ const obj: Row = {};
959
+ for (const { key, var: v } of op.entries) {
960
+ obj[key] = v in scope ? scope[v] : vars[v];
961
+ }
962
+ return obj;
963
+ }
964
+ case 'noop':
965
+ return pureWriteOpResult(op);
966
+ }
967
+ }
968
+
969
+ /**
970
+ * Compile a pure-write op list to ONE worker `batch` (single SQLite
971
+ * transaction). Ensures each touched table exists, then one statement per op.
972
+ * Merges happen in-SQL via json_patch — no read-back round-trips.
973
+ */
974
+ private async runWriteBatch(ops: SqlOp[]): Promise<void> {
975
+ const stmts: { sql: string; bind?: unknown[] }[] = [];
976
+ const tables = new Set<string>();
977
+ const ensure = (t: string) => {
978
+ if (!tables.has(t)) {
979
+ tables.add(t);
980
+ this.knownTables.add(t);
981
+ stmts.push({
982
+ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
983
+ });
984
+ }
985
+ };
986
+ for (const op of ops) {
987
+ if (op.kind === 'upsert') {
988
+ const t = tableOf(op.id);
989
+ const key = stableKey(op.id);
990
+ ensure(t);
991
+ if (op.mode === 'merge') {
992
+ // Serialize ONCE and reuse for both VALUES (fresh insert) and the
993
+ // json_patch (merge). Patching with `id` is a harmless no-op set, so
994
+ // the full row doubles as the delta — halves per-row stringify cost.
995
+ const full = serializeRow({ ...op.data, id: key });
996
+ stmts.push({
997
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
998
+ bind: [key, full, full],
999
+ });
1000
+ } else {
1001
+ stmts.push({
1002
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
1003
+ bind: [key, serializeRow({ ...op.data, id: key })],
1004
+ });
1005
+ }
1006
+ } else if (op.kind === 'delete') {
1007
+ const t = tableOf(op.id);
1008
+ ensure(t);
1009
+ stmts.push({ sql: `DELETE FROM "${t}" WHERE id = ?`, bind: [stableKey(op.id)] });
1010
+ } else if (op.kind === 'deleteAll') {
1011
+ ensure(op.table);
1012
+ stmts.push({ sql: `DELETE FROM "${op.table}"` });
1013
+ }
1014
+ }
1015
+ if (stmts.length > 0) await this.call('batch', stmts);
1016
+ }
1017
+
1018
+ private async rawSelectTable(
1019
+ table: string,
1020
+ where?: WhereNode[],
1021
+ orderBy?: OrderBy
1022
+ ): Promise<Row[]> {
1023
+ await this.ensureTable(table);
1024
+ const bind: unknown[] = [];
1025
+ let sql = `SELECT data FROM "${table}"`;
1026
+ if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
1027
+ if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
1028
+ return this.execRows(sql, bind);
1029
+ }
1030
+
1031
+ async transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
1032
+ // Verbs run in order on the single worker message channel. `patch` needs a
1033
+ // read-modify-write round-trip, so a single BEGIN/COMMIT batch cannot wrap
1034
+ // the whole closure; sequential execution is sufficient for the current
1035
+ // single-record write sites.
1036
+ const tx: EngineTx = {
1037
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
1038
+ patch: (t, id, p) => this.patch(t, id, p),
1039
+ delete: (t, id) => this.delete(t, id),
1040
+ };
1041
+ return fn(tx);
1042
+ }
1043
+ }
1044
+
1045
+ // ==================== instrumentation ====================
1046
+
1047
+ interface SqliteStats {
1048
+ roundTrips: number;
1049
+ batchStatements: number;
1050
+ maxBatch: number;
1051
+ inFlight: number;
1052
+ maxInFlight: number;
1053
+ byType: Record<string, number>;
1054
+ /** Time ops spent waiting behind the opQueue before dispatch. */
1055
+ queueWaitMs: number;
1056
+ /** Time inside the worker's message handler (actual SQLite work). */
1057
+ workerMs: number;
1058
+ /** Round-trip time minus workerMs: postMessage + scheduling overhead. */
1059
+ rpcOverheadMs: number;
1060
+ /** Main-thread JSON parse/revive of returned rows. */
1061
+ parseMs: number;
1062
+ rowsParsed: number;
1063
+ bytesParsed: number;
1064
+ /** Relation-resolver fan-out fetches (one worker round-trip each). */
1065
+ relationFetches: number;
1066
+ /** Whether the open store is OPFS-backed. `false` here with an `opfsError`
1067
+ * means the whole dataset is sitting in RAM. Optional so it stays absent
1068
+ * until the first open (and is skipped by the backfill loop below). */
1069
+ persisted?: boolean;
1070
+ /** Why OPFS persistence failed, when it did. */
1071
+ opfsError?: string;
1072
+ /** Shared-tabs follower: ops that crossed the leader's MessagePort. */
1073
+ proxiedOps?: number;
1074
+ /** Times this tab's engine changed hands (promotions + attachments). */
1075
+ roleChanges?: number;
1076
+ }
1077
+
1078
+ const EMPTY_STATS: SqliteStats = {
1079
+ roundTrips: 0,
1080
+ batchStatements: 0,
1081
+ maxBatch: 0,
1082
+ inFlight: 0,
1083
+ maxInFlight: 0,
1084
+ byType: {},
1085
+ queueWaitMs: 0,
1086
+ workerMs: 0,
1087
+ rpcOverheadMs: 0,
1088
+ parseMs: 0,
1089
+ rowsParsed: 0,
1090
+ bytesParsed: 0,
1091
+ relationFetches: 0,
1092
+ };
1093
+
1094
+ /** Live stats, inspectable in the browser console via `__sqliteStats`. Counts
1095
+ * worker round-trips (the sync-down cost driver), batch sizes, queue depth
1096
+ * (`maxInFlight`), and the latency split of each round-trip (queue wait vs
1097
+ * worker time vs RPC overhead vs main-thread row parsing) so first-load cost
1098
+ * can be measured rather than guessed. */
1099
+ function getStats(): SqliteStats {
1100
+ const g = globalThis as unknown as { __sqliteStats?: SqliteStats };
1101
+ if (!g.__sqliteStats) {
1102
+ g.__sqliteStats = { ...EMPTY_STATS, byType: {} };
1103
+ } else {
1104
+ // Backfill fields added since the object was created (HMR / older bundle).
1105
+ for (const [k, v] of Object.entries(EMPTY_STATS)) {
1106
+ if ((g.__sqliteStats as any)[k] === undefined) (g.__sqliteStats as any)[k] = v;
1107
+ }
1108
+ }
1109
+ return g.__sqliteStats;
1110
+ }
1111
+
1112
+ // SQL rendering + row (de)serialization live in `sqlite-plan-sql.ts`, shared
1113
+ // with the worker (worker-side plan execution renders the same SQL).
1114
+
1115
+ // ==================== structured-clone normalization ====================
1116
+
1117
+ /**
1118
+ * Make a bind/param value safe to cross the worker boundary. structuredClone
1119
+ * keeps plain data intact but strips a CLASS instance to a bare object — and
1120
+ * surrealdb's `RecordId` stores its fields behind getters (zero own
1121
+ * properties), so it clones to `{}`. Convert such instances to their
1122
+ * `stableKey` string (the exact value `scalar()` would bind on the main
1123
+ * thread), leave everything clone-representable untouched.
1124
+ */
1125
+ function toCloneSafe(v: unknown): unknown {
1126
+ if (v === null || typeof v !== 'object') return v;
1127
+ if (Array.isArray(v) || v instanceof Uint8Array || v instanceof Date) return v;
1128
+ const proto = Object.getPrototypeOf(v);
1129
+ if (proto === Object.prototype || proto === null) return v;
1130
+ return stableKey(v);
1131
+ }
1132
+
1133
+ function normalizeWhereForClone(nodes: WhereNode[] | undefined): WhereNode[] | undefined {
1134
+ if (!nodes) return nodes;
1135
+ return nodes.map((n) =>
1136
+ 'or' in n
1137
+ ? { or: n.or.map((c) => ({ ...c, value: toCloneSafe(c.value) })) }
1138
+ : { ...n, value: toCloneSafe(n.value) }
1139
+ );
1140
+ }
1141
+
1142
+ function normalizeRelationForClone(r: RelationPlan): RelationPlan {
1143
+ return {
1144
+ ...r,
1145
+ where: normalizeWhereForClone(r.where),
1146
+ relations: r.relations?.map(normalizeRelationForClone),
1147
+ };
1148
+ }
1149
+
1150
+ /** Normalize every baked value a plan carries (where trees, window ids) for
1151
+ * the postMessage to the worker's `select` op. */
1152
+ function normalizePlanForClone(plan: QueryPlan): QueryPlan {
1153
+ return {
1154
+ ...plan,
1155
+ ids: plan.ids?.map(stableKey),
1156
+ where: normalizeWhereForClone(plan.where),
1157
+ relations: plan.relations?.map(normalizeRelationForClone),
1158
+ };
1159
+ }