@spooky-sync/core 0.0.1-canary.11 → 0.0.1-canary.110

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 (86) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +1033 -372
  3. package/dist/index.js +5490 -1331
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/sqlite-worker.d.ts +1 -0
  7. package/dist/sqlite-worker.js +107 -0
  8. package/dist/types.d.ts +721 -0
  9. package/package.json +40 -9
  10. package/skills/sp00ky-core/SKILL.md +258 -0
  11. package/skills/sp00ky-core/references/auth.md +98 -0
  12. package/skills/sp00ky-core/references/config.md +76 -0
  13. package/src/build-globals.d.ts +12 -0
  14. package/src/events/events.test.ts +2 -1
  15. package/src/events/index.ts +3 -0
  16. package/src/index.ts +9 -2
  17. package/src/modules/auth/events/index.ts +2 -1
  18. package/src/modules/auth/index.ts +59 -20
  19. package/src/modules/cache/index.ts +77 -32
  20. package/src/modules/cache/types.ts +2 -2
  21. package/src/modules/crdt/crdt-field.ts +288 -0
  22. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  23. package/src/modules/crdt/index.ts +357 -0
  24. package/src/modules/data/data.rebind.test.ts +147 -0
  25. package/src/modules/data/data.status.test.ts +249 -0
  26. package/src/modules/data/index.ts +996 -125
  27. package/src/modules/data/window-query.test.ts +52 -0
  28. package/src/modules/data/window-query.ts +154 -0
  29. package/src/modules/devtools/index.ts +180 -28
  30. package/src/modules/devtools/versions.test.ts +74 -0
  31. package/src/modules/devtools/versions.ts +81 -0
  32. package/src/modules/feature-flag/index.test.ts +120 -0
  33. package/src/modules/feature-flag/index.ts +209 -0
  34. package/src/modules/ref-tables.test.ts +91 -0
  35. package/src/modules/ref-tables.ts +88 -0
  36. package/src/modules/sync/engine.ts +101 -37
  37. package/src/modules/sync/events/index.ts +9 -2
  38. package/src/modules/sync/queue/queue-down.ts +12 -5
  39. package/src/modules/sync/queue/queue-up.ts +29 -14
  40. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  41. package/src/modules/sync/scheduler.ts +73 -7
  42. package/src/modules/sync/sync.health.test.ts +149 -0
  43. package/src/modules/sync/sync.ts +1017 -62
  44. package/src/modules/sync/utils.test.ts +269 -2
  45. package/src/modules/sync/utils.ts +182 -17
  46. package/src/otel/index.ts +127 -0
  47. package/src/services/database/cache-engine.ts +143 -0
  48. package/src/services/database/database.ts +11 -11
  49. package/src/services/database/engine-factory.ts +32 -0
  50. package/src/services/database/events/index.ts +2 -1
  51. package/src/services/database/index.ts +6 -0
  52. package/src/services/database/local-migrator.ts +27 -27
  53. package/src/services/database/local.test.ts +64 -0
  54. package/src/services/database/local.ts +452 -66
  55. package/src/services/database/plan-render.test.ts +85 -0
  56. package/src/services/database/plan-render.ts +86 -0
  57. package/src/services/database/relation-resolver.test.ts +413 -0
  58. package/src/services/database/relation-resolver.ts +0 -0
  59. package/src/services/database/remote.ts +13 -13
  60. package/src/services/database/sqlite-cache-engine.test.ts +85 -0
  61. package/src/services/database/sqlite-cache-engine.ts +693 -0
  62. package/src/services/database/sqlite-worker.ts +116 -0
  63. package/src/services/database/surql-translate.ts +291 -0
  64. package/src/services/database/surreal-cache-engine.ts +122 -0
  65. package/src/services/logger/index.ts +6 -101
  66. package/src/services/persistence/localstorage.ts +2 -2
  67. package/src/services/persistence/resilient.ts +41 -0
  68. package/src/services/persistence/surrealdb.ts +10 -10
  69. package/src/services/stream-processor/index.ts +295 -38
  70. package/src/services/stream-processor/permissions.test.ts +47 -0
  71. package/src/services/stream-processor/permissions.ts +53 -0
  72. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  73. package/src/services/stream-processor/stream-processor.reset.test.ts +104 -0
  74. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  75. package/src/services/stream-processor/wasm-types.ts +18 -2
  76. package/src/sp00ky.auth-order.test.ts +92 -0
  77. package/src/sp00ky.ts +795 -0
  78. package/src/types.ts +231 -15
  79. package/src/utils/error-classification.test.ts +44 -0
  80. package/src/utils/error-classification.ts +7 -0
  81. package/src/utils/index.ts +35 -13
  82. package/src/utils/parser.ts +3 -2
  83. package/src/utils/surql.ts +24 -15
  84. package/src/utils/withRetry.test.ts +1 -1
  85. package/tsdown.config.ts +64 -1
  86. package/src/spooky.ts +0 -392
@@ -0,0 +1,693 @@
1
+ import { applyPatch, type Operation } from 'fast-json-patch';
2
+ import type { QueryPlan, WhereNode, WhereComparison } from '@spooky-sync/query-builder';
3
+ import type { Logger } from '../logger/index';
4
+ import type { Sp00kyConfig } from '../../types';
5
+ import type { SealedQuery } from '../../utils/surql';
6
+ import { resolveRelations, stableKey } from './relation-resolver';
7
+ import {
8
+ createDatabaseEventSystem,
9
+ DatabaseEventTypes,
10
+ type DatabaseEventSystem,
11
+ } from './events/index';
12
+ import { StaleEpochError } from './local';
13
+ import { translateSurql, tableOf, setPath, getPath, type SqlOp } from './surql-translate';
14
+ import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
15
+
16
+ /**
17
+ * The statement result a pure-write op contributes to a query's results array.
18
+ * Single source of truth shared by the per-op path (`execOp`) and the batched
19
+ * fast path in `query()`, so the two can never diverge: a caller that reads a
20
+ * statement's output sees the same shape whether or not the transaction took the
21
+ * batch fast path. In particular `create()` compiles to an all-upsert tx
22
+ * (`createSet` + `createMutation`) and reads `resultIndex:0` for the new row and
23
+ * its id — the fast path previously returned empty arrays there, so the row (and
24
+ * its id) was lost and the reconcile crashed in `encodeRecordId`.
25
+ *
26
+ * An upsert echoes the written row (`{...data, id}`) with no read-back — the
27
+ * full merged row is only materialized for a LET-wrapped upsert (see the 'let'
28
+ * case). delete/deleteAll yield `[]`; noop yields `null`.
29
+ */
30
+ export function pureWriteOpResult(op: SqlOp): unknown {
31
+ switch (op.kind) {
32
+ case 'upsert':
33
+ return { ...op.data, id: stableKey(op.id) };
34
+ case 'noop':
35
+ return null;
36
+ default:
37
+ return [];
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Local cache backend on official SQLite-WASM in a dedicated Worker (see
43
+ * `sqlite-worker.ts`), with OPFS SAHPool persistence. Storage model: one table
44
+ * per schema table, `id TEXT PRIMARY KEY, data TEXT` where `data` is the row as
45
+ * JSON. Filtering/ordering use `json_extract`. Relations are decomposed by the
46
+ * shared {@link resolveRelations} — identical to the SurrealDB backend.
47
+ *
48
+ * Value normalization (JSON round-trip):
49
+ * - `Uint8Array`/bytes → `{ "__u8": <base64> }` (CRDT snapshots survive).
50
+ * - Record links / ids → their `table:id` string form (so `json_extract`
51
+ * comparisons and `IN` matching are consistent). NOTE: link fields therefore
52
+ * read back as strings, not `RecordId` instances — the one shape difference
53
+ * from the SurrealDB backend, to be closed with schema-driven revival + an
54
+ * oracle E2E in the browser.
55
+ */
56
+ export class SqliteCacheEngine implements LocalStore {
57
+ private worker: Worker | null = null;
58
+ private seq = 0;
59
+ private pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>();
60
+ private storeEpoch = 0;
61
+ private knownTables = new Set<string>();
62
+ private useOpfs: boolean;
63
+ private events: DatabaseEventSystem = createDatabaseEventSystem();
64
+ private bucketId = 'anon';
65
+ /** Schemaless — tables are created lazily on first write; no migrator. */
66
+ readonly usesSurqlSchema = false;
67
+
68
+ constructor(
69
+ private config: Sp00kyConfig<any>['database'],
70
+ private logger: Logger,
71
+ opts: { useOpfs?: boolean } = {}
72
+ ) {
73
+ this.useOpfs = opts.useOpfs ?? true;
74
+ }
75
+
76
+ get epoch(): number {
77
+ return this.storeEpoch;
78
+ }
79
+
80
+ get currentBucketId(): string {
81
+ return this.bucketId;
82
+ }
83
+
84
+ getConfig(): Sp00kyConfig<any>['database'] {
85
+ return this.config;
86
+ }
87
+
88
+ getEvents(): DatabaseEventSystem {
89
+ return this.events;
90
+ }
91
+
92
+ getClient(): unknown {
93
+ throw new Error('SqliteCacheEngine has no SurrealDB client (getClient is unavailable).');
94
+ }
95
+
96
+ /** LocalStore alias; SQLite has no in-flight gate, so this maps to a rebuild. */
97
+ switchStore(bucketId: string): Promise<void> {
98
+ return this.switchBucket(bucketId);
99
+ }
100
+
101
+ /** SQLite has no switch gate window; the epoch bump alone fences stale writes. */
102
+ beginSwitch(): () => void {
103
+ return () => {};
104
+ }
105
+
106
+ // ---- worker plumbing -----------------------------------------------------
107
+
108
+ private spawnWorker(): Worker {
109
+ // The worker is a separate module entry; the bundler rewrites this URL.
110
+ const worker = new Worker(new URL('./sqlite-worker.js', import.meta.url), { type: 'module' });
111
+ worker.onmessage = (ev: MessageEvent) => {
112
+ const { id, ok, error, ...rest } = ev.data ?? {};
113
+ const p = this.pending.get(id);
114
+ if (!p) return;
115
+ this.pending.delete(id);
116
+ if (ok) p.resolve(rest);
117
+ else p.reject(new Error(error));
118
+ };
119
+ // Surface a worker crash (wasm abort / OOM) instead of leaving every pending
120
+ // call hung forever — reject them all with a clear error.
121
+ const failAll = (msg: string) => {
122
+ const err = new Error(`SQLite worker crashed: ${msg}`);
123
+ this.logger.error(
124
+ { err, Category: 'sp00ky-client::SqliteCacheEngine::worker' },
125
+ 'Worker error'
126
+ );
127
+ for (const [, p] of this.pending) p.reject(err);
128
+ this.pending.clear();
129
+ };
130
+ worker.onerror = (e: ErrorEvent) => failAll(e.message || 'onerror');
131
+ worker.onmessageerror = () => failAll('messageerror');
132
+ return worker;
133
+ }
134
+
135
+ /** Serializes every worker op so reads/writes never overlap at the VFS layer
136
+ * (overlapping ops trip SQLITE_BUSY). Mirrors the SurrealDB engine's
137
+ * single-flight query queue. */
138
+ private opQueue: Promise<unknown> = Promise.resolve();
139
+
140
+ private call<T = any>(type: string, payload?: unknown): Promise<T> {
141
+ const run = () => this.rawCall<T>(type, payload);
142
+ const result = this.opQueue.then(run, run);
143
+ // Keep the chain alive regardless of individual failures.
144
+ this.opQueue = result.then(
145
+ () => undefined,
146
+ () => undefined
147
+ );
148
+ return result;
149
+ }
150
+
151
+ private rawCall<T = any>(type: string, payload?: unknown): Promise<T> {
152
+ if (!this.worker) throw new Error('SqliteCacheEngine: not connected');
153
+ const id = ++this.seq;
154
+ // --- instrumentation: live, inspectable via `globalThis.__sqliteStats` ---
155
+ const s = getStats();
156
+ s.roundTrips++;
157
+ s.byType[type] = (s.byType[type] ?? 0) + 1;
158
+ if (type === 'batch' && Array.isArray(payload)) {
159
+ s.batchStatements += payload.length;
160
+ s.maxBatch = Math.max(s.maxBatch, payload.length);
161
+ }
162
+ s.inFlight++;
163
+ s.maxInFlight = Math.max(s.maxInFlight, s.inFlight);
164
+ const done = () => {
165
+ s.inFlight--;
166
+ };
167
+ return new Promise<T>((resolve, reject) => {
168
+ this.pending.set(id, {
169
+ resolve: (v: T) => {
170
+ done();
171
+ resolve(v);
172
+ },
173
+ reject: (e: unknown) => {
174
+ done();
175
+ reject(e);
176
+ },
177
+ });
178
+ this.worker!.postMessage({ id, type, payload });
179
+ });
180
+ }
181
+
182
+ async connect(bucketId: string): Promise<void> {
183
+ this.worker = this.spawnWorker();
184
+ const { persisted } = await this.call<{ persisted: boolean }>('open', {
185
+ dbName: bucketId,
186
+ useOpfs: this.useOpfs,
187
+ });
188
+ this.knownTables.clear();
189
+ this.bucketId = bucketId;
190
+ this.logger.info(
191
+ { bucketId, persisted, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
192
+ persisted ? 'SQLite OPFS store opened' : 'SQLite in-memory store opened (no OPFS)'
193
+ );
194
+ }
195
+
196
+ async switchBucket(bucketId: string): Promise<void> {
197
+ this.storeEpoch++;
198
+ if (this.worker) {
199
+ try {
200
+ await this.call('close');
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ this.worker.terminate();
205
+ this.worker = null;
206
+ }
207
+ await this.connect(bucketId);
208
+ }
209
+
210
+ async close(): Promise<void> {
211
+ if (!this.worker) return;
212
+ try {
213
+ await this.call('close');
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ this.worker.terminate();
218
+ this.worker = null;
219
+ }
220
+
221
+ private async ensureTable(table: string): Promise<void> {
222
+ if (this.knownTables.has(table)) return;
223
+ await this.call('run', {
224
+ sql: `CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
225
+ });
226
+ this.knownTables.add(table);
227
+ }
228
+
229
+ private async execRows(sql: string, bind: unknown[]): Promise<Row[]> {
230
+ const { rows } = await this.call<{ rows: { data: string }[] }>('exec', { sql, bind });
231
+ return (rows ?? []).map((r) => reviveRow(r.data));
232
+ }
233
+
234
+ // ---- reads ---------------------------------------------------------------
235
+
236
+ async select(plan: QueryPlan, params: Record<string, unknown> = {}): Promise<Row[]> {
237
+ // Window materialization: base rows are exactly `plan.ids`, ordered.
238
+ if (plan.ids) {
239
+ const result = await this.selectByIds(plan.table, plan.ids, {
240
+ select: plan.select,
241
+ orderBy: plan.orderBy,
242
+ });
243
+ await resolveRelations(result, plan.relations, this);
244
+ return result;
245
+ }
246
+ await this.ensureTable(plan.table);
247
+ const bind: unknown[] = [];
248
+ const proj = 'data';
249
+ let sql = `SELECT ${proj} FROM "${plan.table}"`;
250
+ if (plan.where && plan.where.length > 0) {
251
+ sql += ` WHERE ${renderWhereSql(plan.where, bind, params)}`;
252
+ }
253
+ if (plan.orderBy && plan.orderBy.length > 0) sql += renderOrderSql(plan.orderBy);
254
+ if (plan.limit !== undefined) sql += ` LIMIT ${Number(plan.limit)}`;
255
+ if (plan.offset !== undefined) sql += ` OFFSET ${Number(plan.offset)}`;
256
+ const rows = await this.execRows(sql, bind);
257
+ // Optional projection trimming to match `SELECT <fields>`.
258
+ const projected = plan.select ? rows.map((r) => project(r, plan.select!)) : rows;
259
+ await resolveRelations(projected, plan.relations, this);
260
+ return projected;
261
+ }
262
+
263
+ async fetchRelation(req: RelationFetch): Promise<Row[]> {
264
+ await this.ensureTable(req.table);
265
+ const keys = req.keys.map(stableKey);
266
+ const placeholders = keys.map(() => '?').join(', ');
267
+ const bind: unknown[] = [...keys];
268
+ const lhs = req.matchField === 'id' ? 'id' : `json_extract(data, '$.${req.matchField}')`;
269
+ let sql = `SELECT data FROM "${req.table}" WHERE ${lhs} IN (${placeholders})`;
270
+ if (req.where && req.where.length > 0) {
271
+ sql += ` AND ${renderWhereSql(req.where, bind, {})}`;
272
+ }
273
+ if (req.orderBy && req.orderBy.length > 0) sql += renderOrderSql(req.orderBy);
274
+ const rows = await this.execRows(sql, bind);
275
+ return req.select ? rows.map((r) => project(r, req.select!)) : rows;
276
+ }
277
+
278
+ async selectByIds(
279
+ table: string,
280
+ ids: Id[],
281
+ opts?: { select?: string[]; orderBy?: OrderBy }
282
+ ): Promise<Row[]> {
283
+ if (ids.length === 0) return [];
284
+ await this.ensureTable(table);
285
+ const keys = ids.map(stableKey);
286
+ const placeholders = keys.map(() => '?').join(', ');
287
+ let sql = `SELECT data FROM "${table}" WHERE id IN (${placeholders})`;
288
+ if (opts?.orderBy && opts.orderBy.length > 0) sql += renderOrderSql(opts.orderBy);
289
+ let rows = await this.execRows(sql, keys);
290
+ if (!opts?.orderBy || opts.orderBy.length === 0) {
291
+ const pos = new Map(keys.map((k, i) => [k, i]));
292
+ rows = rows.sort((a, b) => (pos.get(stableKey(a.id)) ?? 0) - (pos.get(stableKey(b.id)) ?? 0));
293
+ }
294
+ return opts?.select ? rows.map((r) => project(r, opts.select!)) : rows;
295
+ }
296
+
297
+ async getById(table: string, id: Id): Promise<Row | null> {
298
+ await this.ensureTable(table);
299
+ const rows = await this.execRows(`SELECT data FROM "${table}" WHERE id = ?`, [stableKey(id)]);
300
+ return rows[0] ?? null;
301
+ }
302
+
303
+ // ---- writes --------------------------------------------------------------
304
+
305
+ async upsert(table: string, id: Id, data: Row, mode: 'replace' | 'merge'): Promise<void> {
306
+ await this.ensureTable(table);
307
+ const key = stableKey(id);
308
+ if (mode === 'merge') {
309
+ // Merge in-SQL via json_patch (RFC7396 = MERGE semantics): on insert store
310
+ // the row, on conflict shallow-merge. Serialize once, reuse for VALUES and
311
+ // the patch. No read-modify-write round-trip. (RFC7396: null deletes key.)
312
+ const full = serializeRow({ ...data, id: key });
313
+ await this.call('run', {
314
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
315
+ bind: [key, full, full],
316
+ });
317
+ return;
318
+ }
319
+ await this.call('run', {
320
+ sql: `INSERT INTO "${table}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
321
+ bind: [key, serializeRow({ ...data, id: key })],
322
+ });
323
+ }
324
+
325
+ async patch(table: string, id: Id, patches: unknown[]): Promise<void> {
326
+ // RFC6902 (fast-json-patch) applied read-modify-write — SQLite's json_patch
327
+ // is RFC7396 merge-patch and would misinterpret op arrays.
328
+ const existing = (await this.getById(table, id)) ?? { id: stableKey(id) };
329
+ const next = applyPatch(existing, patches as Operation[]).newDocument as Row;
330
+ await this.upsert(table, id, next, 'replace');
331
+ }
332
+
333
+ async delete(table: string, id: Id): Promise<void> {
334
+ await this.ensureTable(table);
335
+ await this.call('run', { sql: `DELETE FROM "${table}" WHERE id = ?`, bind: [stableKey(id)] });
336
+ }
337
+
338
+ // ---- SurrealQL-vocabulary shim (LocalStore compatibility) ---------------
339
+
340
+ /**
341
+ * Execute a raw SurrealQL statement by translating the client's bounded
342
+ * vocabulary to verbs (see `surql-translate.ts`). Returns results shaped like
343
+ * SurrealDB's `.query()` (one element per statement; a tx prepends a `null`
344
+ * begin-result so `surql.seal` extraction lines up). Epoch-fences writes.
345
+ */
346
+ async query<T extends unknown[]>(
347
+ sql: string,
348
+ vars: Record<string, unknown> = {},
349
+ opts?: { epoch?: number }
350
+ ): Promise<T> {
351
+ if (opts?.epoch !== undefined && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
352
+ const start = performance.now();
353
+ try {
354
+ const { transaction, ops } = translateSurql(sql, vars);
355
+ let shaped: unknown[];
356
+ // FAST PATH: a pure-write transaction (bulk sync-down is one
357
+ // `tx([upsertMerge…])`) compiles to a SINGLE worker `batch` message run in
358
+ // one SQLite transaction — instead of 1-2 worker round-trips PER row. This
359
+ // is the dominant sync-down cost; per-op execution here caused the churn
360
+ // OOM. Mixed txs (LET/RETURN single-record mutations) keep the per-op path.
361
+ if (
362
+ transaction &&
363
+ ops.every(
364
+ (o) =>
365
+ o.kind === 'upsert' ||
366
+ o.kind === 'delete' ||
367
+ o.kind === 'deleteAll' ||
368
+ o.kind === 'noop'
369
+ )
370
+ ) {
371
+ await this.runWriteBatch(ops);
372
+ // Shape each statement's result the SAME as the per-op path (`execOp`),
373
+ // so a caller reading a statement's output still works after taking the
374
+ // batch fast path. A single `create()` compiles to an all-upsert tx
375
+ // (createSet + createMutation) and reads `resultIndex:0` for the new
376
+ // row + its id; returning empty arrays here dropped that row (id became
377
+ // undefined → the reconcile crashed in `encodeRecordId`). The
378
+ // single-batch write is kept — this only rebuilds the return value.
379
+ shaped = [null, ...ops.map(pureWriteOpResult)];
380
+ } else {
381
+ const results: unknown[] = [];
382
+ // Per-query scope holds `LET $var = (...)` bindings for later statements
383
+ // (e.g. `RETURN { target: $updated }`).
384
+ const scope: Record<string, unknown> = {};
385
+ for (const op of ops) results.push(await this.execOp(op, scope, vars));
386
+ shaped = transaction ? [null, ...results] : results;
387
+ }
388
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
389
+ query: sql,
390
+ vars,
391
+ duration: performance.now() - start,
392
+ success: true,
393
+ timestamp: Date.now(),
394
+ });
395
+ return shaped as unknown as T;
396
+ } catch (err) {
397
+ this.events.emit(DatabaseEventTypes.LocalQuery, {
398
+ query: sql,
399
+ vars,
400
+ duration: performance.now() - start,
401
+ success: false,
402
+ error: err instanceof Error ? err.message : String(err),
403
+ timestamp: Date.now(),
404
+ });
405
+ throw err;
406
+ }
407
+ }
408
+
409
+ async execute<R>(
410
+ query: SealedQuery<R>,
411
+ vars?: Record<string, unknown>,
412
+ opts?: { epoch?: number }
413
+ ): Promise<R> {
414
+ const raw = await this.query<unknown[]>(query.sql, vars, opts);
415
+ return query.extract(raw);
416
+ }
417
+
418
+ queryUngated<T extends unknown[]>(sql: string, vars?: Record<string, unknown>): Promise<T> {
419
+ return this.query<T>(sql, vars);
420
+ }
421
+
422
+ private async execOp(
423
+ op: SqlOp,
424
+ scope: Record<string, unknown>,
425
+ vars: Record<string, unknown>
426
+ ): Promise<unknown> {
427
+ switch (op.kind) {
428
+ case 'getById': {
429
+ const row = await this.getById(tableOf(op.id), op.id);
430
+ if (op.value) return row ? (row[op.value] ?? null) : null;
431
+ return row ? (op.select ? project(row, op.select) : row) : null;
432
+ }
433
+ case 'selectByIds': {
434
+ if (op.ids.length === 0) return [];
435
+ let rows = await this.selectByIds(tableOf(op.ids[0]), op.ids, {
436
+ select: op.select,
437
+ orderBy: op.orderBy,
438
+ });
439
+ if (op.value) return rows.map((r) => r[op.value!]);
440
+ return rows;
441
+ }
442
+ case 'selectTable': {
443
+ const rows = await this.rawSelectTable(op.table, op.where, op.orderBy);
444
+ if (op.value) return rows.map((r) => r[op.value!]);
445
+ return op.select ? rows.map((r) => project(r, op.select!)) : rows;
446
+ }
447
+ case 'upsert':
448
+ await this.upsert(tableOf(op.id), op.id, op.data, op.mode);
449
+ // Cheap return — no read-back. The full merged row is only needed by a
450
+ // LET-wrapped upsert, which reads it back in the 'let' case below. This
451
+ // avoids an extra worker round-trip + full-row parse on EVERY sync-down
452
+ // write (the hot path under rapid churn). Shared with the batch fast
453
+ // path so the two never drift.
454
+ return pureWriteOpResult(op);
455
+ case 'updateSet': {
456
+ const existing = (await this.getById(tableOf(op.id), op.id)) ?? { id: stableKey(op.id) };
457
+ for (const { path, op: setOp, value } of op.sets) {
458
+ if (setOp === '+=' || setOp === '-=') {
459
+ const cur = Number(getPath(existing, path) ?? 0);
460
+ const delta = Number(value ?? 0);
461
+ setPath(existing, path, setOp === '+=' ? cur + delta : cur - delta);
462
+ } else {
463
+ setPath(existing, path, value);
464
+ }
465
+ }
466
+ await this.upsert(tableOf(op.id), op.id, existing, 'replace');
467
+ return op.returnNone ? null : existing;
468
+ }
469
+ case 'delete':
470
+ await this.delete(tableOf(op.id), op.id);
471
+ return pureWriteOpResult(op);
472
+ case 'deleteAll':
473
+ await this.ensureTable(op.table);
474
+ await this.call('run', { sql: `DELETE FROM "${op.table}"` });
475
+ return pureWriteOpResult(op);
476
+ case 'let': {
477
+ let result = await this.execOp(op.inner, scope, vars);
478
+ // A LET-bound UPSERT must expose the FULL merged row (e.g.
479
+ // `RETURN { target: $updated }`), so read it back here — only here,
480
+ // not on every upsert.
481
+ if (op.inner.kind === 'upsert') {
482
+ result = (await this.getById(tableOf(op.inner.id), op.inner.id)) ?? result;
483
+ }
484
+ scope[op.var] = result;
485
+ return result;
486
+ }
487
+ case 'return': {
488
+ const obj: Row = {};
489
+ for (const { key, var: v } of op.entries) {
490
+ obj[key] = v in scope ? scope[v] : vars[v];
491
+ }
492
+ return obj;
493
+ }
494
+ case 'noop':
495
+ return pureWriteOpResult(op);
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Compile a pure-write op list to ONE worker `batch` (single SQLite
501
+ * transaction). Ensures each touched table exists, then one statement per op.
502
+ * Merges happen in-SQL via json_patch — no read-back round-trips.
503
+ */
504
+ private async runWriteBatch(ops: SqlOp[]): Promise<void> {
505
+ const stmts: { sql: string; bind?: unknown[] }[] = [];
506
+ const tables = new Set<string>();
507
+ const ensure = (t: string) => {
508
+ if (!tables.has(t)) {
509
+ tables.add(t);
510
+ this.knownTables.add(t);
511
+ stmts.push({
512
+ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)`,
513
+ });
514
+ }
515
+ };
516
+ for (const op of ops) {
517
+ if (op.kind === 'upsert') {
518
+ const t = tableOf(op.id);
519
+ const key = stableKey(op.id);
520
+ ensure(t);
521
+ if (op.mode === 'merge') {
522
+ // Serialize ONCE and reuse for both VALUES (fresh insert) and the
523
+ // json_patch (merge). Patching with `id` is a harmless no-op set, so
524
+ // the full row doubles as the delta — halves per-row stringify cost.
525
+ const full = serializeRow({ ...op.data, id: key });
526
+ stmts.push({
527
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = json_patch(data, ?)`,
528
+ bind: [key, full, full],
529
+ });
530
+ } else {
531
+ stmts.push({
532
+ sql: `INSERT INTO "${t}"(id, data) VALUES(?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`,
533
+ bind: [key, serializeRow({ ...op.data, id: key })],
534
+ });
535
+ }
536
+ } else if (op.kind === 'delete') {
537
+ const t = tableOf(op.id);
538
+ ensure(t);
539
+ stmts.push({ sql: `DELETE FROM "${t}" WHERE id = ?`, bind: [stableKey(op.id)] });
540
+ } else if (op.kind === 'deleteAll') {
541
+ ensure(op.table);
542
+ stmts.push({ sql: `DELETE FROM "${op.table}"` });
543
+ }
544
+ }
545
+ if (stmts.length > 0) await this.call('batch', stmts);
546
+ }
547
+
548
+ private async rawSelectTable(
549
+ table: string,
550
+ where?: WhereNode[],
551
+ orderBy?: OrderBy
552
+ ): Promise<Row[]> {
553
+ await this.ensureTable(table);
554
+ const bind: unknown[] = [];
555
+ let sql = `SELECT data FROM "${table}"`;
556
+ if (where && where.length > 0) sql += ` WHERE ${renderWhereSql(where, bind, {})}`;
557
+ if (orderBy && orderBy.length > 0) sql += renderOrderSql(orderBy);
558
+ return this.execRows(sql, bind);
559
+ }
560
+
561
+ async transaction<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
562
+ // Verbs run in order on the single worker message channel. `patch` needs a
563
+ // read-modify-write round-trip, so a single BEGIN/COMMIT batch cannot wrap
564
+ // the whole closure; sequential execution is sufficient for the current
565
+ // single-record write sites.
566
+ const tx: EngineTx = {
567
+ upsert: (t, id, data, mode) => this.upsert(t, id, data, mode),
568
+ patch: (t, id, p) => this.patch(t, id, p),
569
+ delete: (t, id) => this.delete(t, id),
570
+ };
571
+ return fn(tx);
572
+ }
573
+ }
574
+
575
+ // ==================== instrumentation ====================
576
+
577
+ interface SqliteStats {
578
+ roundTrips: number;
579
+ batchStatements: number;
580
+ maxBatch: number;
581
+ inFlight: number;
582
+ maxInFlight: number;
583
+ byType: Record<string, number>;
584
+ }
585
+
586
+ /** Live stats, inspectable in the browser console via `__sqliteStats`. Counts
587
+ * worker round-trips (the sync-down cost driver), batch sizes, and queue depth
588
+ * (`maxInFlight`) so a churn OOM can be measured rather than guessed. */
589
+ function getStats(): SqliteStats {
590
+ const g = globalThis as unknown as { __sqliteStats?: SqliteStats };
591
+ if (!g.__sqliteStats) {
592
+ g.__sqliteStats = {
593
+ roundTrips: 0,
594
+ batchStatements: 0,
595
+ maxBatch: 0,
596
+ inFlight: 0,
597
+ maxInFlight: 0,
598
+ byType: {},
599
+ };
600
+ }
601
+ return g.__sqliteStats;
602
+ }
603
+
604
+ // ==================== SQL rendering ====================
605
+
606
+ function renderOrderSql(orderBy: OrderBy): string {
607
+ return ` ORDER BY ${orderBy
608
+ .map(([f, d]) => `json_extract(data, '$.${f}') ${d === 'desc' ? 'DESC' : 'ASC'}`)
609
+ .join(', ')}`;
610
+ }
611
+
612
+ function comparisonSql(
613
+ c: WhereComparison,
614
+ bind: unknown[],
615
+ params: Record<string, unknown>
616
+ ): string {
617
+ const lhs = c.field === 'id' ? 'id' : `json_extract(data, '$.${c.field}')`;
618
+ const value = c.paramRef ? params[c.paramRef] : c.value;
619
+ bind.push(scalar(value));
620
+ const op = c.op === '!=' ? '!=' : c.op;
621
+ return c.swap ? `? ${op} ${lhs}` : `${lhs} ${op} ?`;
622
+ }
623
+
624
+ function renderWhereSql(
625
+ nodes: WhereNode[],
626
+ bind: unknown[],
627
+ params: Record<string, unknown>
628
+ ): string {
629
+ return nodes
630
+ .map((node) => {
631
+ if ('or' in node) {
632
+ return `(${node.or.map((c) => comparisonSql(c, bind, params)).join(' OR ')})`;
633
+ }
634
+ return comparisonSql(node, bind, params);
635
+ })
636
+ .join(' AND ');
637
+ }
638
+
639
+ // ==================== value (de)serialization ====================
640
+
641
+ /** A comparable scalar for SQL binding: record links → `table:id`, everything
642
+ * else passed through (numbers/strings/bools). */
643
+ function scalar(value: unknown): unknown {
644
+ if (value == null) return null;
645
+ if (typeof value === 'object') return stableKey(value);
646
+ return value;
647
+ }
648
+
649
+ function serializeRow(row: Row): string {
650
+ return JSON.stringify(row, (_k, v) => {
651
+ if (v instanceof Uint8Array) return { __u8: toBase64(v) };
652
+ if (v && typeof v === 'object') {
653
+ const rid = v as { tb?: unknown; id?: unknown };
654
+ if (rid.tb !== undefined && rid.id !== undefined) return stableKey(v);
655
+ }
656
+ return v;
657
+ });
658
+ }
659
+
660
+ function reviveRow(json: string): Row {
661
+ // Fast path: the per-key reviver is only needed to rebuild `Uint8Array`s from
662
+ // `{__u8}` tags. Most rows (e.g. game bodies) have none — a plain parse avoids
663
+ // invoking a JS callback for every key of every row on the read hot path.
664
+ if (json.indexOf('"__u8"') === -1) return JSON.parse(json);
665
+ return JSON.parse(json, (_k, v) => {
666
+ if (v && typeof v === 'object' && typeof (v as any).__u8 === 'string') {
667
+ return fromBase64((v as any).__u8);
668
+ }
669
+ return v;
670
+ });
671
+ }
672
+
673
+ function project(row: Row, fields: string[]): Row {
674
+ const out: Row = {};
675
+ for (const f of ['id', ...fields]) if (f in row) out[f] = row[f];
676
+ return out;
677
+ }
678
+
679
+ function toBase64(bytes: Uint8Array): string {
680
+ let bin = '';
681
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
682
+ return typeof btoa !== 'undefined' ? btoa(bin) : Buffer.from(bytes).toString('base64');
683
+ }
684
+
685
+ function fromBase64(b64: string): Uint8Array {
686
+ if (typeof atob !== 'undefined') {
687
+ const bin = atob(b64);
688
+ const out = new Uint8Array(bin.length);
689
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
690
+ return out;
691
+ }
692
+ return new Uint8Array(Buffer.from(b64, 'base64'));
693
+ }