@spooky-sync/core 0.0.1-canary.103 → 0.0.1-canary.104

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRemoteEngines } from "surrealdb";
2
- import { QueryBuilder, RecordId as RecordId$1 } from "@spooky-sync/query-builder";
3
2
  import { createWasmWorkerEngines } from "@surrealdb/wasm";
3
+ import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query-builder";
4
4
  import pino from "pino";
5
5
  import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
6
6
  import { LoroDoc } from "loro-crdt";
@@ -9,6 +9,241 @@ import { LoroDoc } from "loro-crdt";
9
9
  /** Cap on the rolling materialization-sample window kept per query in memory. */
10
10
  const MATERIALIZATION_SAMPLE_WINDOW = 100;
11
11
 
12
+ //#endregion
13
+ //#region src/services/database/database.ts
14
+ var AbstractDatabaseService = class {
15
+ client;
16
+ logger;
17
+ events;
18
+ constructor(client, logger, events) {
19
+ this.client = client;
20
+ this.logger = logger.child({ service: "Database" });
21
+ this.events = events;
22
+ }
23
+ getClient() {
24
+ return this.client;
25
+ }
26
+ getEvents() {
27
+ return this.events;
28
+ }
29
+ tx() {
30
+ return this.client.beginTransaction();
31
+ }
32
+ queryQueue = Promise.resolve();
33
+ /**
34
+ * Execute a query with serialized execution to prevent WASM transaction issues.
35
+ */
36
+ async query(query, vars) {
37
+ return new Promise((resolve, reject) => {
38
+ this.queryQueue = this.queryQueue.then(async () => {
39
+ const startTime = performance.now();
40
+ try {
41
+ this.logger.debug({
42
+ query,
43
+ vars,
44
+ Category: "sp00ky-client::Database::query"
45
+ }, "Executing query");
46
+ const result = await this.client.query(query, vars);
47
+ const duration = performance.now() - startTime;
48
+ this.events.emit(this.eventType, {
49
+ query,
50
+ vars,
51
+ duration,
52
+ success: true,
53
+ timestamp: Date.now()
54
+ });
55
+ resolve(result);
56
+ this.logger.trace({
57
+ query,
58
+ result,
59
+ Category: "sp00ky-client::Database::query"
60
+ }, "Query executed successfully");
61
+ } catch (err) {
62
+ const duration = performance.now() - startTime;
63
+ this.events.emit(this.eventType, {
64
+ query,
65
+ vars,
66
+ duration,
67
+ success: false,
68
+ error: err instanceof Error ? err.message : String(err),
69
+ timestamp: Date.now()
70
+ });
71
+ this.logger.error({
72
+ query,
73
+ vars,
74
+ err,
75
+ Category: "sp00ky-client::Database::query"
76
+ }, "Query execution failed");
77
+ reject(err);
78
+ }
79
+ }).catch(() => {});
80
+ });
81
+ }
82
+ async execute(query, vars) {
83
+ const raw = await this.query(query.sql, vars);
84
+ return query.extract(raw);
85
+ }
86
+ async close() {
87
+ this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
88
+ await this.client.close();
89
+ }
90
+ };
91
+
92
+ //#endregion
93
+ //#region src/events/index.ts
94
+ /**
95
+ * A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
96
+ * @template E The EventTypeMap defining all supported events.
97
+ */
98
+ var EventSystem = class {
99
+ subscriberId = 0;
100
+ isProcessing = false;
101
+ buffer;
102
+ subscribers;
103
+ subscribersTypeMap;
104
+ lastEvents;
105
+ debouncedEvents;
106
+ constructor(_eventTypes) {
107
+ this._eventTypes = _eventTypes;
108
+ this.buffer = [];
109
+ this.subscribers = this._eventTypes.reduce((acc, key) => {
110
+ return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
111
+ }, {});
112
+ this.lastEvents = {};
113
+ this.subscribersTypeMap = /* @__PURE__ */ new Map();
114
+ this.debouncedEvents = /* @__PURE__ */ new Map();
115
+ }
116
+ get eventTypes() {
117
+ return this._eventTypes;
118
+ }
119
+ /**
120
+ * Subscribes a handler to a specific event type.
121
+ * @param type The event type to subscribe to.
122
+ * @param handler The function to call when the event occurs.
123
+ * @param options Subscription options (once, immediately).
124
+ * @returns A subscription ID that can be used to unsubscribe.
125
+ */
126
+ subscribe(type, handler, options) {
127
+ const id = this.subscriberId++;
128
+ this.subscribers[type].set(id, {
129
+ id,
130
+ handler,
131
+ once: options?.once ?? false
132
+ });
133
+ this.subscribersTypeMap.set(id, type);
134
+ if (options?.immediately) {
135
+ const lastEvent = this.lastEvents[type];
136
+ if (lastEvent) handler(lastEvent);
137
+ }
138
+ return id;
139
+ }
140
+ /**
141
+ * Subscribes a handler to multiple event types.
142
+ * @param types An array of event types to subscribe to.
143
+ * @param handler The function to call when any of the events occur.
144
+ * @param options Subscription options.
145
+ * @returns An array of subscription IDs.
146
+ */
147
+ subscribeMany(types, handler, options) {
148
+ return types.map((type) => this.subscribe(type, handler, options));
149
+ }
150
+ /**
151
+ * Unsubscribes a specific subscription by ID.
152
+ * @param id The subscription ID returned by subscribe().
153
+ * @returns True if the subscription was found and removed, false otherwise.
154
+ */
155
+ unsubscribe(id) {
156
+ const type = this.subscribersTypeMap.get(id);
157
+ if (type) {
158
+ this.subscribers[type].delete(id);
159
+ this.subscribersTypeMap.delete(id);
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ /**
165
+ * Emits an event with the given type and payload.
166
+ * @param type The type of event to emit.
167
+ * @param payload The data associated with the event.
168
+ */
169
+ emit(type, payload) {
170
+ const event = {
171
+ type,
172
+ payload
173
+ };
174
+ this.addEvent(event);
175
+ }
176
+ /**
177
+ * Adds a fully constructed event object to the system.
178
+ * Similar to emit, but takes the full event object directly.
179
+ * Supports debouncing if options are provided.
180
+ * @param event The event object.
181
+ * @param options Options for the event push (e.g., debouncing).
182
+ */
183
+ addEvent(event, options) {
184
+ if (options?.debounced) {
185
+ this.handleDebouncedEvent(event, options.debounced.key, options.debounced.delay);
186
+ return;
187
+ }
188
+ this.buffer.push(event);
189
+ this.scheduleProcessing();
190
+ }
191
+ handleDebouncedEvent(event, key, delay) {
192
+ if (this.debouncedEvents.has(key)) clearTimeout(this.debouncedEvents.get(key)?.timer);
193
+ const timer = setTimeout(() => {
194
+ this.debouncedEvents.delete(key);
195
+ this.buffer.push(event);
196
+ this.scheduleProcessing();
197
+ }, delay);
198
+ this.debouncedEvents.set(key, {
199
+ timer,
200
+ resolve: () => {}
201
+ });
202
+ }
203
+ scheduleProcessing() {
204
+ if (!this.isProcessing) queueMicrotask(() => this.processEvents());
205
+ }
206
+ async processEvents() {
207
+ if (this.isProcessing) return;
208
+ this.isProcessing = true;
209
+ try {
210
+ while (this.dequeue());
211
+ } finally {
212
+ this.isProcessing = false;
213
+ }
214
+ }
215
+ dequeue() {
216
+ const event = this.buffer.shift();
217
+ if (!event) return false;
218
+ this.setLastEvent(event.type, event);
219
+ this.broadcastEvent(event.type, event);
220
+ return true;
221
+ }
222
+ setLastEvent(type, event) {
223
+ this.lastEvents[type] = event;
224
+ }
225
+ broadcastEvent(type, event) {
226
+ const subscribers = this.subscribers[type].values();
227
+ for (const subscriber of subscribers) {
228
+ subscriber.handler(event);
229
+ if (subscriber.once) this.unsubscribe(subscriber.id);
230
+ }
231
+ }
232
+ };
233
+ function createEventSystem(eventTypes) {
234
+ return new EventSystem(eventTypes);
235
+ }
236
+
237
+ //#endregion
238
+ //#region src/services/database/events/index.ts
239
+ const DatabaseEventTypes = {
240
+ LocalQuery: "DATABASE_LOCAL_QUERY",
241
+ RemoteQuery: "DATABASE_REMOTE_QUERY"
242
+ };
243
+ function createDatabaseEventSystem() {
244
+ return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
245
+ }
246
+
12
247
  //#endregion
13
248
  //#region src/utils/surql.ts
14
249
  const surql = {
@@ -221,1718 +456,1852 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
221
456
  }
222
457
 
223
458
  //#endregion
224
- //#region src/modules/data/window-query.ts
459
+ //#region src/modules/ref-tables.ts
225
460
  /**
226
- * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
227
- * materialized from an explicit record-id set the window the SSP already
228
- * computed instead of re-running the original query (with its `START m`)
229
- * against the shared local DB.
230
- *
231
- * Why: `DataModule.processStreamUpdate` materializes a query's rows by
232
- * re-querying the local in-browser SurrealDB with the original surql. For an
233
- * offset query that re-applies `START m` against the *shared* local store —
234
- * which, with sparse windowing, may hold only this window's rows — so it skips
235
- * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
236
- * materialized view (`StreamUpdate.localArray`) is exactly this window's row
237
- * ids, so we select those ids directly and re-apply the original `ORDER BY` for
238
- * stable display order.
239
- *
240
- * Returns `null` for non-offset queries (`START` absent or 0) — the caller
241
- * keeps the normal re-query path, so only the broken case changes behavior.
242
- *
243
- * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
244
- * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
245
- * projection are preserved verbatim (the scanner is paren/quote aware), with
246
- * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
247
- * shape — such windowed+subquery queries are rare and were already returning 0,
248
- * so this is no regression.
461
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
462
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
463
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
464
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
249
465
  */
250
- function buildWindowMaterialization(surql, idsParam = "__win") {
251
- const kw = scanTopLevelClauses(surql);
252
- if (kw.startValue === null || kw.startValue <= 0) return null;
253
- if (kw.fromIndex === null) return null;
254
- const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
255
- let orderBy = "";
256
- if (kw.orderByIndex !== null) {
257
- const ends = [
258
- kw.limitIndex,
259
- kw.startIndex,
260
- kw.semicolonIndex,
261
- surql.length
262
- ].filter((n) => n !== null && n > kw.orderByIndex);
263
- const end = Math.min(...ends);
264
- orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
265
- }
266
- return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
267
- }
268
- function scanTopLevelClauses(sql) {
269
- const out = {
270
- fromIndex: null,
271
- orderByIndex: null,
272
- limitIndex: null,
273
- startIndex: null,
274
- startValue: null,
275
- semicolonIndex: null
276
- };
277
- let depth = 0;
278
- let inStr = false;
279
- for (let i = 0; i < sql.length; i++) {
280
- const ch = sql[i];
281
- if (inStr) {
282
- if (ch === "\\") i++;
283
- else if (ch === "'") inStr = false;
284
- continue;
285
- }
286
- if (ch === "'") {
287
- inStr = true;
288
- continue;
289
- }
290
- if (ch === "(") {
291
- depth++;
292
- continue;
293
- }
294
- if (ch === ")") {
295
- depth--;
296
- continue;
297
- }
298
- if (depth !== 0) continue;
299
- if (ch === ";" && out.semicolonIndex === null) {
300
- out.semicolonIndex = i;
301
- continue;
302
- }
303
- if (!isWordBoundary(sql, i)) continue;
304
- if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
305
- out.fromIndex = i;
306
- continue;
307
- }
308
- if (out.fromIndex === null) continue;
309
- if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
310
- out.orderByIndex = i;
311
- continue;
312
- }
313
- if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
314
- out.limitIndex = i;
315
- continue;
316
- }
317
- if (out.startIndex === null && matchKeyword(sql, i, "START")) {
318
- out.startIndex = i;
319
- out.startValue = readNumberAfter(sql, i + 5);
320
- continue;
321
- }
322
- }
323
- return out;
324
- }
325
- function isWordBoundary(sql, i) {
326
- if (i === 0) return true;
327
- return !/[A-Za-z0-9_]/.test(sql[i - 1]);
466
+ const ANON_USER_ID = "anon";
467
+ /**
468
+ * Default ref-storage mode for this client build. Mirrors the SSP's
469
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
470
+ * box.
471
+ */
472
+ const DEFAULT_REF_MODE = "dedicated";
473
+ /**
474
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
475
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
476
+ * the id is missing the `user:` prefix or contains characters that
477
+ * aren't valid in a SurrealDB table identifier — the server-side
478
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
479
+ *
480
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
481
+ * objects (which only stringify cleanly via `.toString()`), since
482
+ * `AuthService` passes the record-id object as-is to its subscribers.
483
+ */
484
+ function sanitizeUserId(userId) {
485
+ if (userId === null || userId === void 0) return null;
486
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
487
+ if (!asString) return null;
488
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
489
+ if (raw.length === 0) return null;
490
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
491
+ return raw;
328
492
  }
329
- function matchKeyword(sql, i, keyword) {
330
- const parts = keyword.split(" ");
331
- let pos = i;
332
- for (let p = 0; p < parts.length; p++) {
333
- const word = parts[p];
334
- if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
335
- pos += word.length;
336
- if (p < parts.length - 1) {
337
- const wsStart = pos;
338
- while (pos < sql.length && /\s/.test(sql[pos])) pos++;
339
- if (pos === wsStart) return false;
340
- }
341
- }
342
- return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
493
+ /**
494
+ * Resolve the LOCAL storage bucket id for a user. Every user gets their own
495
+ * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
496
+ * query state, and the mutation outbox never leak across accounts on a shared
497
+ * device. Signed-out sessions share the `anon` bucket.
498
+ *
499
+ * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
500
+ * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
501
+ * authenticated user in the shared bucket and recreate the cross-user leak.
502
+ */
503
+ function bucketIdForUser(userId) {
504
+ if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
505
+ const uid = sanitizeUserId(userId);
506
+ if (uid) return uid;
507
+ return `u${cyrb53(String(userId)).toString(16)}`;
343
508
  }
344
- function readNumberAfter(sql, from) {
345
- let pos = from;
346
- while (pos < sql.length && /\s/.test(sql[pos])) pos++;
347
- const m = /^\d+/.exec(sql.slice(pos));
348
- return m ? parseInt(m[0], 10) : null;
509
+ /**
510
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
511
+ * back to the global `_00_list_ref` when sanitization fails or in
512
+ * single mode.
513
+ */
514
+ function listRefTableFor(mode, userId) {
515
+ if (userId === ANON_USER_ID) return "_00_list_ref_anon";
516
+ if (mode === "single") return "_00_list_ref";
517
+ const uid = sanitizeUserId(userId);
518
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
349
519
  }
350
520
 
351
521
  //#endregion
352
- //#region src/modules/data/index.ts
353
- /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
354
- function pushSample(samples, ms) {
355
- samples.push(ms);
356
- if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
522
+ //#region src/services/database/local.ts
523
+ /** Thrown when a query carries an `epoch` from before a bucket switch. The
524
+ * caller's chain read from the previous user's store; its write must be
525
+ * dropped, not applied to the new bucket. */
526
+ var StaleEpochError = class extends Error {
527
+ constructor() {
528
+ super("Local store epoch changed (bucket switch); stale write dropped");
529
+ this.name = "StaleEpochError";
530
+ }
531
+ };
532
+ /** Store URL for a local bucket. One IndexedDB store per user (`anon` for
533
+ * signed-out) so cached rows never leak across accounts on a shared device. */
534
+ function bucketStoreUrl(bucketId) {
535
+ return `indxdb://${bucketStoreName(bucketId)}`;
357
536
  }
358
- /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
359
- function phaseStatOf(samples, lastMs) {
360
- if (samples.length === 0) return {
361
- lastMs,
362
- p50: null,
363
- p90: null,
364
- p99: null,
365
- count: 0
366
- };
367
- const sorted = [...samples].sort((a, b) => a - b);
368
- const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
369
- return {
370
- lastMs,
371
- p50: pick(.5),
372
- p90: pick(.9),
373
- p99: pick(.99),
374
- count: samples.length
375
- };
537
+ /** The IndexedDB database name SurrealDB-WASM derives from the store URL. */
538
+ function bucketStoreName(bucketId) {
539
+ return `sp00ky-${bucketId}`;
376
540
  }
377
- /**
378
- * DataModule - Unified query and mutation management
379
- *
380
- * Merges the functionality of QueryManager and MutationManager.
381
- * Uses CacheModule for all storage operations.
382
- */
383
- var DataModule = class {
384
- activeQueries = /* @__PURE__ */ new Map();
385
- pendingQueries = /* @__PURE__ */ new Map();
386
- subscriptions = /* @__PURE__ */ new Map();
387
- statusSubscriptions = /* @__PURE__ */ new Map();
388
- mutationCallbacks = /* @__PURE__ */ new Set();
389
- debounceTimers = /* @__PURE__ */ new Map();
390
- logger;
391
- /**
392
- * Optional observer notified whenever a query's fetch status changes.
393
- * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
394
- * settable field (rather than a constructor arg) because DevTools is
395
- * constructed after DataModule.
396
- */
397
- onQueryStatusChange;
541
+ function createLocalSurrealClient(logger) {
542
+ return new Surreal({
543
+ codecOptions: { valueDecodeVisitor(value) {
544
+ if (value instanceof RecordId) return encodeRecordId(value);
545
+ if (value instanceof DateTime) return value.toDate();
546
+ return value;
547
+ } },
548
+ engines: applyDiagnostics(createWasmWorkerEngines(), ({ key, type, phase, ...other }) => {
549
+ if (phase === "progress" || phase === "after") logger.trace({
550
+ ...other,
551
+ key,
552
+ type,
553
+ phase,
554
+ service: "surrealdb:local",
555
+ Category: "sp00ky-client::LocalDatabaseService::diagnostics"
556
+ }, `Local SurrealDB diagnostics captured ${type}:${phase}`);
557
+ })
558
+ });
559
+ }
560
+ var LocalDatabaseService = class extends AbstractDatabaseService {
561
+ config;
562
+ eventType = DatabaseEventTypes.LocalQuery;
563
+ /** Bucket currently open. Set by `connect`/`switchStore`. */
564
+ bucketId = ANON_USER_ID;
398
565
  /**
399
- * Optional observer invoked when a still-subscribed query's TTL heartbeat
400
- * fires (~90% of the TTL). Wired by Sp00kyClient to
401
- * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
402
- * row's `lastActiveAt` so an actively-watched query never expires. Settable
403
- * field (not a constructor arg) because the sync engine is wired after
404
- * DataModule is constructed — mirrors `onQueryStatusChange`.
566
+ * Monotonic store generation. Bumped on every `switchStore`. Async chains
567
+ * that read from the store, await something remote, and then write back
568
+ * (sync poll, SSP stream updates) capture this at chain start and drop
569
+ * their write when it no longer matches — a stale-epoch write would land
570
+ * another user's data in the new bucket.
405
571
  */
406
- onHeartbeat;
572
+ storeEpoch = 0;
573
+ /** Gate that `query()`/`execute()` await; closed for the switch window. */
574
+ gate = Promise.resolve();
575
+ /** The incoming client while a switch is in flight (for unload cleanup). */
576
+ pendingSwitchClient = null;
577
+ constructor(config, logger) {
578
+ const events = createDatabaseEventSystem();
579
+ super(createLocalSurrealClient(logger), logger, events);
580
+ this.config = config;
581
+ }
582
+ getConfig() {
583
+ return this.config;
584
+ }
585
+ get currentBucketId() {
586
+ return this.bucketId;
587
+ }
588
+ get epoch() {
589
+ return this.storeEpoch;
590
+ }
407
591
  /**
408
- * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
409
- * viewport-windowed list cancelling an off-screen window) loses its last
410
- * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
411
- * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
412
- * instead of leaving it for the TTL sweep. The local view + state are freed in
413
- * {@link finalizeDeregister} only after that remote delete, so a fast
414
- * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
592
+ * Close the query gate for a bucket switch. Every `query()`/`execute()`
593
+ * issued after this waits until the returned release fn runs — so work
594
+ * triggered mid-switch (sibling auth subscribers registering queries)
595
+ * lands on the NEW bucket instead of racing the swap. The migrator uses
596
+ * `queryUngated()` to provision the new bucket while the gate is closed.
415
597
  */
416
- onDeregister;
417
- sessionId = "";
418
- currentUserId = null;
419
- constructor(cache, local, schema, logger, streamDebounceTime = 50) {
420
- this.cache = cache;
421
- this.local = local;
422
- this.schema = schema;
423
- this.streamDebounceTime = streamDebounceTime;
424
- this.logger = logger.child({ service: "DataModule" });
598
+ beginSwitch() {
599
+ let release;
600
+ this.gate = new Promise((resolve) => {
601
+ release = resolve;
602
+ });
603
+ return release;
425
604
  }
426
- async init(sessionId) {
427
- this.sessionId = sessionId;
605
+ async query(query, vars, opts) {
606
+ await this.gate;
607
+ if (opts?.epoch !== void 0 && opts.epoch !== this.storeEpoch) throw new StaleEpochError();
608
+ return super.query(query, vars);
609
+ }
610
+ async execute(query, vars, opts) {
611
+ const raw = await this.query(query.sql, vars, opts);
612
+ return query.extract(raw);
613
+ }
614
+ /** Gate-bypassing query — ONLY for the switch path itself (schema
615
+ * provisioning must run while the gate is closed, or it deadlocks). */
616
+ queryUngated(query, vars) {
617
+ return super.query(query, vars);
618
+ }
619
+ async connect(bucketId = ANON_USER_ID) {
620
+ const { namespace, database } = this.getConfig();
621
+ const store = this.getConfig().store ?? "memory";
622
+ this.bucketId = bucketId;
623
+ const storeUrl = store === "memory" ? "mem://" : bucketStoreUrl(bucketId);
428
624
  this.logger.info({
429
- sessionId,
430
- Category: "sp00ky-client::DataModule::init"
431
- }, "DataModule initialized");
625
+ namespace,
626
+ database,
627
+ storeUrl,
628
+ Category: "sp00ky-client::LocalDatabaseService::connect"
629
+ }, "Connecting to local database");
630
+ this.registerUnloadClose();
631
+ await this.openWithRecovery(this.client, storeUrl, namespace, database, bucketId, store);
432
632
  }
433
633
  /**
434
- * Update the session salt used in query-id hashing. Call this when the
435
- * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
436
- * registered queries will get fresh, session-scoped IDs.
634
+ * Switch the local store to another user's bucket. Opens the NEW bucket on a
635
+ * second client first (with the same 3-tier recovery), then atomically swaps
636
+ * `this.client` and closes the old one — a failed open never leaves the
637
+ * service on a dead client. Bumps the store epoch so in-flight old-bucket
638
+ * async chains can detect they're stale.
639
+ *
640
+ * Callers own the drain/rebind choreography (close the gate, quiesce sync +
641
+ * timers BEFORE calling this; re-provision + rebind AFTER).
437
642
  */
438
- setSessionId(sessionId) {
439
- this.sessionId = sessionId;
440
- }
441
- /**
442
- * Update the authenticated user record id. Pass `null` on sign-out.
443
- * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
444
- * the poll route to the same per-user `_00_list_ref_user_<id>` the
445
- * SSP writes to.
446
- */
447
- setCurrentUserId(userId) {
448
- this.currentUserId = userId;
449
- }
450
- /** Read-only view of the authenticated user id used for per-user
451
- * `_00_list_ref` routing. Other modules consult this so they pick the
452
- * same table name DataModule does. */
453
- getCurrentUserId() {
454
- return this.currentUserId;
455
- }
456
- /**
457
- * Register a query and return its hash for subscriptions
458
- */
459
- async query(tableName, surqlString, params, ttl) {
460
- const hash = await this.calculateHash({
461
- surql: surqlString,
462
- params
463
- });
464
- this.logger.debug({
465
- hash,
466
- Category: "sp00ky-client::DataModule::query"
467
- }, "Query Initialization: started");
468
- const recordId = new RecordId("_00_query", hash);
469
- if (this.activeQueries.has(hash)) {
470
- this.logger.debug({
471
- hash,
472
- Category: "sp00ky-client::DataModule::query"
473
- }, "Query Initialization: exists, returning");
474
- return hash;
475
- }
476
- if (this.pendingQueries.has(hash)) {
477
- this.logger.debug({
478
- hash,
479
- Category: "sp00ky-client::DataModule::query"
480
- }, "Query Initialization: pending, waiting for existing creation");
481
- await this.pendingQueries.get(hash);
482
- return hash;
643
+ async switchStore(bucketId) {
644
+ if (bucketId === this.bucketId) return;
645
+ const { namespace, database } = this.getConfig();
646
+ const store = this.getConfig().store ?? "memory";
647
+ this.storeEpoch++;
648
+ if (store === "memory") {
649
+ try {
650
+ await this.client.close();
651
+ } catch {}
652
+ await this.openStore(this.client, "mem://", namespace, database);
653
+ this.bucketId = bucketId;
654
+ this.logger.info({
655
+ bucketId,
656
+ Category: "sp00ky-client::LocalDatabaseService::switchStore"
657
+ }, "Reset in-memory local store for bucket switch");
658
+ return;
483
659
  }
484
- this.logger.debug({
485
- hash,
486
- Category: "sp00ky-client::DataModule::query"
487
- }, "Query Initialization: not found, creating new query");
488
- const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
489
- this.pendingQueries.set(hash, promise);
660
+ const next = createLocalSurrealClient(this.logger);
661
+ this.pendingSwitchClient = next;
490
662
  try {
491
- await promise;
663
+ await this.openWithRecovery(next, bucketStoreUrl(bucketId), namespace, database, bucketId, store);
492
664
  } finally {
493
- this.pendingQueries.delete(hash);
665
+ this.pendingSwitchClient = null;
494
666
  }
495
- return hash;
667
+ const old = this.client;
668
+ this.client = next;
669
+ this.bucketId = bucketId;
670
+ try {
671
+ await old.close();
672
+ } catch {}
673
+ this.logger.info({
674
+ bucketId,
675
+ Category: "sp00ky-client::LocalDatabaseService::switchStore"
676
+ }, "Switched local store bucket");
496
677
  }
497
678
  /**
498
- * Subscribe to query updates
679
+ * Open `storeUrl` on `client` with tiered recovery:
680
+ * tier 1 retries the same store (transient idb-handle races — preserves the
681
+ * cache), tier 2 drops THIS bucket's IndexedDB store and reconnects fresh,
682
+ * tier 3 falls back to `mem://` for the session. Only ever drops the bucket
683
+ * being opened — other users' buckets hold their own caches AND un-pushed
684
+ * mutation outboxes, which must survive another bucket's corruption.
499
685
  */
500
- subscribe(queryHash, callback, options = {}) {
501
- if (!this.subscriptions.has(queryHash)) this.subscriptions.set(queryHash, /* @__PURE__ */ new Set());
502
- this.subscriptions.get(queryHash)?.add(callback);
503
- if (options.immediate) {
504
- const query = this.activeQueries.get(queryHash);
505
- if (query) callback(query.records);
686
+ async openWithRecovery(client, storeUrl, namespace, database, bucketId, store) {
687
+ try {
688
+ await this.openStore(client, storeUrl, namespace, database);
689
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
690
+ return;
691
+ } catch (err) {
692
+ if (store === "memory" || !isLocalStoreOpenError(err)) {
693
+ this.logger.error({
694
+ err,
695
+ Category: "sp00ky-client::LocalDatabaseService::connect"
696
+ }, "Failed to connect to local database");
697
+ throw err;
698
+ }
699
+ this.logger.warn({
700
+ err,
701
+ Category: "sp00ky-client::LocalDatabaseService::connect"
702
+ }, "Local IndexedDB store failed to open; retrying before clearing");
506
703
  }
507
- return () => {
508
- const subs = this.subscriptions.get(queryHash);
509
- if (subs) {
510
- subs.delete(callback);
511
- if (subs.size === 0) this.subscriptions.delete(queryHash);
704
+ for (let attempt = 1; attempt <= 2; attempt++) {
705
+ try {
706
+ await client.close();
707
+ } catch {}
708
+ await delay(150 * attempt);
709
+ try {
710
+ await this.openStore(client, storeUrl, namespace, database);
711
+ this.logger.info({
712
+ attempt,
713
+ Category: "sp00ky-client::LocalDatabaseService::connect"
714
+ }, "Connected to local database on retry (cache preserved)");
715
+ return;
716
+ } catch (retryErr) {
717
+ this.logger.warn({
718
+ err: retryErr,
719
+ attempt,
720
+ Category: "sp00ky-client::LocalDatabaseService::connect"
721
+ }, "Local store retry failed");
512
722
  }
513
- };
723
+ }
724
+ try {
725
+ await client.close();
726
+ } catch {}
727
+ await dropLocalIndexedDbStores(this.logger, bucketStoreName(bucketId));
728
+ try {
729
+ await this.openStore(client, storeUrl, namespace, database);
730
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
731
+ } catch (retryErr) {
732
+ this.logger.error({
733
+ err: retryErr,
734
+ Category: "sp00ky-client::LocalDatabaseService::connect"
735
+ }, "Local store still failing after clear; falling back to in-memory");
736
+ try {
737
+ await client.close();
738
+ } catch {}
739
+ await this.openStore(client, "mem://", namespace, database);
740
+ this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
741
+ }
514
742
  }
743
+ unloadCloseRegistered = false;
515
744
  /**
516
- * Subscribe to a query's fetch-status changes (idle/fetching).
517
- * With `{ immediate: true }` the callback fires synchronously with the
518
- * current status (defaults to `idle` if the query isn't registered yet).
745
+ * Close the local DB on page unload so the SurrealDB-WASM worker releases its
746
+ * IndexedDB connection cleanly. Without this, the previous page's connection
747
+ * lingers; the next load's `client.connect` opens the store but the first
748
+ * write transaction in `client.use` hits an "IndexedDB error" — which then
749
+ * (mis)triggered the corrupt-store recovery and WIPED the cache on every
750
+ * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
751
+ * unload signal (fires on bfcache + normal navigation); `close()` is async but
752
+ * the WASM worker initiates the IndexedDB connection teardown synchronously.
753
+ * Also closes a mid-switch incoming client so its fresh handle doesn't linger.
519
754
  */
520
- subscribeStatus(queryHash, callback, options = {}) {
521
- if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
522
- this.statusSubscriptions.get(queryHash)?.add(callback);
523
- if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
524
- return () => {
525
- const subs = this.statusSubscriptions.get(queryHash);
526
- if (subs) {
527
- subs.delete(callback);
528
- if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
529
- }
755
+ registerUnloadClose() {
756
+ if (this.unloadCloseRegistered || typeof window === "undefined") return;
757
+ this.unloadCloseRegistered = true;
758
+ const close = () => {
759
+ try {
760
+ this.client.close();
761
+ } catch {}
762
+ try {
763
+ this.pendingSwitchClient?.close();
764
+ } catch {}
530
765
  };
766
+ window.addEventListener("pagehide", close);
767
+ window.addEventListener("beforeunload", close);
531
768
  }
532
- /**
533
- * Set a query's fetch status and notify status observers (DevTools +
534
- * `subscribeStatus` listeners). No-op when the status is unchanged or the
535
- * query is unknown.
536
- */
537
- setQueryStatus(queryHash, status) {
538
- const queryState = this.activeQueries.get(queryHash);
539
- if (!queryState || queryState.status === status) return;
540
- queryState.status = status;
541
- this.onQueryStatusChange?.(queryHash, status);
542
- const subs = this.statusSubscriptions.get(queryHash);
543
- if (subs) for (const callback of subs) callback(status);
544
- }
545
- /**
546
- * Subscribe to mutations (for sync)
547
- */
548
- onMutation(callback) {
549
- this.mutationCallbacks.add(callback);
550
- return () => {
551
- this.mutationCallbacks.delete(callback);
552
- };
769
+ async openStore(client, storeUrl, namespace, database) {
770
+ this.logger.debug({
771
+ storeUrl,
772
+ Category: "sp00ky-client::LocalDatabaseService::connect"
773
+ }, "[LocalDatabaseService] Calling client.connect");
774
+ await client.connect(storeUrl, {});
775
+ this.logger.debug({
776
+ namespace,
777
+ database,
778
+ Category: "sp00ky-client::LocalDatabaseService::connect"
779
+ }, "[LocalDatabaseService] client.connect returned. Calling client.use");
780
+ await client.use({
781
+ namespace,
782
+ database
783
+ });
784
+ this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
553
785
  }
554
- /**
555
- * Handle stream updates from DBSP (via CacheModule)
556
- */
557
- async onStreamUpdate(update) {
558
- const { queryHash, op } = update;
559
- if (op === "DELETE") {
560
- const existing = this.debounceTimers.get(queryHash);
561
- if (existing) {
562
- clearTimeout(existing);
563
- this.debounceTimers.delete(queryHash);
564
- }
565
- await this.processStreamUpdate(update);
566
- return;
567
- }
568
- if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
569
- const timer = setTimeout(async () => {
570
- this.debounceTimers.delete(queryHash);
571
- await this.processStreamUpdate(update);
572
- }, this.streamDebounceTime);
573
- this.debounceTimers.set(queryHash, timer);
786
+ };
787
+ function delay(ms) {
788
+ return new Promise((resolve) => setTimeout(resolve, ms));
789
+ }
790
+ /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
791
+ * store can't be opened (corrupt / version-incompatible / blocked). Exported
792
+ * for unit testing the error-message match. */
793
+ function isLocalStoreOpenError(err) {
794
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
795
+ return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
796
+ }
797
+ /** Best-effort delete of ONE bucket's IndexedDB store(s). SurrealDB-WASM backs
798
+ * `indxdb://<name>` with one or more IndexedDB databases whose names include
799
+ * `<name>`. Scoped to the given store name — never wipe by the bare `sp00ky`
800
+ * substring, that would take every user's bucket (and their un-pushed mutation
801
+ * outboxes) down with one corrupt store. Resolves even on error/blocked so
802
+ * startup can proceed. No-op outside a browser. Exported for unit tests. */
803
+ async function dropLocalIndexedDbStores(logger, storeName) {
804
+ if (typeof indexedDB === "undefined") return;
805
+ try {
806
+ let names = [];
807
+ if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && matchesBucketStore(n, storeName));
808
+ if (names.length === 0) names = [storeName];
809
+ await Promise.all(names.map(deleteIndexedDb));
810
+ logger.info({
811
+ names,
812
+ Category: "sp00ky-client::LocalDatabaseService::connect"
813
+ }, "Cleared local IndexedDB store(s)");
814
+ } catch (e) {
815
+ logger.warn({
816
+ err: e,
817
+ Category: "sp00ky-client::LocalDatabaseService::connect"
818
+ }, "Failed to enumerate/clear IndexedDB; proceeding anyway");
574
819
  }
575
- async materializeRecords(queryState, sspArray) {
576
- const t0 = performance.now();
577
- const windowMat = buildWindowMaterialization(queryState.config.surql);
578
- let records;
579
- if (windowMat) {
580
- const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
581
- const [rows] = await this.local.query(windowMat.query, {
582
- ...queryState.config.params,
583
- __win: winIds
584
- });
585
- records = rows || [];
586
- } else {
587
- const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
588
- records = rows || [];
820
+ }
821
+ /** True when idb database `name` belongs to the bucket store `storeName` —
822
+ * exact match or a derived name (`<storeName>`, `<storeName>-*`, `*<storeName>*`
823
+ * with a non-alphanumeric boundary so `sp00ky-abc` never matches
824
+ * `sp00ky-abcdef`'s store). Exported for unit tests. */
825
+ function matchesBucketStore(name, storeName) {
826
+ const lower = name.toLowerCase();
827
+ const target = storeName.toLowerCase();
828
+ const idx = lower.indexOf(target);
829
+ if (idx === -1) return false;
830
+ const after = lower[idx + target.length];
831
+ return after === void 0 || !/[a-z0-9_]/.test(after);
832
+ }
833
+ function deleteIndexedDb(name) {
834
+ return new Promise((resolve) => {
835
+ try {
836
+ const req = indexedDB.deleteDatabase(name);
837
+ req.onsuccess = () => resolve();
838
+ req.onerror = () => resolve();
839
+ req.onblocked = () => resolve();
840
+ } catch {
841
+ resolve();
589
842
  }
590
- this.recordPhase(queryState, "localFetch", performance.now() - t0);
591
- return records;
843
+ });
844
+ }
845
+
846
+ //#endregion
847
+ //#region src/services/database/remote.ts
848
+ var RemoteDatabaseService = class extends AbstractDatabaseService {
849
+ config;
850
+ eventType = DatabaseEventTypes.RemoteQuery;
851
+ constructor(config, logger) {
852
+ const events = createDatabaseEventSystem();
853
+ super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
854
+ if (phase === "progress" || phase === "after") logger.trace({
855
+ ...other,
856
+ key,
857
+ type,
858
+ phase,
859
+ service: "surrealdb:remote",
860
+ Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
861
+ }, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
862
+ }) }), logger, events);
863
+ this.config = config;
592
864
  }
593
- async processStreamUpdate(update) {
594
- const { queryHash, localArray, materializationTimeMs } = update;
595
- const queryState = this.activeQueries.get(queryHash);
596
- if (!queryState) {
597
- this.logger.warn({
598
- queryHash,
599
- Category: "sp00ky-client::DataModule::onStreamUpdate"
600
- }, "Received update for unknown query. Skipping...");
601
- return;
602
- }
603
- if (typeof materializationTimeMs === "number") {
604
- queryState.materializationSamples.push(materializationTimeMs);
605
- if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
606
- queryState.lastIngestLatencyMs = materializationTimeMs;
607
- }
608
- if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
609
- if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
610
- if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
611
- const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
612
- try {
613
- const newRecords = await this.materializeRecords(queryState, localArray);
614
- queryState.config.localArray = localArray;
615
- const prevJson = JSON.stringify(queryState.records);
616
- const newJson = JSON.stringify(newRecords);
617
- queryState.records = newRecords;
618
- const recordsChanged = prevJson !== newJson;
619
- if (recordsChanged) queryState.updateCount++;
620
- await this.local.query(surql.seal(surql.updateSet("id", [
621
- "localArray",
622
- "rowCount",
623
- "updateCount",
624
- "lastIngestLatency",
625
- "materializationP55",
626
- "materializationP90",
627
- "materializationP99"
628
- ])), {
629
- id: queryState.config.id,
630
- localArray,
631
- rowCount: localArray.length,
632
- updateCount: queryState.updateCount,
633
- lastIngestLatency: queryState.lastIngestLatencyMs,
634
- materializationP55: percentiles.p55,
635
- materializationP90: percentiles.p90,
636
- materializationP99: percentiles.p99
637
- });
638
- if (!recordsChanged) {
639
- this.logger.debug({
640
- queryHash,
641
- Category: "sp00ky-client::DataModule::onStreamUpdate"
642
- }, "Query records unchanged, skipping notification");
643
- return;
644
- }
645
- const subscribers = this.subscriptions.get(queryHash);
646
- if (subscribers) for (const callback of subscribers) callback(queryState.records);
647
- this.logger.debug({
648
- queryHash,
649
- recordCount: newRecords?.length,
650
- Category: "sp00ky-client::DataModule::onStreamUpdate"
651
- }, "Query updated from stream");
652
- } catch (err) {
653
- queryState.errorCount++;
654
- this.logger.error({
655
- err,
656
- queryHash,
657
- Category: "sp00ky-client::DataModule::onStreamUpdate"
658
- }, "Failed to fetch records for stream update");
865
+ getConfig() {
866
+ return this.config;
867
+ }
868
+ async connect() {
869
+ const { endpoint, token, namespace, database } = this.getConfig();
870
+ if (endpoint) {
871
+ this.logger.info({
872
+ endpoint,
873
+ namespace,
874
+ database,
875
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
876
+ }, "Connecting to remote database");
659
877
  try {
660
- await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
661
- id: queryState.config.id,
662
- errorCount: queryState.errorCount
878
+ await this.client.connect(endpoint);
879
+ await this.client.use({
880
+ namespace,
881
+ database
663
882
  });
664
- } catch (persistErr) {
665
- this.logger.warn({
666
- err: persistErr,
667
- queryHash,
668
- Category: "sp00ky-client::DataModule::onStreamUpdate"
669
- }, "Failed to persist incremented errorCount");
883
+ if (token) {
884
+ this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
885
+ await this.client.authenticate(token);
886
+ }
887
+ this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
888
+ } catch (err) {
889
+ this.logger.error({
890
+ err,
891
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
892
+ }, "Failed to connect to remote database");
893
+ throw err;
670
894
  }
671
- }
895
+ } else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
672
896
  }
673
- /**
674
- * Compute p55/p90/p99 from a rolling window of materialization samples.
675
- * Returns nulls for any percentile that has no samples yet so SurrealDB
676
- * `option<float>` columns stay NONE rather than 0 before the first ingest.
677
- */
678
- computeMaterializationPercentiles(samples) {
679
- if (samples.length === 0) return {
680
- p55: null,
681
- p90: null,
682
- p99: null
683
- };
684
- const sorted = [...samples].sort((a, b) => a - b);
685
- const pick = (q) => {
686
- return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
687
- };
688
- return {
689
- p55: pick(.55),
690
- p90: pick(.9),
691
- p99: pick(.99)
692
- };
897
+ async signin(params) {
898
+ return this.client.signin(params);
693
899
  }
694
- /** Record a per-phase timing sample (ms) on a query's rolling window. */
695
- recordPhase(qs, phase, ms) {
696
- if (!Number.isFinite(ms)) return;
697
- pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
698
- qs.phaseLast[phase] = ms;
900
+ async signup(params) {
901
+ return this.client.signup(params);
699
902
  }
700
- /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
701
- recordRemoteFetch(hash, ms) {
702
- const qs = this.activeQueries.get(hash);
703
- if (qs) this.recordPhase(qs, "remoteFetch", ms);
903
+ async authenticate(token) {
904
+ return this.client.authenticate(token);
704
905
  }
705
- /**
706
- * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
707
- * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
708
- */
709
- recordFrontendTiming(hash, ms) {
710
- const qs = this.activeQueries.get(hash);
711
- if (qs) this.recordPhase(qs, "frontend", ms);
906
+ async invalidate() {
907
+ return this.client.invalidate();
712
908
  }
713
- /**
714
- * Build the per-query processing-time breakdown surfaced to the DevTools panel
715
- * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
716
- * the rest come from the per-phase rolling windows + one-shot registration timings.
717
- */
718
- phaseTimings(q) {
719
- const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
720
- return {
721
- ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
722
- sspStoreApply: stat("sspStoreApply"),
723
- sspCircuitStep: stat("sspCircuitStep"),
724
- sspTransform: stat("sspTransform"),
725
- localFetch: stat("localFetch"),
726
- remoteFetch: stat("remoteFetch"),
727
- frontend: stat("frontend"),
728
- registration: q.registrationTimings,
729
- updateCount: q.updateCount,
730
- errorCount: q.errorCount
731
- };
909
+ };
910
+
911
+ //#endregion
912
+ //#region src/services/logger/index.ts
913
+ function createLogger(level = "info", transmit) {
914
+ const browserConfig = {
915
+ asObject: true,
916
+ write: (o) => {
917
+ console.log(JSON.stringify(o));
918
+ }
919
+ };
920
+ if (transmit) browserConfig.transmit = transmit;
921
+ return pino({
922
+ level,
923
+ browser: browserConfig
924
+ });
925
+ }
926
+
927
+ //#endregion
928
+ //#region src/services/database/local-migrator.ts
929
+ const sha1 = async (str) => {
930
+ const enc = new TextEncoder();
931
+ const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
932
+ return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
933
+ };
934
+ var LocalMigrator = class {
935
+ logger;
936
+ constructor(localDb, logger) {
937
+ this.localDb = localDb;
938
+ this.logger = logger.child({ service: "LocalMigrator" });
939
+ logger?.child({ service: "LocalMigrator" }) ?? createLogger("info").child({ service: "LocalMigrator" });
732
940
  }
733
- /**
734
- * Get query state (for sync and devtools)
735
- */
736
- getQueryByHash(hash) {
737
- return this.activeQueries.get(hash);
941
+ async provision(schemaSurql) {
942
+ const hash = await sha1(schemaSurql);
943
+ const { database } = this.localDb.getConfig();
944
+ if (await this.isSchemaUpToDate(hash)) {
945
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
946
+ return;
947
+ }
948
+ await this.recreateDatabase(database);
949
+ const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
950
+ const statements = this.splitStatements(fullSchema);
951
+ for (let i = 0; i < statements.length; i++) {
952
+ const statement = statements[i];
953
+ const cleanStatement = statement.replace(/--.*/g, "").trim();
954
+ if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
955
+ this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
956
+ continue;
957
+ }
958
+ try {
959
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
960
+ await this.localDb.queryUngated(statement);
961
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
962
+ } catch (e) {
963
+ this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
964
+ throw e;
965
+ }
966
+ }
967
+ await this.createHashRecord(hash);
738
968
  }
739
- /**
740
- * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
741
- * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
742
- * We gate on `remoteArray`, not local `records`: a windowed query is often
743
- * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
744
- * but it still hasn't loaded its own full window from the server — so it should
745
- * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
746
- */
747
- isCold(hash) {
748
- const qs = this.activeQueries.get(hash);
749
- return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
969
+ async isSchemaUpToDate(hash) {
970
+ try {
971
+ const [lastSchemaRecord] = await this.localDb.queryUngated(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
972
+ return lastSchemaRecord?.hash === hash;
973
+ } catch (_error) {
974
+ return false;
975
+ }
750
976
  }
751
- /**
752
- * Walk a hydrated record's fields and append any EMBEDDED child records to
753
- * `batch` (recursing for nested related fields). An embedded child is a
754
- * value that is itself a record — a non-null object whose `id` is a
755
- * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
756
- * bare `RecordId` (a foreign-key reference) or any other value is skipped,
757
- * so this never mistakes a FK column for an embedded body. Children are
758
- * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
759
- * to their table's real columns (which strips the alias/related fields).
760
- * `seen` dedupes within the batch.
761
- */
762
- collectEmbeddedChildren(record, batch, seen) {
763
- const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
764
- for (const value of Object.values(record)) {
765
- const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
766
- for (const child of children) {
767
- const key = encodeRecordId(child.id);
768
- if (seen.has(key)) continue;
769
- seen.add(key);
770
- this.collectEmbeddedChildren(child, batch, seen);
771
- const table = child.id.table.toString();
772
- const tableSchema = this.schema.tables.find((t) => t.name === table);
773
- batch.push({
774
- table,
775
- op: "CREATE",
776
- record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
777
- version: child._00_rv || 1
778
- });
977
+ async recreateDatabase(database) {
978
+ try {
979
+ await this.localDb.queryUngated(`DEFINE DATABASE _00_temp;`);
980
+ } catch (_e) {}
981
+ try {
982
+ await this.localDb.queryUngated(`
983
+ USE DB _00_temp;
984
+ REMOVE DATABASE ${database};
985
+ `);
986
+ } catch (_e) {}
987
+ await this.localDb.queryUngated(`
988
+ DEFINE DATABASE ${database};
989
+ USE DB ${database};
990
+ `);
991
+ }
992
+ splitStatements(schema) {
993
+ const statements = [];
994
+ let current = "";
995
+ let depth = 0;
996
+ let inQuote = false;
997
+ let quoteChar = "";
998
+ let inComment = false;
999
+ for (let i = 0; i < schema.length; i++) {
1000
+ const char = schema[i];
1001
+ const nextChar = schema[i + 1];
1002
+ if (inComment) {
1003
+ current += char;
1004
+ if (char === "\n") inComment = false;
1005
+ continue;
1006
+ }
1007
+ if (!inQuote && char === "-" && nextChar === "-") {
1008
+ inComment = true;
1009
+ current += char;
1010
+ continue;
1011
+ }
1012
+ if (inQuote) {
1013
+ current += char;
1014
+ if (char === quoteChar && schema[i - 1] !== "\\") inQuote = false;
1015
+ continue;
1016
+ }
1017
+ if (char === "\"" || char === "'") {
1018
+ inQuote = true;
1019
+ quoteChar = char;
1020
+ current += char;
1021
+ continue;
1022
+ }
1023
+ if (char === "{") {
1024
+ depth++;
1025
+ current += char;
1026
+ continue;
1027
+ }
1028
+ if (char === "}") {
1029
+ depth--;
1030
+ current += char;
1031
+ continue;
1032
+ }
1033
+ if (char === ";" && depth === 0) {
1034
+ if (current.trim().length > 0) statements.push(current.trim());
1035
+ current = "";
1036
+ continue;
779
1037
  }
1038
+ current += char;
780
1039
  }
1040
+ if (current.trim().length > 0) statements.push(current.trim());
1041
+ return statements;
781
1042
  }
782
- /**
783
- * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
784
- * surql run directly) so the query DISPLAYS immediately, while the full realtime
785
- * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
786
- * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
787
- * `remoteArray` so windowed queries materialize the correct window (no sparse
788
- * local-circuit issue). Runs at most once per query (the `hydrated` flag).
789
- */
790
- async applyHydration(hash, rows) {
791
- const queryState = this.activeQueries.get(hash);
792
- if (!queryState) return;
793
- queryState.hydrated = true;
794
- if (rows.length === 0) return;
795
- const tableName = queryState.config.tableName;
796
- const batch = rows.map((record) => ({
797
- table: tableName,
798
- op: "CREATE",
799
- record,
800
- version: record._00_rv || 1
801
- }));
802
- const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
803
- for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
804
- await this.cache.saveBatch(batch);
805
- queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
806
- queryState.records = await this.materializeRecords(queryState);
807
- const subscribers = this.subscriptions.get(hash);
808
- if (subscribers) for (const cb of subscribers) cb(queryState.records);
809
- }
810
- /** True while ≥1 live subscriber is watching this query (refcount guard). */
811
- hasSubscribers(hash) {
812
- return (this.subscriptions.get(hash)?.size ?? 0) > 0;
1043
+ async createHashRecord(hash) {
1044
+ await this.localDb.queryUngated(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
813
1045
  }
814
- /**
815
- * Opt-in eager teardown for a query whose LAST subscriber just left — used by
816
- * viewport-windowed lists to cancel off-screen windows instead of leaving
817
- * their remote views to expire on the TTL sweep. No-op while any subscriber
818
- * remains (refcount). Only enqueues the remote cleanup here; the local WASM
819
- * view + in-memory state are freed in {@link finalizeDeregister} after the
820
- * remote delete completes, so a re-subscribe in between aborts/heals it.
821
- *
822
- * NOTE: most queries should NOT use this — the default keep-alive on
823
- * unsubscribe avoids re-registration churn on navigation.
824
- */
825
- deregisterQuery(hash) {
826
- if (this.hasSubscribers(hash)) return;
827
- if (!this.activeQueries.has(hash)) return;
828
- this.onDeregister?.(hash);
1046
+ };
1047
+
1048
+ //#endregion
1049
+ //#region src/modules/data/window-query.ts
1050
+ /**
1051
+ * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
1052
+ * materialized from an explicit record-id set the window the SSP already
1053
+ * computed — instead of re-running the original query (with its `START m`)
1054
+ * against the shared local DB.
1055
+ *
1056
+ * Why: `DataModule.processStreamUpdate` materializes a query's rows by
1057
+ * re-querying the local in-browser SurrealDB with the original surql. For an
1058
+ * offset query that re-applies `START m` against the *shared* local store —
1059
+ * which, with sparse windowing, may hold only this window's rows — so it skips
1060
+ * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
1061
+ * materialized view (`StreamUpdate.localArray`) is exactly this window's row
1062
+ * ids, so we select those ids directly and re-apply the original `ORDER BY` for
1063
+ * stable display order.
1064
+ *
1065
+ * Returns `null` for non-offset queries (`START` absent or 0) — the caller
1066
+ * keeps the normal re-query path, so only the broken case changes behavior.
1067
+ *
1068
+ * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
1069
+ * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
1070
+ * projection are preserved verbatim (the scanner is paren/quote aware), with
1071
+ * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
1072
+ * shape — such windowed+subquery queries are rare and were already returning 0,
1073
+ * so this is no regression.
1074
+ */
1075
+ function buildWindowMaterialization(surql, idsParam = "__win") {
1076
+ const kw = scanTopLevelClauses(surql);
1077
+ if (kw.startValue === null || kw.startValue <= 0) return null;
1078
+ if (kw.fromIndex === null) return null;
1079
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
1080
+ let orderBy = "";
1081
+ if (kw.orderByIndex !== null) {
1082
+ const ends = [
1083
+ kw.limitIndex,
1084
+ kw.startIndex,
1085
+ kw.semicolonIndex,
1086
+ surql.length
1087
+ ].filter((n) => n !== null && n > kw.orderByIndex);
1088
+ const end = Math.min(...ends);
1089
+ orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
829
1090
  }
830
- /**
831
- * Final local teardown after the remote `_00_query` row was deleted: free the
832
- * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
833
- * (`cleanupQuery`) guarantees no subscriber remains.
834
- */
835
- finalizeDeregister(hash) {
836
- const qs = this.activeQueries.get(hash);
837
- if (qs?.ttlTimer) {
838
- clearTimeout(qs.ttlTimer);
839
- qs.ttlTimer = null;
1091
+ return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
1092
+ }
1093
+ function scanTopLevelClauses(sql) {
1094
+ const out = {
1095
+ fromIndex: null,
1096
+ orderByIndex: null,
1097
+ limitIndex: null,
1098
+ startIndex: null,
1099
+ startValue: null,
1100
+ semicolonIndex: null
1101
+ };
1102
+ let depth = 0;
1103
+ let inStr = false;
1104
+ for (let i = 0; i < sql.length; i++) {
1105
+ const ch = sql[i];
1106
+ if (inStr) {
1107
+ if (ch === "\\") i++;
1108
+ else if (ch === "'") inStr = false;
1109
+ continue;
840
1110
  }
841
- const debounce = this.debounceTimers.get(hash);
842
- if (debounce) {
843
- clearTimeout(debounce);
844
- this.debounceTimers.delete(hash);
1111
+ if (ch === "'") {
1112
+ inStr = true;
1113
+ continue;
845
1114
  }
846
- this.cache.unregisterQuery(hash);
847
- this.activeQueries.delete(hash);
848
- this.subscriptions.delete(hash);
849
- }
850
- /**
851
- * Get query state by id (for sync and devtools)
852
- */
853
- getQueryById(id) {
854
- return this.activeQueries.get(extractIdPart(id));
855
- }
856
- /**
857
- * Get all active queries (for devtools)
858
- */
859
- getActiveQueries() {
860
- return Array.from(this.activeQueries.values());
861
- }
862
- getActiveQueryHashes() {
863
- return Array.from(this.activeQueries.keys());
864
- }
865
- async updateQueryLocalArray(id, localArray) {
866
- const queryState = this.activeQueries.get(id);
867
- if (!queryState) {
868
- this.logger.warn({
869
- id,
870
- Category: "sp00ky-client::DataModule::updateQueryLocalArray"
871
- }, "Query to update local array not found");
872
- return;
1115
+ if (ch === "(") {
1116
+ depth++;
1117
+ continue;
1118
+ }
1119
+ if (ch === ")") {
1120
+ depth--;
1121
+ continue;
1122
+ }
1123
+ if (depth !== 0) continue;
1124
+ if (ch === ";" && out.semicolonIndex === null) {
1125
+ out.semicolonIndex = i;
1126
+ continue;
1127
+ }
1128
+ if (!isWordBoundary(sql, i)) continue;
1129
+ if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
1130
+ out.fromIndex = i;
1131
+ continue;
1132
+ }
1133
+ if (out.fromIndex === null) continue;
1134
+ if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
1135
+ out.orderByIndex = i;
1136
+ continue;
1137
+ }
1138
+ if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
1139
+ out.limitIndex = i;
1140
+ continue;
1141
+ }
1142
+ if (out.startIndex === null && matchKeyword(sql, i, "START")) {
1143
+ out.startIndex = i;
1144
+ out.startValue = readNumberAfter(sql, i + 5);
1145
+ continue;
873
1146
  }
874
- queryState.config.localArray = localArray;
875
- await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
876
- id: queryState.config.id,
877
- localArray
878
- });
879
1147
  }
880
- async updateQueryRemoteArray(hash, remoteArray) {
881
- const queryState = this.getQueryByHash(hash);
882
- if (!queryState) {
883
- this.logger.warn({
884
- hash,
885
- Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
886
- }, "Query to update remote array not found");
887
- return;
1148
+ return out;
1149
+ }
1150
+ function isWordBoundary(sql, i) {
1151
+ if (i === 0) return true;
1152
+ return !/[A-Za-z0-9_]/.test(sql[i - 1]);
1153
+ }
1154
+ function matchKeyword(sql, i, keyword) {
1155
+ const parts = keyword.split(" ");
1156
+ let pos = i;
1157
+ for (let p = 0; p < parts.length; p++) {
1158
+ const word = parts[p];
1159
+ if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
1160
+ pos += word.length;
1161
+ if (p < parts.length - 1) {
1162
+ const wsStart = pos;
1163
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
1164
+ if (pos === wsStart) return false;
888
1165
  }
889
- queryState.config.remoteArray = remoteArray;
890
- await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
891
- id: queryState.config.id,
892
- remoteArray
893
- });
894
1166
  }
1167
+ return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
1168
+ }
1169
+ function readNumberAfter(sql, from) {
1170
+ let pos = from;
1171
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
1172
+ const m = /^\d+/.exec(sql.slice(pos));
1173
+ return m ? parseInt(m[0], 10) : null;
1174
+ }
1175
+
1176
+ //#endregion
1177
+ //#region src/modules/data/index.ts
1178
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
1179
+ function pushSample(samples, ms) {
1180
+ samples.push(ms);
1181
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
1182
+ }
1183
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
1184
+ function phaseStatOf(samples, lastMs) {
1185
+ if (samples.length === 0) return {
1186
+ lastMs,
1187
+ p50: null,
1188
+ p90: null,
1189
+ p99: null,
1190
+ count: 0
1191
+ };
1192
+ const sorted = [...samples].sort((a, b) => a - b);
1193
+ const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
1194
+ return {
1195
+ lastMs,
1196
+ p50: pick(.5),
1197
+ p90: pick(.9),
1198
+ p99: pick(.99),
1199
+ count: samples.length
1200
+ };
1201
+ }
1202
+ /**
1203
+ * DataModule - Unified query and mutation management
1204
+ *
1205
+ * Merges the functionality of QueryManager and MutationManager.
1206
+ * Uses CacheModule for all storage operations.
1207
+ */
1208
+ var DataModule = class {
1209
+ activeQueries = /* @__PURE__ */ new Map();
1210
+ pendingQueries = /* @__PURE__ */ new Map();
1211
+ subscriptions = /* @__PURE__ */ new Map();
1212
+ statusSubscriptions = /* @__PURE__ */ new Map();
1213
+ mutationCallbacks = /* @__PURE__ */ new Set();
1214
+ debounceTimers = /* @__PURE__ */ new Map();
1215
+ pendingStreamUpdates = /* @__PURE__ */ new Map();
1216
+ fetchDepth = /* @__PURE__ */ new Map();
1217
+ logger;
895
1218
  /**
896
- * Called after a query's initial sync completes.
897
- * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
1219
+ * Optional observer notified whenever a query's fetch status changes.
1220
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
1221
+ * settable field (rather than a constructor arg) because DevTools is
1222
+ * constructed after DataModule.
898
1223
  */
899
- async notifyQuerySynced(queryHash) {
900
- const queryState = this.activeQueries.get(queryHash);
901
- if (!queryState) return;
902
- const newRecords = await this.materializeRecords(queryState);
903
- const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
904
- queryState.records = newRecords;
905
- if (changed || queryState.updateCount === 0) {
906
- queryState.updateCount++;
907
- const subscribers = this.subscriptions.get(queryHash);
908
- if (subscribers) for (const callback of subscribers) callback(queryState.records);
909
- }
1224
+ onQueryStatusChange;
1225
+ /**
1226
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
1227
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
1228
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
1229
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
1230
+ * field (not a constructor arg) because the sync engine is wired after
1231
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
1232
+ */
1233
+ onHeartbeat;
1234
+ /**
1235
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
1236
+ * viewport-windowed list cancelling an off-screen window) loses its last
1237
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
1238
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
1239
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
1240
+ * {@link finalizeDeregister} only after that remote delete, so a fast
1241
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
1242
+ */
1243
+ onDeregister;
1244
+ sessionId = "";
1245
+ currentUserId = null;
1246
+ constructor(cache, local, schema, logger, streamDebounceTime = 50) {
1247
+ this.cache = cache;
1248
+ this.local = local;
1249
+ this.schema = schema;
1250
+ this.streamDebounceTime = streamDebounceTime;
1251
+ this.logger = logger.child({ service: "DataModule" });
910
1252
  }
911
- async run(backend, path, data, options) {
912
- const route = this.schema.backends?.[backend]?.routes?.[path];
913
- if (!route) throw new Error(`Route ${backend}.${path} not found`);
914
- const tableName = this.schema.backends?.[backend]?.outboxTable;
915
- if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
916
- const payload = {};
917
- for (const argName of Object.keys(route.args)) {
918
- const arg = route.args[argName];
919
- if (data[argName] === void 0 && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
920
- payload[argName] = data[argName];
921
- }
922
- const record = {
923
- path,
924
- payload: JSON.stringify(payload),
925
- max_retries: options?.max_retries ?? 3,
926
- retry_strategy: options?.retry_strategy ?? "linear"
927
- };
928
- if (options?.timeout != null) record.timeout = options.timeout;
929
- if (options?.delay != null) record.delay = options.delay;
930
- if (options?.assignedTo) record.assigned_to = options.assignedTo;
931
- const recordId = `${tableName}:${generateId()}`;
932
- await this.create(recordId, record);
1253
+ async init(sessionId) {
1254
+ this.sessionId = sessionId;
1255
+ this.logger.info({
1256
+ sessionId,
1257
+ Category: "sp00ky-client::DataModule::init"
1258
+ }, "DataModule initialized");
933
1259
  }
934
1260
  /**
935
- * Create a new record
1261
+ * Update the session salt used in query-id hashing. Call this when the
1262
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
1263
+ * registered queries will get fresh, session-scoped IDs.
936
1264
  */
937
- async create(id, data) {
938
- const tableName = extractTablePart(id);
939
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
940
- if (!tableSchema) throw new Error(`Table ${tableName} not found`);
941
- const rid = parseRecordIdString(id);
942
- const params = parseParams(tableSchema.columns, data);
943
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
944
- const dataKeys = Object.keys(params).map((key) => ({
945
- key,
946
- variable: `data_${key}`
947
- }));
948
- const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, params[key]]));
949
- const query = surql.seal(surql.tx([surql.createSet("id", dataKeys), surql.createMutation("create", "mid", "id", "data")]), { resultIndex: 0 });
950
- const target = await withRetry(this.logger, () => this.local.execute(query, {
951
- id: rid,
952
- mid: mutationId,
953
- ...prefixedParams
954
- }));
955
- const parsedRecord = parseParams(tableSchema.columns, target);
956
- await this.cache.save({
957
- table: tableName,
958
- op: "CREATE",
959
- record: parsedRecord,
960
- version: 1
961
- }, true);
962
- const mutationEvent = {
963
- type: "create",
964
- mutation_id: mutationId,
965
- record_id: rid,
966
- data: params,
967
- record: target,
968
- tableName
969
- };
970
- for (const callback of this.mutationCallbacks) callback([mutationEvent]);
971
- this.logger.debug({
972
- id,
973
- Category: "sp00ky-client::DataModule::create"
974
- }, "Record created");
975
- return target;
1265
+ setSessionId(sessionId) {
1266
+ this.sessionId = sessionId;
976
1267
  }
977
1268
  /**
978
- * Update an existing record
1269
+ * Update the authenticated user record id. Pass `null` on sign-out.
1270
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
1271
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
1272
+ * SSP writes to.
979
1273
  */
980
- async update(table, id, data, options) {
981
- const tableName = extractTablePart(id);
982
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
983
- if (!tableSchema) throw new Error(`Table ${tableName} not found`);
984
- const rid = parseRecordIdString(id);
985
- const params = parseParams(tableSchema.columns, data);
986
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
987
- const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
988
- const query = surql.seal(surql.tx([
989
- surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
990
- surql.let("updated", surql.updateMerge("id", "data")),
991
- surql.createMutation("update", "mid", "id", "data"),
992
- surql.returnObject([{
993
- key: "target",
994
- variable: "updated"
995
- }])
996
- ]));
997
- const { target } = await withRetry(this.logger, () => this.local.execute(query, {
998
- id: rid,
999
- mid: mutationId,
1000
- data: params
1001
- }));
1002
- const updatedFields = { id: target.id };
1003
- for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
1004
- if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
1005
- this.replaceRecordInQueries(updatedFields);
1006
- const parsedRecord = parseParams(tableSchema.columns, target);
1007
- await this.cache.save({
1008
- table,
1009
- op: "UPDATE",
1010
- record: parsedRecord,
1011
- version: target._00_rv
1012
- }, true);
1013
- const pushEventOptions = parseUpdateOptions(id, data, options);
1014
- const mutationEvent = {
1015
- type: "update",
1016
- mutation_id: mutationId,
1017
- record_id: rid,
1018
- data: params,
1019
- record: target,
1020
- beforeRecord: beforeRecord || void 0,
1021
- options: pushEventOptions
1022
- };
1023
- for (const callback of this.mutationCallbacks) callback([mutationEvent]);
1024
- this.logger.debug({
1025
- id,
1026
- Category: "sp00ky-client::DataModule::update"
1027
- }, "Record updated");
1028
- return target;
1274
+ setCurrentUserId(userId) {
1275
+ this.currentUserId = userId;
1276
+ }
1277
+ /** Read-only view of the authenticated user id used for per-user
1278
+ * `_00_list_ref` routing. Other modules consult this so they pick the
1279
+ * same table name DataModule does. */
1280
+ getCurrentUserId() {
1281
+ return this.currentUserId;
1029
1282
  }
1030
1283
  /**
1031
- * Delete a record
1284
+ * Register a query and return its hash for subscriptions
1032
1285
  */
1033
- async delete(table, id) {
1034
- const tableName = extractTablePart(id);
1035
- if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
1036
- const rid = parseRecordIdString(id);
1037
- const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1038
- const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
1039
- const beforeRecord = beforeRecords ?? {};
1040
- const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
1041
- await withRetry(this.logger, () => this.local.execute(query, {
1042
- id: rid,
1043
- mid: mutationId
1044
- }));
1045
- try {
1046
- await this.cache.delete(table, id, true, beforeRecord);
1047
- } catch (err) {
1048
- this.logger.error({
1049
- err,
1050
- id,
1051
- Category: "sp00ky-client::DataModule::delete"
1052
- }, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
1286
+ async query(tableName, surqlString, params, ttl) {
1287
+ const hash = await this.calculateHash({
1288
+ surql: surqlString,
1289
+ params
1290
+ });
1291
+ this.logger.debug({
1292
+ hash,
1293
+ Category: "sp00ky-client::DataModule::query"
1294
+ }, "Query Initialization: started");
1295
+ const recordId = new RecordId("_00_query", hash);
1296
+ if (this.activeQueries.has(hash)) {
1297
+ this.logger.debug({
1298
+ hash,
1299
+ Category: "sp00ky-client::DataModule::query"
1300
+ }, "Query Initialization: exists, returning");
1301
+ return hash;
1053
1302
  }
1054
- for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
1055
- await this.notifyQuerySynced(queryHash);
1056
- } catch (err) {
1057
- this.logger.error({
1058
- err,
1059
- queryHash,
1060
- Category: "sp00ky-client::DataModule::delete"
1061
- }, "notifyQuerySynced failed after delete");
1303
+ if (this.pendingQueries.has(hash)) {
1304
+ this.logger.debug({
1305
+ hash,
1306
+ Category: "sp00ky-client::DataModule::query"
1307
+ }, "Query Initialization: pending, waiting for existing creation");
1308
+ await this.pendingQueries.get(hash);
1309
+ return hash;
1062
1310
  }
1063
- const mutationEvent = {
1064
- type: "delete",
1065
- mutation_id: mutationId,
1066
- record_id: rid
1067
- };
1068
- for (const callback of this.mutationCallbacks) callback([mutationEvent]);
1069
1311
  this.logger.debug({
1070
- id,
1071
- Category: "sp00ky-client::DataModule::delete"
1072
- }, "Record deleted");
1312
+ hash,
1313
+ Category: "sp00ky-client::DataModule::query"
1314
+ }, "Query Initialization: not found, creating new query");
1315
+ const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
1316
+ this.pendingQueries.set(hash, promise);
1317
+ try {
1318
+ await promise;
1319
+ } finally {
1320
+ this.pendingQueries.delete(hash);
1321
+ }
1322
+ return hash;
1073
1323
  }
1074
1324
  /**
1075
- * Rollback a failed optimistic create by deleting the record locally
1325
+ * Subscribe to query updates
1076
1326
  */
1077
- async rollbackCreate(recordId, tableName) {
1078
- const id = encodeRecordId(recordId);
1079
- try {
1080
- await withRetry(this.logger, () => this.local.query("DELETE $id", { id: recordId }));
1081
- await this.cache.delete(tableName, id, true);
1082
- this.removeRecordFromQueries(recordId);
1083
- this.logger.info({
1084
- id,
1085
- tableName,
1086
- Category: "sp00ky-client::DataModule::rollbackCreate"
1087
- }, "Rolled back optimistic create");
1088
- } catch (err) {
1089
- this.logger.error({
1090
- err,
1091
- id,
1092
- tableName,
1093
- Category: "sp00ky-client::DataModule::rollbackCreate"
1094
- }, "Failed to rollback create");
1327
+ subscribe(queryHash, callback, options = {}) {
1328
+ if (!this.subscriptions.has(queryHash)) this.subscriptions.set(queryHash, /* @__PURE__ */ new Set());
1329
+ this.subscriptions.get(queryHash)?.add(callback);
1330
+ if (options.immediate) {
1331
+ const query = this.activeQueries.get(queryHash);
1332
+ if (query) callback(query.records);
1095
1333
  }
1334
+ return () => {
1335
+ const subs = this.subscriptions.get(queryHash);
1336
+ if (subs) {
1337
+ subs.delete(callback);
1338
+ if (subs.size === 0) this.subscriptions.delete(queryHash);
1339
+ }
1340
+ };
1096
1341
  }
1097
1342
  /**
1098
- * Rollback a failed optimistic update by restoring the previous record state
1343
+ * Subscribe to a query's fetch-status changes (idle/fetching).
1344
+ * With `{ immediate: true }` the callback fires synchronously with the
1345
+ * current status (defaults to `idle` if the query isn't registered yet).
1099
1346
  */
1100
- async rollbackUpdate(recordId, tableName, beforeRecord) {
1101
- const id = encodeRecordId(recordId);
1102
- try {
1103
- const { id: _recordId, ...content } = beforeRecord;
1104
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.upsert("id", "content")), {
1105
- id: recordId,
1106
- content
1107
- }));
1108
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1109
- const parsedRecord = tableSchema ? parseParams(tableSchema.columns, beforeRecord) : beforeRecord;
1110
- await this.cache.save({
1111
- table: tableName,
1112
- op: "UPDATE",
1113
- record: parsedRecord,
1114
- version: beforeRecord._00_rv || 1
1115
- }, true);
1116
- await this.replaceRecordInQueries(beforeRecord);
1117
- this.logger.info({
1118
- id,
1119
- tableName,
1120
- Category: "sp00ky-client::DataModule::rollbackUpdate"
1121
- }, "Rolled back optimistic update");
1122
- } catch (err) {
1123
- this.logger.error({
1124
- err,
1125
- id,
1126
- tableName,
1127
- Category: "sp00ky-client::DataModule::rollbackUpdate"
1128
- }, "Failed to rollback update");
1347
+ subscribeStatus(queryHash, callback, options = {}) {
1348
+ if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
1349
+ this.statusSubscriptions.get(queryHash)?.add(callback);
1350
+ if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
1351
+ return () => {
1352
+ const subs = this.statusSubscriptions.get(queryHash);
1353
+ if (subs) {
1354
+ subs.delete(callback);
1355
+ if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
1356
+ }
1357
+ };
1358
+ }
1359
+ /**
1360
+ * Set a query's fetch status and notify status observers (DevTools +
1361
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
1362
+ * query is unknown.
1363
+ */
1364
+ setQueryStatus(queryHash, status) {
1365
+ const queryState = this.activeQueries.get(queryHash);
1366
+ if (!queryState || queryState.status === status) return;
1367
+ queryState.status = status;
1368
+ this.onQueryStatusChange?.(queryHash, status);
1369
+ const subs = this.statusSubscriptions.get(queryHash);
1370
+ if (subs) for (const callback of subs) callback(status);
1371
+ }
1372
+ /**
1373
+ * Enter a fetch cycle for a query. Refcounted: registration and concurrent
1374
+ * poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
1375
+ * cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
1376
+ * emits `idle`. Always pair with `endFetching` in a `finally`.
1377
+ */
1378
+ beginFetching(queryHash) {
1379
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
1380
+ this.fetchDepth.set(queryHash, depth + 1);
1381
+ if (depth === 0) this.setQueryStatus(queryHash, "fetching");
1382
+ }
1383
+ /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
1384
+ endFetching(queryHash) {
1385
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
1386
+ if (depth <= 1) {
1387
+ this.fetchDepth.delete(queryHash);
1388
+ this.setQueryStatus(queryHash, "idle");
1389
+ return;
1129
1390
  }
1391
+ this.fetchDepth.set(queryHash, depth - 1);
1130
1392
  }
1131
1393
  /**
1132
- * Remove a record from all active query states and notify subscribers
1394
+ * Subscribe to mutations (for sync)
1133
1395
  */
1134
- removeRecordFromQueries(recordId) {
1135
- const encodedId = encodeRecordId(recordId);
1136
- for (const [queryHash, queryState] of this.activeQueries.entries()) {
1137
- const index = queryState.records.findIndex((r) => {
1138
- return (r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id)) === encodedId;
1139
- });
1140
- if (index !== -1) {
1141
- queryState.records.splice(index, 1);
1142
- const subscribers = this.subscriptions.get(queryHash);
1143
- if (subscribers) for (const callback of subscribers) callback(queryState.records);
1396
+ onMutation(callback) {
1397
+ this.mutationCallbacks.add(callback);
1398
+ return () => {
1399
+ this.mutationCallbacks.delete(callback);
1400
+ };
1401
+ }
1402
+ /**
1403
+ * Handle stream updates from DBSP (via CacheModule)
1404
+ */
1405
+ async onStreamUpdate(update) {
1406
+ const { queryHash, op } = update;
1407
+ if (op === "DELETE") {
1408
+ const existing = this.debounceTimers.get(queryHash);
1409
+ if (existing) {
1410
+ clearTimeout(existing);
1411
+ this.debounceTimers.delete(queryHash);
1144
1412
  }
1413
+ this.pendingStreamUpdates.delete(queryHash);
1414
+ await this.processStreamUpdate(update);
1415
+ return;
1145
1416
  }
1417
+ if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
1418
+ this.pendingStreamUpdates.set(queryHash, update);
1419
+ const timer = setTimeout(async () => {
1420
+ this.debounceTimers.delete(queryHash);
1421
+ this.pendingStreamUpdates.delete(queryHash);
1422
+ await this.processStreamUpdate(update);
1423
+ }, this.streamDebounceTime);
1424
+ this.debounceTimers.set(queryHash, timer);
1146
1425
  }
1147
- async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
1148
- const queryState = await this.createNewQuery({
1149
- recordId,
1150
- surql: surqlString,
1151
- params,
1152
- ttl,
1153
- tableName
1154
- });
1426
+ /**
1427
+ * Process a query's pending (debounced) stream update NOW instead of on the
1428
+ * trailing edge. Called by the sync engine before it flips a query back to
1429
+ * `idle`, so the status change never races ahead of the rows it fetched.
1430
+ * No-op when nothing is pending. The pending entry is removed before the
1431
+ * await so a concurrently-firing timer can't process it twice.
1432
+ */
1433
+ async flushPendingStreamUpdate(queryHash) {
1434
+ const timer = this.debounceTimers.get(queryHash);
1435
+ if (timer) {
1436
+ clearTimeout(timer);
1437
+ this.debounceTimers.delete(queryHash);
1438
+ }
1439
+ const pending = this.pendingStreamUpdates.get(queryHash);
1440
+ if (!pending) return;
1441
+ this.pendingStreamUpdates.delete(queryHash);
1442
+ await this.processStreamUpdate(pending);
1443
+ }
1444
+ async materializeRecords(queryState, sspArray) {
1155
1445
  const t0 = performance.now();
1156
- const { localArray, registrationTimings } = this.cache.registerQuery({
1157
- queryHash: hash,
1158
- surql: surqlString,
1159
- params,
1160
- ttl: new Duration(ttl),
1161
- lastActiveAt: /* @__PURE__ */ new Date()
1162
- });
1163
- const registrationTime = performance.now() - t0;
1164
- queryState.registrationTimings = {
1165
- parseMs: registrationTimings?.parseMs ?? null,
1166
- planMs: registrationTimings?.planMs ?? null,
1167
- snapshotMs: registrationTimings?.snapshotMs ?? null,
1168
- wallMs: registrationTime
1169
- };
1170
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
1171
- "localArray",
1172
- "registrationTime",
1173
- "rowCount"
1174
- ])), {
1175
- id: recordId,
1176
- localArray,
1177
- registrationTime,
1178
- rowCount: localArray.length
1179
- }));
1180
- const windowMat = buildWindowMaterialization(surqlString);
1181
- if (windowMat && localArray.length > 0) try {
1182
- const winIds = localArray.map(([id]) => parseRecordIdString(id));
1183
- const [seeded] = await this.local.query(windowMat.query, {
1184
- ...params,
1446
+ const windowMat = buildWindowMaterialization(queryState.config.surql);
1447
+ let records;
1448
+ if (windowMat) {
1449
+ const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
1450
+ const [rows] = await this.local.query(windowMat.query, {
1451
+ ...queryState.config.params,
1185
1452
  __win: winIds
1186
1453
  });
1187
- queryState.records = seeded || [];
1188
- } catch (err) {
1189
- this.logger.warn({
1190
- err,
1191
- hash,
1192
- Category: "sp00ky-client::DataModule::createAndRegisterQuery"
1193
- }, "Failed to seed windowed initial records from localArray");
1454
+ records = rows || [];
1455
+ } else {
1456
+ const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
1457
+ records = rows || [];
1194
1458
  }
1195
- this.activeQueries.set(hash, queryState);
1196
- this.startTTLHeartbeat(queryState, hash);
1197
- this.logger.debug({
1198
- hash,
1199
- tableName,
1200
- recordCount: queryState.records.length,
1201
- Category: "sp00ky-client::DataModule::query"
1202
- }, "Query registered");
1203
- return hash;
1459
+ this.recordPhase(queryState, "localFetch", performance.now() - t0);
1460
+ return records;
1204
1461
  }
1205
- async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
1206
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1207
- if (!tableSchema) throw new Error(`Table ${tableName} not found`);
1208
- let [configRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: recordId }));
1209
- if (!configRecord) {
1210
- const [createdRecord] = await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
1211
- id: recordId,
1212
- data: {
1213
- surql: surqlString,
1214
- params,
1215
- localArray: [],
1216
- remoteArray: [],
1217
- lastActiveAt: /* @__PURE__ */ new Date(),
1218
- createdAt: /* @__PURE__ */ new Date(),
1219
- ttl,
1220
- tableName,
1221
- updateCount: 0,
1222
- rowCount: 0,
1223
- errorCount: 0
1224
- }
1225
- }));
1226
- configRecord = createdRecord;
1227
- }
1228
- const config = {
1229
- ...configRecord,
1230
- id: recordId,
1231
- params: parseParams(tableSchema.columns, configRecord.params)
1232
- };
1233
- let records = [];
1234
- if (buildWindowMaterialization(surqlString) === null) try {
1235
- const [result] = await this.local.query(surqlString, params);
1236
- records = result || [];
1237
- } catch (err) {
1462
+ async processStreamUpdate(update) {
1463
+ const { queryHash, localArray, materializationTimeMs } = update;
1464
+ const queryState = this.activeQueries.get(queryHash);
1465
+ if (!queryState) {
1238
1466
  this.logger.warn({
1239
- err,
1240
- Category: "sp00ky-client::DataModule::createNewQuery"
1241
- }, "Failed to load initial cached records");
1467
+ queryHash,
1468
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1469
+ }, "Received update for unknown query. Skipping...");
1470
+ return;
1242
1471
  }
1243
- const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
1244
- const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
1245
- return {
1246
- config,
1247
- records,
1248
- ttlTimer: null,
1249
- ttlDurationMs: parseDuration(ttl),
1250
- updateCount: persistedUpdateCount,
1251
- materializationSamples: [],
1252
- lastIngestLatencyMs: null,
1253
- errorCount: persistedErrorCount,
1254
- status: "idle",
1255
- phaseSamples: {},
1256
- phaseLast: {},
1257
- registrationTimings: {
1258
- parseMs: null,
1259
- planMs: null,
1260
- snapshotMs: null,
1261
- wallMs: null
1472
+ if (typeof materializationTimeMs === "number") {
1473
+ queryState.materializationSamples.push(materializationTimeMs);
1474
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
1475
+ queryState.lastIngestLatencyMs = materializationTimeMs;
1476
+ }
1477
+ if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
1478
+ if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
1479
+ if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
1480
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
1481
+ const epoch = this.local.epoch;
1482
+ try {
1483
+ const newRecords = await this.materializeRecords(queryState, localArray);
1484
+ if (epoch !== this.local.epoch) return;
1485
+ queryState.config.localArray = localArray;
1486
+ const prevJson = JSON.stringify(queryState.records);
1487
+ const newJson = JSON.stringify(newRecords);
1488
+ queryState.records = newRecords;
1489
+ const recordsChanged = prevJson !== newJson;
1490
+ if (recordsChanged) {
1491
+ queryState.updateCount++;
1492
+ queryState.lastUpdatedAt = Date.now();
1262
1493
  }
1263
- };
1264
- }
1265
- async calculateHash(data) {
1266
- const content = JSON.stringify({
1267
- ...data,
1268
- sessionId: this.sessionId
1269
- });
1270
- const msgBuffer = new TextEncoder().encode(content);
1271
- const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
1272
- return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1273
- }
1274
- startTTLHeartbeat(queryState, hash) {
1275
- if (queryState.ttlTimer) return;
1276
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
1277
- queryState.ttlTimer = setTimeout(() => {
1278
- queryState.ttlTimer = null;
1279
- if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
1494
+ await this.local.query(surql.seal(surql.updateSet("id", [
1495
+ "localArray",
1496
+ "rowCount",
1497
+ "updateCount",
1498
+ "lastIngestLatency",
1499
+ "materializationP55",
1500
+ "materializationP90",
1501
+ "materializationP99"
1502
+ ])), {
1503
+ id: queryState.config.id,
1504
+ localArray,
1505
+ rowCount: localArray.length,
1506
+ updateCount: queryState.updateCount,
1507
+ lastIngestLatency: queryState.lastIngestLatencyMs,
1508
+ materializationP55: percentiles.p55,
1509
+ materializationP90: percentiles.p90,
1510
+ materializationP99: percentiles.p99
1511
+ }, { epoch });
1512
+ if (!recordsChanged) {
1280
1513
  this.logger.debug({
1281
- hash,
1282
- Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1283
- }, "TTL heartbeat: no subscribers, stopping");
1514
+ queryHash,
1515
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1516
+ }, "Query records unchanged, skipping notification");
1284
1517
  return;
1285
1518
  }
1286
- this.onHeartbeat?.(hash);
1519
+ const subscribers = this.subscriptions.get(queryHash);
1520
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1287
1521
  this.logger.debug({
1288
- hash,
1289
- id: encodeRecordId(queryState.config.id),
1290
- Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1291
- }, "TTL heartbeat sent");
1292
- this.startTTLHeartbeat(queryState, hash);
1293
- }, heartbeatTime);
1294
- }
1295
- async replaceRecordInQueries(record) {
1296
- for (const [queryHash, queryState] of this.activeQueries.entries()) {
1297
- const index = queryState.records.findIndex((r) => r.id === record.id);
1298
- if (index !== -1) {
1299
- queryState.records[index] = {
1300
- ...queryState.records[index],
1301
- ...record
1302
- };
1303
- const subscribers = this.subscriptions.get(queryHash);
1304
- if (subscribers) for (const callback of subscribers) callback(queryState.records);
1522
+ queryHash,
1523
+ recordCount: newRecords?.length,
1524
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1525
+ }, "Query updated from stream");
1526
+ } catch (err) {
1527
+ if (err instanceof StaleEpochError) {
1528
+ this.logger.debug({
1529
+ queryHash,
1530
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1531
+ }, "Dropped stream update from before a bucket switch");
1532
+ return;
1533
+ }
1534
+ queryState.errorCount++;
1535
+ this.logger.error({
1536
+ err,
1537
+ queryHash,
1538
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1539
+ }, "Failed to fetch records for stream update");
1540
+ try {
1541
+ await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
1542
+ id: queryState.config.id,
1543
+ errorCount: queryState.errorCount
1544
+ });
1545
+ } catch (persistErr) {
1546
+ this.logger.warn({
1547
+ err: persistErr,
1548
+ queryHash,
1549
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
1550
+ }, "Failed to persist incremented errorCount");
1305
1551
  }
1306
1552
  }
1307
1553
  }
1308
- };
1309
- /**
1310
- * Parse update options to generate push event options
1311
- */
1312
- function parseUpdateOptions(id, data, options) {
1313
- let pushEventOptions = {};
1314
- if (options?.debounced) pushEventOptions = { debounced: {
1315
- delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
1316
- key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
1317
- } };
1318
- return pushEventOptions;
1319
- }
1320
-
1321
- //#endregion
1322
- //#region src/services/database/database.ts
1323
- var AbstractDatabaseService = class {
1324
- client;
1325
- logger;
1326
- events;
1327
- constructor(client, logger, events) {
1328
- this.client = client;
1329
- this.logger = logger.child({ service: "Database" });
1330
- this.events = events;
1331
- }
1332
- getClient() {
1333
- return this.client;
1334
- }
1335
- getEvents() {
1336
- return this.events;
1337
- }
1338
- tx() {
1339
- return this.client.beginTransaction();
1340
- }
1341
- queryQueue = Promise.resolve();
1342
1554
  /**
1343
- * Execute a query with serialized execution to prevent WASM transaction issues.
1555
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
1556
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
1557
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
1344
1558
  */
1345
- async query(query, vars) {
1346
- return new Promise((resolve, reject) => {
1347
- this.queryQueue = this.queryQueue.then(async () => {
1348
- const startTime = performance.now();
1349
- try {
1350
- this.logger.debug({
1351
- query,
1352
- vars,
1353
- Category: "sp00ky-client::Database::query"
1354
- }, "Executing query");
1355
- const result = await this.client.query(query, vars);
1356
- const duration = performance.now() - startTime;
1357
- this.events.emit(this.eventType, {
1358
- query,
1359
- vars,
1360
- duration,
1361
- success: true,
1362
- timestamp: Date.now()
1363
- });
1364
- resolve(result);
1365
- this.logger.trace({
1366
- query,
1367
- result,
1368
- Category: "sp00ky-client::Database::query"
1369
- }, "Query executed successfully");
1370
- } catch (err) {
1371
- const duration = performance.now() - startTime;
1372
- this.events.emit(this.eventType, {
1373
- query,
1374
- vars,
1375
- duration,
1376
- success: false,
1377
- error: err instanceof Error ? err.message : String(err),
1378
- timestamp: Date.now()
1379
- });
1380
- this.logger.error({
1381
- query,
1382
- vars,
1383
- err,
1384
- Category: "sp00ky-client::Database::query"
1385
- }, "Query execution failed");
1386
- reject(err);
1387
- }
1388
- }).catch(() => {});
1389
- });
1390
- }
1391
- async execute(query, vars) {
1392
- const raw = await this.query(query.sql, vars);
1393
- return query.extract(raw);
1394
- }
1395
- async close() {
1396
- this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
1397
- await this.client.close();
1559
+ computeMaterializationPercentiles(samples) {
1560
+ if (samples.length === 0) return {
1561
+ p55: null,
1562
+ p90: null,
1563
+ p99: null
1564
+ };
1565
+ const sorted = [...samples].sort((a, b) => a - b);
1566
+ const pick = (q) => {
1567
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
1568
+ };
1569
+ return {
1570
+ p55: pick(.55),
1571
+ p90: pick(.9),
1572
+ p99: pick(.99)
1573
+ };
1398
1574
  }
1399
- };
1400
-
1401
- //#endregion
1402
- //#region src/events/index.ts
1403
- /**
1404
- * A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
1405
- * @template E The EventTypeMap defining all supported events.
1406
- */
1407
- var EventSystem = class {
1408
- subscriberId = 0;
1409
- isProcessing = false;
1410
- buffer;
1411
- subscribers;
1412
- subscribersTypeMap;
1413
- lastEvents;
1414
- debouncedEvents;
1415
- constructor(_eventTypes) {
1416
- this._eventTypes = _eventTypes;
1417
- this.buffer = [];
1418
- this.subscribers = this._eventTypes.reduce((acc, key) => {
1419
- return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
1420
- }, {});
1421
- this.lastEvents = {};
1422
- this.subscribersTypeMap = /* @__PURE__ */ new Map();
1423
- this.debouncedEvents = /* @__PURE__ */ new Map();
1575
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
1576
+ recordPhase(qs, phase, ms) {
1577
+ if (!Number.isFinite(ms)) return;
1578
+ pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
1579
+ qs.phaseLast[phase] = ms;
1424
1580
  }
1425
- get eventTypes() {
1426
- return this._eventTypes;
1581
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
1582
+ recordRemoteFetch(hash, ms) {
1583
+ const qs = this.activeQueries.get(hash);
1584
+ if (qs) this.recordPhase(qs, "remoteFetch", ms);
1427
1585
  }
1428
1586
  /**
1429
- * Subscribes a handler to a specific event type.
1430
- * @param type The event type to subscribe to.
1431
- * @param handler The function to call when the event occurs.
1432
- * @param options Subscription options (once, immediately).
1433
- * @returns A subscription ID that can be used to unsubscribe.
1587
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
1588
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
1434
1589
  */
1435
- subscribe(type, handler, options) {
1436
- const id = this.subscriberId++;
1437
- this.subscribers[type].set(id, {
1438
- id,
1439
- handler,
1440
- once: options?.once ?? false
1441
- });
1442
- this.subscribersTypeMap.set(id, type);
1443
- if (options?.immediately) {
1444
- const lastEvent = this.lastEvents[type];
1445
- if (lastEvent) handler(lastEvent);
1446
- }
1447
- return id;
1590
+ recordFrontendTiming(hash, ms) {
1591
+ const qs = this.activeQueries.get(hash);
1592
+ if (qs) this.recordPhase(qs, "frontend", ms);
1448
1593
  }
1449
1594
  /**
1450
- * Subscribes a handler to multiple event types.
1451
- * @param types An array of event types to subscribe to.
1452
- * @param handler The function to call when any of the events occur.
1453
- * @param options Subscription options.
1454
- * @returns An array of subscription IDs.
1595
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
1596
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
1597
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
1455
1598
  */
1456
- subscribeMany(types, handler, options) {
1457
- return types.map((type) => this.subscribe(type, handler, options));
1599
+ phaseTimings(q) {
1600
+ const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
1601
+ return {
1602
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
1603
+ sspStoreApply: stat("sspStoreApply"),
1604
+ sspCircuitStep: stat("sspCircuitStep"),
1605
+ sspTransform: stat("sspTransform"),
1606
+ localFetch: stat("localFetch"),
1607
+ remoteFetch: stat("remoteFetch"),
1608
+ frontend: stat("frontend"),
1609
+ registration: q.registrationTimings,
1610
+ updateCount: q.updateCount,
1611
+ errorCount: q.errorCount
1612
+ };
1458
1613
  }
1459
1614
  /**
1460
- * Unsubscribes a specific subscription by ID.
1461
- * @param id The subscription ID returned by subscribe().
1462
- * @returns True if the subscription was found and removed, false otherwise.
1615
+ * Get query state (for sync and devtools)
1463
1616
  */
1464
- unsubscribe(id) {
1465
- const type = this.subscribersTypeMap.get(id);
1466
- if (type) {
1467
- this.subscribers[type].delete(id);
1468
- this.subscribersTypeMap.delete(id);
1469
- return true;
1470
- }
1471
- return false;
1617
+ getQueryByHash(hash) {
1618
+ return this.activeQueries.get(hash);
1472
1619
  }
1473
1620
  /**
1474
- * Emits an event with the given type and payload.
1475
- * @param type The type of event to emit.
1476
- * @param payload The data associated with the event.
1621
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
1622
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
1623
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
1624
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
1625
+ * but it still hasn't loaded its own full window from the server — so it should
1626
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
1477
1627
  */
1478
- emit(type, payload) {
1479
- const event = {
1480
- type,
1481
- payload
1482
- };
1483
- this.addEvent(event);
1628
+ isCold(hash) {
1629
+ const qs = this.activeQueries.get(hash);
1630
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
1484
1631
  }
1485
1632
  /**
1486
- * Adds a fully constructed event object to the system.
1487
- * Similar to emit, but takes the full event object directly.
1488
- * Supports debouncing if options are provided.
1489
- * @param event The event object.
1490
- * @param options Options for the event push (e.g., debouncing).
1633
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
1634
+ * `batch` (recursing for nested related fields). An embedded child is a
1635
+ * value that is itself a record — a non-null object whose `id` is a
1636
+ * `RecordId` or an array of such records (one-to-many vs one-to-one). A
1637
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
1638
+ * so this never mistakes a FK column for an embedded body. Children are
1639
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
1640
+ * to their table's real columns (which strips the alias/related fields).
1641
+ * `seen` dedupes within the batch.
1491
1642
  */
1492
- addEvent(event, options) {
1493
- if (options?.debounced) {
1494
- this.handleDebouncedEvent(event, options.debounced.key, options.debounced.delay);
1495
- return;
1643
+ collectEmbeddedChildren(record, batch, seen) {
1644
+ const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
1645
+ for (const value of Object.values(record)) {
1646
+ const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
1647
+ for (const child of children) {
1648
+ const key = encodeRecordId(child.id);
1649
+ if (seen.has(key)) continue;
1650
+ seen.add(key);
1651
+ this.collectEmbeddedChildren(child, batch, seen);
1652
+ const table = child.id.table.toString();
1653
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
1654
+ batch.push({
1655
+ table,
1656
+ op: "CREATE",
1657
+ record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
1658
+ version: child._00_rv || 1
1659
+ });
1660
+ }
1496
1661
  }
1497
- this.buffer.push(event);
1498
- this.scheduleProcessing();
1499
1662
  }
1500
- handleDebouncedEvent(event, key, delay) {
1501
- if (this.debouncedEvents.has(key)) clearTimeout(this.debouncedEvents.get(key)?.timer);
1502
- const timer = setTimeout(() => {
1503
- this.debouncedEvents.delete(key);
1504
- this.buffer.push(event);
1505
- this.scheduleProcessing();
1506
- }, delay);
1507
- this.debouncedEvents.set(key, {
1508
- timer,
1509
- resolve: () => {}
1510
- });
1663
+ /**
1664
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
1665
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
1666
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
1667
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
1668
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
1669
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
1670
+ */
1671
+ async applyHydration(hash, rows) {
1672
+ const queryState = this.activeQueries.get(hash);
1673
+ if (!queryState) return;
1674
+ queryState.hydrated = true;
1675
+ if (rows.length === 0) return;
1676
+ const tableName = queryState.config.tableName;
1677
+ const batch = rows.map((record) => ({
1678
+ table: tableName,
1679
+ op: "CREATE",
1680
+ record,
1681
+ version: record._00_rv || 1
1682
+ }));
1683
+ const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
1684
+ for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
1685
+ await this.cache.saveBatch(batch);
1686
+ queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
1687
+ queryState.records = await this.materializeRecords(queryState);
1688
+ const subscribers = this.subscriptions.get(hash);
1689
+ if (subscribers) for (const cb of subscribers) cb(queryState.records);
1511
1690
  }
1512
- scheduleProcessing() {
1513
- if (!this.isProcessing) queueMicrotask(() => this.processEvents());
1514
- }
1515
- async processEvents() {
1516
- if (this.isProcessing) return;
1517
- this.isProcessing = true;
1518
- try {
1519
- while (this.dequeue());
1520
- } finally {
1521
- this.isProcessing = false;
1522
- }
1523
- }
1524
- dequeue() {
1525
- const event = this.buffer.shift();
1526
- if (!event) return false;
1527
- this.setLastEvent(event.type, event);
1528
- this.broadcastEvent(event.type, event);
1529
- return true;
1691
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
1692
+ hasSubscribers(hash) {
1693
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
1530
1694
  }
1531
- setLastEvent(type, event) {
1532
- this.lastEvents[type] = event;
1695
+ /**
1696
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
1697
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
1698
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
1699
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
1700
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
1701
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
1702
+ *
1703
+ * NOTE: most queries should NOT use this — the default keep-alive on
1704
+ * unsubscribe avoids re-registration churn on navigation.
1705
+ */
1706
+ deregisterQuery(hash) {
1707
+ if (this.hasSubscribers(hash)) return;
1708
+ if (!this.activeQueries.has(hash)) return;
1709
+ this.onDeregister?.(hash);
1533
1710
  }
1534
- broadcastEvent(type, event) {
1535
- const subscribers = this.subscribers[type].values();
1536
- for (const subscriber of subscribers) {
1537
- subscriber.handler(event);
1538
- if (subscriber.once) this.unsubscribe(subscriber.id);
1711
+ /**
1712
+ * Final local teardown after the remote `_00_query` row was deleted: free the
1713
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
1714
+ * (`cleanupQuery`) guarantees no subscriber remains.
1715
+ */
1716
+ finalizeDeregister(hash) {
1717
+ const qs = this.activeQueries.get(hash);
1718
+ if (qs?.ttlTimer) {
1719
+ clearTimeout(qs.ttlTimer);
1720
+ qs.ttlTimer = null;
1721
+ }
1722
+ const debounce = this.debounceTimers.get(hash);
1723
+ if (debounce) {
1724
+ clearTimeout(debounce);
1725
+ this.debounceTimers.delete(hash);
1539
1726
  }
1727
+ this.pendingStreamUpdates.delete(hash);
1728
+ this.fetchDepth.delete(hash);
1729
+ this.cache.unregisterQuery(hash);
1730
+ this.activeQueries.delete(hash);
1731
+ this.subscriptions.delete(hash);
1540
1732
  }
1541
- };
1542
- function createEventSystem(eventTypes) {
1543
- return new EventSystem(eventTypes);
1544
- }
1545
-
1546
- //#endregion
1547
- //#region src/services/database/events/index.ts
1548
- const DatabaseEventTypes = {
1549
- LocalQuery: "DATABASE_LOCAL_QUERY",
1550
- RemoteQuery: "DATABASE_REMOTE_QUERY"
1551
- };
1552
- function createDatabaseEventSystem() {
1553
- return createEventSystem([DatabaseEventTypes.LocalQuery, DatabaseEventTypes.RemoteQuery]);
1554
- }
1555
-
1556
- //#endregion
1557
- //#region src/services/database/local.ts
1558
- var LocalDatabaseService = class extends AbstractDatabaseService {
1559
- config;
1560
- eventType = DatabaseEventTypes.LocalQuery;
1561
- constructor(config, logger) {
1562
- const events = createDatabaseEventSystem();
1563
- super(new Surreal({
1564
- codecOptions: { valueDecodeVisitor(value) {
1565
- if (value instanceof RecordId) return encodeRecordId(value);
1566
- if (value instanceof DateTime) return value.toDate();
1567
- return value;
1568
- } },
1569
- engines: applyDiagnostics(createWasmWorkerEngines(), ({ key, type, phase, ...other }) => {
1570
- if (phase === "progress" || phase === "after") logger.trace({
1571
- ...other,
1572
- key,
1573
- type,
1574
- phase,
1575
- service: "surrealdb:local",
1576
- Category: "sp00ky-client::LocalDatabaseService::diagnostics"
1577
- }, `Local SurrealDB diagnostics captured ${type}:${phase}`);
1578
- })
1579
- }), logger, events);
1580
- this.config = config;
1733
+ /**
1734
+ * Get query state by id (for sync and devtools)
1735
+ */
1736
+ getQueryById(id) {
1737
+ return this.activeQueries.get(extractIdPart(id));
1581
1738
  }
1582
- getConfig() {
1583
- return this.config;
1739
+ /**
1740
+ * Get all active queries (for devtools)
1741
+ */
1742
+ getActiveQueries() {
1743
+ return Array.from(this.activeQueries.values());
1584
1744
  }
1585
- async connect() {
1586
- const { namespace, database } = this.getConfig();
1587
- const store = this.getConfig().store ?? "memory";
1588
- const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
1589
- this.logger.info({
1590
- namespace,
1591
- database,
1592
- storeUrl,
1593
- Category: "sp00ky-client::LocalDatabaseService::connect"
1594
- }, "Connecting to local database");
1595
- this.registerUnloadClose();
1596
- try {
1597
- await this.openStore(storeUrl, namespace, database);
1598
- this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
1745
+ getActiveQueryHashes() {
1746
+ return Array.from(this.activeQueries.keys());
1747
+ }
1748
+ async updateQueryLocalArray(id, localArray) {
1749
+ const queryState = this.activeQueries.get(id);
1750
+ if (!queryState) {
1751
+ this.logger.warn({
1752
+ id,
1753
+ Category: "sp00ky-client::DataModule::updateQueryLocalArray"
1754
+ }, "Query to update local array not found");
1599
1755
  return;
1756
+ }
1757
+ const epoch = this.local.epoch;
1758
+ queryState.config.localArray = localArray;
1759
+ try {
1760
+ await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
1761
+ id: queryState.config.id,
1762
+ localArray
1763
+ }, { epoch });
1600
1764
  } catch (err) {
1601
- if (store === "memory" || !isLocalStoreOpenError(err)) {
1602
- this.logger.error({
1603
- err,
1604
- Category: "sp00ky-client::LocalDatabaseService::connect"
1605
- }, "Failed to connect to local database");
1606
- throw err;
1607
- }
1608
- this.logger.warn({
1609
- err,
1610
- Category: "sp00ky-client::LocalDatabaseService::connect"
1611
- }, "Local IndexedDB store failed to open; retrying before clearing");
1765
+ if (err instanceof StaleEpochError) return;
1766
+ throw err;
1612
1767
  }
1613
- for (let attempt = 1; attempt <= 2; attempt++) {
1614
- try {
1615
- await this.client.close();
1616
- } catch {}
1617
- await delay(150 * attempt);
1618
- try {
1619
- await this.openStore(storeUrl, namespace, database);
1620
- this.logger.info({
1621
- attempt,
1622
- Category: "sp00ky-client::LocalDatabaseService::connect"
1623
- }, "Connected to local database on retry (cache preserved)");
1624
- return;
1625
- } catch (retryErr) {
1626
- this.logger.warn({
1627
- err: retryErr,
1628
- attempt,
1629
- Category: "sp00ky-client::LocalDatabaseService::connect"
1630
- }, "Local store retry failed");
1631
- }
1768
+ }
1769
+ async updateQueryRemoteArray(hash, remoteArray) {
1770
+ const queryState = this.getQueryByHash(hash);
1771
+ if (!queryState) {
1772
+ this.logger.warn({
1773
+ hash,
1774
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
1775
+ }, "Query to update remote array not found");
1776
+ return;
1632
1777
  }
1778
+ const epoch = this.local.epoch;
1779
+ queryState.config.remoteArray = remoteArray;
1633
1780
  try {
1634
- await this.client.close();
1635
- } catch {}
1636
- await dropLocalIndexedDbStores(this.logger);
1637
- try {
1638
- await this.openStore(storeUrl, namespace, database);
1639
- this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
1640
- } catch (retryErr) {
1641
- this.logger.error({
1642
- err: retryErr,
1643
- Category: "sp00ky-client::LocalDatabaseService::connect"
1644
- }, "Local store still failing after clear; falling back to in-memory");
1645
- try {
1646
- await this.client.close();
1647
- } catch {}
1648
- await this.openStore("mem://", namespace, database);
1649
- this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
1781
+ await this.local.query(surql.seal(surql.updateSet("id", ["remoteArray"])), {
1782
+ id: queryState.config.id,
1783
+ remoteArray
1784
+ }, { epoch });
1785
+ } catch (err) {
1786
+ if (err instanceof StaleEpochError) return;
1787
+ throw err;
1650
1788
  }
1651
1789
  }
1652
- unloadCloseRegistered = false;
1653
1790
  /**
1654
- * Close the local DB on page unload so the SurrealDB-WASM worker releases its
1655
- * IndexedDB connection cleanly. Without this, the previous page's connection
1656
- * lingers; the next load's `client.connect` opens the store but the first
1657
- * write transaction in `client.use` hits an "IndexedDB error" which then
1658
- * (mis)triggered the corrupt-store recovery and WIPED the cache on every
1659
- * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
1660
- * unload signal (fires on bfcache + normal navigation); `close()` is async but
1661
- * the WASM worker initiates the IndexedDB connection teardown synchronously.
1791
+ * Cancel every armed timer ahead of a local-bucket switch: stream-update
1792
+ * debounce timers (their pending updates carry the OLD bucket's id-sets) and
1793
+ * per-query TTL heartbeats (they'd refresh the previous user's remote
1794
+ * `_00_query` rows under the new session). The rebind re-arms heartbeats.
1662
1795
  */
1663
- registerUnloadClose() {
1664
- if (this.unloadCloseRegistered || typeof window === "undefined") return;
1665
- this.unloadCloseRegistered = true;
1666
- const close = () => {
1796
+ quiesce() {
1797
+ for (const timer of this.debounceTimers.values()) clearTimeout(timer);
1798
+ this.debounceTimers.clear();
1799
+ this.pendingStreamUpdates.clear();
1800
+ this.fetchDepth.clear();
1801
+ for (const queryState of this.activeQueries.values()) if (queryState.ttlTimer) {
1802
+ clearTimeout(queryState.ttlTimer);
1803
+ queryState.ttlTimer = null;
1804
+ }
1805
+ }
1806
+ /**
1807
+ * Re-home every active query in a freshly-opened bucket, KEEPING its hash —
1808
+ * `useQuery` subscriptions are keyed by hash and don't re-register on auth
1809
+ * changes, so the hooks must stay attached. Per query:
1810
+ * 1. reset the sync arrays + hydration flag and drop the previous user's
1811
+ * records, notifying subscribers with the new-bucket materialization
1812
+ * (usually empty) so their rows leave the UI immediately;
1813
+ * 2. recreate the `_00_query` row in the new bucket;
1814
+ * 3. re-register the SSP view on the (fresh, post-reset) processor — this
1815
+ * also rebinds the view to the NEW `$auth` context;
1816
+ * 4. restart the TTL heartbeat.
1817
+ * Returns the hashes so the caller can enqueue remote re-registration, which
1818
+ * refills records from the server via the normal register→sync→notify path.
1819
+ */
1820
+ async rebindAfterBucketSwitch() {
1821
+ const hashes = [];
1822
+ for (const [hash, queryState] of this.activeQueries.entries()) {
1823
+ const config = queryState.config;
1824
+ config.localArray = [];
1825
+ config.remoteArray = [];
1826
+ config.subqueryRemoteArray = void 0;
1827
+ queryState.hydrated = false;
1828
+ queryState.syncNotified = false;
1829
+ queryState.records = [];
1830
+ this.setQueryStatus(hash, "fetching");
1667
1831
  try {
1668
- this.client.close();
1669
- } catch {}
1670
- };
1671
- window.addEventListener("pagehide", close);
1672
- window.addEventListener("beforeunload", close);
1832
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
1833
+ id: config.id,
1834
+ data: {
1835
+ surql: config.surql,
1836
+ params: config.params,
1837
+ localArray: [],
1838
+ remoteArray: [],
1839
+ lastActiveAt: /* @__PURE__ */ new Date(),
1840
+ createdAt: /* @__PURE__ */ new Date(),
1841
+ ttl: config.ttl,
1842
+ tableName: config.tableName,
1843
+ updateCount: queryState.updateCount,
1844
+ rowCount: 0,
1845
+ errorCount: queryState.errorCount
1846
+ }
1847
+ }));
1848
+ const { localArray } = this.cache.registerQuery({
1849
+ queryHash: hash,
1850
+ surql: config.surql,
1851
+ params: config.params,
1852
+ ttl: new Duration(config.ttl),
1853
+ lastActiveAt: /* @__PURE__ */ new Date()
1854
+ });
1855
+ config.localArray = localArray;
1856
+ await this.local.query(surql.seal(surql.updateSet("id", ["localArray", "rowCount"])), {
1857
+ id: config.id,
1858
+ localArray,
1859
+ rowCount: localArray.length
1860
+ });
1861
+ } catch (err) {
1862
+ this.logger.error({
1863
+ err,
1864
+ hash,
1865
+ Category: "sp00ky-client::DataModule::rebindAfterBucketSwitch"
1866
+ }, "Failed to rebind query after bucket switch; remote re-registration will retry");
1867
+ }
1868
+ const subscribers = this.subscriptions.get(hash);
1869
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1870
+ this.startTTLHeartbeat(queryState, hash);
1871
+ hashes.push(hash);
1872
+ }
1873
+ return hashes;
1673
1874
  }
1674
- async openStore(storeUrl, namespace, database) {
1675
- this.logger.debug({
1676
- storeUrl,
1677
- Category: "sp00ky-client::LocalDatabaseService::connect"
1678
- }, "[LocalDatabaseService] Calling client.connect");
1679
- await this.client.connect(storeUrl, {});
1680
- this.logger.debug({
1681
- namespace,
1682
- database,
1683
- Category: "sp00ky-client::LocalDatabaseService::connect"
1684
- }, "[LocalDatabaseService] client.connect returned. Calling client.use");
1685
- await this.client.use({
1686
- namespace,
1687
- database
1688
- });
1689
- this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
1875
+ /**
1876
+ * Called after a query's initial sync completes.
1877
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
1878
+ */
1879
+ async notifyQuerySynced(queryHash) {
1880
+ const queryState = this.activeQueries.get(queryHash);
1881
+ if (!queryState) return;
1882
+ const epoch = this.local.epoch;
1883
+ const newRecords = await this.materializeRecords(queryState);
1884
+ if (epoch !== this.local.epoch) return;
1885
+ const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
1886
+ queryState.records = newRecords;
1887
+ if (changed || !queryState.syncNotified) {
1888
+ queryState.syncNotified = true;
1889
+ queryState.updateCount++;
1890
+ queryState.lastUpdatedAt = Date.now();
1891
+ const subscribers = this.subscriptions.get(queryHash);
1892
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1893
+ }
1690
1894
  }
1691
- };
1692
- function delay(ms) {
1693
- return new Promise((resolve) => setTimeout(resolve, ms));
1694
- }
1695
- /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
1696
- * store can't be opened (corrupt / version-incompatible / blocked). Exported
1697
- * for unit testing the error-message match. */
1698
- function isLocalStoreOpenError(err) {
1699
- const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
1700
- return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
1701
- }
1702
- /** Best-effort delete of this client's IndexedDB store(s). The persistent local
1703
- * DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
1704
- * IndexedDB databases whose names include `sp00ky`. Resolves even on
1705
- * error/blocked so startup can proceed. No-op outside a browser. */
1706
- async function dropLocalIndexedDbStores(logger) {
1707
- if (typeof indexedDB === "undefined") return;
1708
- const remove = (name) => new Promise((resolve) => {
1709
- try {
1710
- const req = indexedDB.deleteDatabase(name);
1711
- req.onsuccess = () => resolve();
1712
- req.onerror = () => resolve();
1713
- req.onblocked = () => resolve();
1714
- } catch {
1715
- resolve();
1895
+ async run(backend, path, data, options) {
1896
+ const route = this.schema.backends?.[backend]?.routes?.[path];
1897
+ if (!route) throw new Error(`Route ${backend}.${path} not found`);
1898
+ const tableName = this.schema.backends?.[backend]?.outboxTable;
1899
+ if (!tableName) throw new Error(`Outbox table for backend ${backend} not found`);
1900
+ const payload = {};
1901
+ for (const argName of Object.keys(route.args)) {
1902
+ const arg = route.args[argName];
1903
+ if (data[argName] === void 0 && arg.optional === false) throw new Error(`Missing required argument ${argName}`);
1904
+ payload[argName] = data[argName];
1716
1905
  }
1717
- });
1718
- try {
1719
- let names = [];
1720
- if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
1721
- if (names.length === 0) names = ["sp00ky"];
1722
- await Promise.all(names.map(remove));
1723
- logger.info({
1724
- names,
1725
- Category: "sp00ky-client::LocalDatabaseService::connect"
1726
- }, "Cleared local IndexedDB store(s)");
1727
- } catch (e) {
1728
- logger.warn({
1729
- err: e,
1730
- Category: "sp00ky-client::LocalDatabaseService::connect"
1731
- }, "Failed to enumerate/clear IndexedDB; proceeding anyway");
1732
- }
1733
- }
1734
-
1735
- //#endregion
1736
- //#region src/services/database/remote.ts
1737
- var RemoteDatabaseService = class extends AbstractDatabaseService {
1738
- config;
1739
- eventType = DatabaseEventTypes.RemoteQuery;
1740
- constructor(config, logger) {
1741
- const events = createDatabaseEventSystem();
1742
- super(new Surreal({ engines: applyDiagnostics(createRemoteEngines(), ({ key, type, phase, ...other }) => {
1743
- if (phase === "progress" || phase === "after") logger.trace({
1744
- ...other,
1745
- key,
1746
- type,
1747
- phase,
1748
- service: "surrealdb:remote",
1749
- Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
1750
- }, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
1751
- }) }), logger, events);
1752
- this.config = config;
1753
- }
1754
- getConfig() {
1755
- return this.config;
1756
- }
1757
- async connect() {
1758
- const { endpoint, token, namespace, database } = this.getConfig();
1759
- if (endpoint) {
1760
- this.logger.info({
1761
- endpoint,
1762
- namespace,
1763
- database,
1764
- Category: "sp00ky-client::RemoteDatabaseService::connect"
1765
- }, "Connecting to remote database");
1766
- try {
1767
- await this.client.connect(endpoint);
1768
- await this.client.use({
1769
- namespace,
1770
- database
1771
- });
1772
- if (token) {
1773
- this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
1774
- await this.client.authenticate(token);
1775
- }
1776
- this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
1777
- } catch (err) {
1778
- this.logger.error({
1779
- err,
1780
- Category: "sp00ky-client::RemoteDatabaseService::connect"
1781
- }, "Failed to connect to remote database");
1782
- throw err;
1783
- }
1784
- } else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
1785
- }
1786
- async signin(params) {
1787
- return this.client.signin(params);
1906
+ const record = {
1907
+ path,
1908
+ payload: JSON.stringify(payload),
1909
+ max_retries: options?.max_retries ?? 3,
1910
+ retry_strategy: options?.retry_strategy ?? "linear"
1911
+ };
1912
+ if (options?.timeout != null) record.timeout = options.timeout;
1913
+ if (options?.delay != null) record.delay = options.delay;
1914
+ if (options?.assignedTo) record.assigned_to = options.assignedTo;
1915
+ const recordId = `${tableName}:${generateId()}`;
1916
+ await this.create(recordId, record);
1788
1917
  }
1789
- async signup(params) {
1790
- return this.client.signup(params);
1918
+ /**
1919
+ * Create a new record
1920
+ */
1921
+ async create(id, data) {
1922
+ const tableName = extractTablePart(id);
1923
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1924
+ if (!tableSchema) throw new Error(`Table ${tableName} not found`);
1925
+ const rid = parseRecordIdString(id);
1926
+ const params = parseParams(tableSchema.columns, data);
1927
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1928
+ const dataKeys = Object.keys(params).map((key) => ({
1929
+ key,
1930
+ variable: `data_${key}`
1931
+ }));
1932
+ const prefixedParams = Object.fromEntries(dataKeys.map(({ key, variable }) => [variable, params[key]]));
1933
+ const query = surql.seal(surql.tx([surql.createSet("id", dataKeys), surql.createMutation("create", "mid", "id", "data")]), { resultIndex: 0 });
1934
+ const target = await withRetry(this.logger, () => this.local.execute(query, {
1935
+ id: rid,
1936
+ mid: mutationId,
1937
+ ...prefixedParams
1938
+ }));
1939
+ const parsedRecord = parseParams(tableSchema.columns, target);
1940
+ await this.cache.save({
1941
+ table: tableName,
1942
+ op: "CREATE",
1943
+ record: parsedRecord,
1944
+ version: 1
1945
+ }, true);
1946
+ const mutationEvent = {
1947
+ type: "create",
1948
+ mutation_id: mutationId,
1949
+ record_id: rid,
1950
+ data: params,
1951
+ record: target,
1952
+ tableName
1953
+ };
1954
+ for (const callback of this.mutationCallbacks) callback([mutationEvent]);
1955
+ this.logger.debug({
1956
+ id,
1957
+ Category: "sp00ky-client::DataModule::create"
1958
+ }, "Record created");
1959
+ return target;
1791
1960
  }
1792
- async authenticate(token) {
1793
- return this.client.authenticate(token);
1961
+ /**
1962
+ * Update an existing record
1963
+ */
1964
+ async update(table, id, data, options) {
1965
+ const tableName = extractTablePart(id);
1966
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1967
+ if (!tableSchema) throw new Error(`Table ${tableName} not found`);
1968
+ const rid = parseRecordIdString(id);
1969
+ const params = parseParams(tableSchema.columns, data);
1970
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1971
+ const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
1972
+ const query = surql.seal(surql.tx([
1973
+ surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
1974
+ surql.let("updated", surql.updateMerge("id", "data")),
1975
+ surql.createMutation("update", "mid", "id", "data"),
1976
+ surql.returnObject([{
1977
+ key: "target",
1978
+ variable: "updated"
1979
+ }])
1980
+ ]));
1981
+ const { target } = await withRetry(this.logger, () => this.local.execute(query, {
1982
+ id: rid,
1983
+ mid: mutationId,
1984
+ data: params
1985
+ }));
1986
+ const updatedFields = { id: target.id };
1987
+ for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
1988
+ if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
1989
+ this.replaceRecordInQueries(updatedFields);
1990
+ const parsedRecord = parseParams(tableSchema.columns, target);
1991
+ await this.cache.save({
1992
+ table,
1993
+ op: "UPDATE",
1994
+ record: parsedRecord,
1995
+ version: target._00_rv
1996
+ }, true);
1997
+ const pushEventOptions = parseUpdateOptions(id, data, options);
1998
+ const mutationEvent = {
1999
+ type: "update",
2000
+ mutation_id: mutationId,
2001
+ record_id: rid,
2002
+ data: params,
2003
+ record: target,
2004
+ beforeRecord: beforeRecord || void 0,
2005
+ options: pushEventOptions
2006
+ };
2007
+ for (const callback of this.mutationCallbacks) callback([mutationEvent]);
2008
+ this.logger.debug({
2009
+ id,
2010
+ Category: "sp00ky-client::DataModule::update"
2011
+ }, "Record updated");
2012
+ return target;
1794
2013
  }
1795
- async invalidate() {
1796
- return this.client.invalidate();
2014
+ /**
2015
+ * Delete a record
2016
+ */
2017
+ async delete(table, id) {
2018
+ const tableName = extractTablePart(id);
2019
+ if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
2020
+ const rid = parseRecordIdString(id);
2021
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
2022
+ const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
2023
+ const beforeRecord = beforeRecords ?? {};
2024
+ const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
2025
+ await withRetry(this.logger, () => this.local.execute(query, {
2026
+ id: rid,
2027
+ mid: mutationId
2028
+ }));
2029
+ try {
2030
+ await this.cache.delete(table, id, true, beforeRecord);
2031
+ } catch (err) {
2032
+ this.logger.error({
2033
+ err,
2034
+ id,
2035
+ Category: "sp00ky-client::DataModule::delete"
2036
+ }, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
2037
+ }
2038
+ for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
2039
+ await this.notifyQuerySynced(queryHash);
2040
+ } catch (err) {
2041
+ this.logger.error({
2042
+ err,
2043
+ queryHash,
2044
+ Category: "sp00ky-client::DataModule::delete"
2045
+ }, "notifyQuerySynced failed after delete");
2046
+ }
2047
+ const mutationEvent = {
2048
+ type: "delete",
2049
+ mutation_id: mutationId,
2050
+ record_id: rid
2051
+ };
2052
+ for (const callback of this.mutationCallbacks) callback([mutationEvent]);
2053
+ this.logger.debug({
2054
+ id,
2055
+ Category: "sp00ky-client::DataModule::delete"
2056
+ }, "Record deleted");
1797
2057
  }
1798
- };
1799
-
1800
- //#endregion
1801
- //#region src/services/logger/index.ts
1802
- function createLogger(level = "info", transmit) {
1803
- const browserConfig = {
1804
- asObject: true,
1805
- write: (o) => {
1806
- console.log(JSON.stringify(o));
2058
+ /**
2059
+ * Rollback a failed optimistic create by deleting the record locally
2060
+ */
2061
+ async rollbackCreate(recordId, tableName) {
2062
+ const id = encodeRecordId(recordId);
2063
+ try {
2064
+ await withRetry(this.logger, () => this.local.query("DELETE $id", { id: recordId }));
2065
+ await this.cache.delete(tableName, id, true);
2066
+ this.removeRecordFromQueries(recordId);
2067
+ this.logger.info({
2068
+ id,
2069
+ tableName,
2070
+ Category: "sp00ky-client::DataModule::rollbackCreate"
2071
+ }, "Rolled back optimistic create");
2072
+ } catch (err) {
2073
+ this.logger.error({
2074
+ err,
2075
+ id,
2076
+ tableName,
2077
+ Category: "sp00ky-client::DataModule::rollbackCreate"
2078
+ }, "Failed to rollback create");
1807
2079
  }
1808
- };
1809
- if (transmit) browserConfig.transmit = transmit;
1810
- return pino({
1811
- level,
1812
- browser: browserConfig
1813
- });
1814
- }
1815
-
1816
- //#endregion
1817
- //#region src/services/database/local-migrator.ts
1818
- const sha1 = async (str) => {
1819
- const enc = new TextEncoder();
1820
- const hash = await crypto.subtle.digest("SHA-1", enc.encode(str));
1821
- return Array.from(new Uint8Array(hash)).map((v) => v.toString(16).padStart(2, "0")).join("");
1822
- };
1823
- var LocalMigrator = class {
1824
- logger;
1825
- constructor(localDb, logger) {
1826
- this.localDb = localDb;
1827
- this.logger = logger.child({ service: "LocalMigrator" });
1828
- logger?.child({ service: "LocalMigrator" }) ?? createLogger("info").child({ service: "LocalMigrator" });
1829
2080
  }
1830
- async provision(schemaSurql) {
1831
- const hash = await sha1(schemaSurql);
1832
- const { database } = this.localDb.getConfig();
1833
- if (await this.isSchemaUpToDate(hash)) {
1834
- this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
1835
- return;
2081
+ /**
2082
+ * Rollback a failed optimistic update by restoring the previous record state
2083
+ */
2084
+ async rollbackUpdate(recordId, tableName, beforeRecord) {
2085
+ const id = encodeRecordId(recordId);
2086
+ try {
2087
+ const { id: _recordId, ...content } = beforeRecord;
2088
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.upsert("id", "content")), {
2089
+ id: recordId,
2090
+ content
2091
+ }));
2092
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
2093
+ const parsedRecord = tableSchema ? parseParams(tableSchema.columns, beforeRecord) : beforeRecord;
2094
+ await this.cache.save({
2095
+ table: tableName,
2096
+ op: "UPDATE",
2097
+ record: parsedRecord,
2098
+ version: beforeRecord._00_rv || 1
2099
+ }, true);
2100
+ await this.replaceRecordInQueries(beforeRecord);
2101
+ this.logger.info({
2102
+ id,
2103
+ tableName,
2104
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
2105
+ }, "Rolled back optimistic update");
2106
+ } catch (err) {
2107
+ this.logger.error({
2108
+ err,
2109
+ id,
2110
+ tableName,
2111
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
2112
+ }, "Failed to rollback update");
1836
2113
  }
1837
- await this.recreateDatabase(database);
1838
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
1839
- const statements = this.splitStatements(fullSchema);
1840
- for (let i = 0; i < statements.length; i++) {
1841
- const statement = statements[i];
1842
- const cleanStatement = statement.replace(/--.*/g, "").trim();
1843
- if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
1844
- this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
1845
- continue;
1846
- }
1847
- try {
1848
- this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
1849
- await this.localDb.query(statement);
1850
- this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
1851
- } catch (e) {
1852
- this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
1853
- throw e;
2114
+ }
2115
+ /**
2116
+ * Remove a record from all active query states and notify subscribers
2117
+ */
2118
+ removeRecordFromQueries(recordId) {
2119
+ const encodedId = encodeRecordId(recordId);
2120
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
2121
+ const index = queryState.records.findIndex((r) => {
2122
+ return (r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id)) === encodedId;
2123
+ });
2124
+ if (index !== -1) {
2125
+ queryState.records.splice(index, 1);
2126
+ const subscribers = this.subscriptions.get(queryHash);
2127
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1854
2128
  }
1855
2129
  }
1856
- await this.createHashRecord(hash);
1857
2130
  }
1858
- async isSchemaUpToDate(hash) {
1859
- try {
1860
- const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
1861
- return lastSchemaRecord?.hash === hash;
1862
- } catch (_error) {
1863
- return false;
2131
+ async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
2132
+ const queryState = await this.createNewQuery({
2133
+ recordId,
2134
+ surql: surqlString,
2135
+ params,
2136
+ ttl,
2137
+ tableName
2138
+ });
2139
+ const t0 = performance.now();
2140
+ const { localArray, registrationTimings } = this.cache.registerQuery({
2141
+ queryHash: hash,
2142
+ surql: surqlString,
2143
+ params,
2144
+ ttl: new Duration(ttl),
2145
+ lastActiveAt: /* @__PURE__ */ new Date()
2146
+ });
2147
+ const registrationTime = performance.now() - t0;
2148
+ queryState.registrationTimings = {
2149
+ parseMs: registrationTimings?.parseMs ?? null,
2150
+ planMs: registrationTimings?.planMs ?? null,
2151
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
2152
+ wallMs: registrationTime
2153
+ };
2154
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
2155
+ "localArray",
2156
+ "registrationTime",
2157
+ "rowCount"
2158
+ ])), {
2159
+ id: recordId,
2160
+ localArray,
2161
+ registrationTime,
2162
+ rowCount: localArray.length
2163
+ }));
2164
+ const windowMat = buildWindowMaterialization(surqlString);
2165
+ if (windowMat && localArray.length > 0) try {
2166
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
2167
+ const [seeded] = await this.local.query(windowMat.query, {
2168
+ ...params,
2169
+ __win: winIds
2170
+ });
2171
+ queryState.records = seeded || [];
2172
+ } catch (err) {
2173
+ this.logger.warn({
2174
+ err,
2175
+ hash,
2176
+ Category: "sp00ky-client::DataModule::createAndRegisterQuery"
2177
+ }, "Failed to seed windowed initial records from localArray");
2178
+ }
2179
+ this.activeQueries.set(hash, queryState);
2180
+ this.startTTLHeartbeat(queryState, hash);
2181
+ this.logger.debug({
2182
+ hash,
2183
+ tableName,
2184
+ recordCount: queryState.records.length,
2185
+ Category: "sp00ky-client::DataModule::query"
2186
+ }, "Query registered");
2187
+ return hash;
2188
+ }
2189
+ async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
2190
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
2191
+ if (!tableSchema) throw new Error(`Table ${tableName} not found`);
2192
+ let [configRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: recordId }));
2193
+ if (!configRecord) {
2194
+ const [createdRecord] = await withRetry(this.logger, () => this.local.query(surql.seal(surql.create("id", "data")), {
2195
+ id: recordId,
2196
+ data: {
2197
+ surql: surqlString,
2198
+ params,
2199
+ localArray: [],
2200
+ remoteArray: [],
2201
+ lastActiveAt: /* @__PURE__ */ new Date(),
2202
+ createdAt: /* @__PURE__ */ new Date(),
2203
+ ttl,
2204
+ tableName,
2205
+ updateCount: 0,
2206
+ rowCount: 0,
2207
+ errorCount: 0
2208
+ }
2209
+ }));
2210
+ configRecord = createdRecord;
2211
+ }
2212
+ const config = {
2213
+ ...configRecord,
2214
+ id: recordId,
2215
+ params: parseParams(tableSchema.columns, configRecord.params)
2216
+ };
2217
+ let records = [];
2218
+ if (buildWindowMaterialization(surqlString) === null) try {
2219
+ const [result] = await this.local.query(surqlString, params);
2220
+ records = result || [];
2221
+ } catch (err) {
2222
+ this.logger.warn({
2223
+ err,
2224
+ Category: "sp00ky-client::DataModule::createNewQuery"
2225
+ }, "Failed to load initial cached records");
1864
2226
  }
2227
+ const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
2228
+ const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
2229
+ return {
2230
+ config,
2231
+ records,
2232
+ ttlTimer: null,
2233
+ ttlDurationMs: parseDuration(ttl),
2234
+ updateCount: persistedUpdateCount,
2235
+ lastUpdatedAt: null,
2236
+ materializationSamples: [],
2237
+ lastIngestLatencyMs: null,
2238
+ errorCount: persistedErrorCount,
2239
+ status: "fetching",
2240
+ phaseSamples: {},
2241
+ phaseLast: {},
2242
+ registrationTimings: {
2243
+ parseMs: null,
2244
+ planMs: null,
2245
+ snapshotMs: null,
2246
+ wallMs: null
2247
+ }
2248
+ };
1865
2249
  }
1866
- async recreateDatabase(database) {
1867
- try {
1868
- await this.localDb.query(`DEFINE DATABASE _00_temp;`);
1869
- } catch (_e) {}
1870
- try {
1871
- await this.localDb.query(`
1872
- USE DB _00_temp;
1873
- REMOVE DATABASE ${database};
1874
- `);
1875
- } catch (_e) {}
1876
- await this.localDb.query(`
1877
- DEFINE DATABASE ${database};
1878
- USE DB ${database};
1879
- `);
2250
+ async calculateHash(data) {
2251
+ const content = JSON.stringify({
2252
+ ...data,
2253
+ sessionId: this.sessionId
2254
+ });
2255
+ const msgBuffer = new TextEncoder().encode(content);
2256
+ const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
2257
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1880
2258
  }
1881
- splitStatements(schema) {
1882
- const statements = [];
1883
- let current = "";
1884
- let depth = 0;
1885
- let inQuote = false;
1886
- let quoteChar = "";
1887
- let inComment = false;
1888
- for (let i = 0; i < schema.length; i++) {
1889
- const char = schema[i];
1890
- const nextChar = schema[i + 1];
1891
- if (inComment) {
1892
- current += char;
1893
- if (char === "\n") inComment = false;
1894
- continue;
1895
- }
1896
- if (!inQuote && char === "-" && nextChar === "-") {
1897
- inComment = true;
1898
- current += char;
1899
- continue;
1900
- }
1901
- if (inQuote) {
1902
- current += char;
1903
- if (char === quoteChar && schema[i - 1] !== "\\") inQuote = false;
1904
- continue;
1905
- }
1906
- if (char === "\"" || char === "'") {
1907
- inQuote = true;
1908
- quoteChar = char;
1909
- current += char;
1910
- continue;
1911
- }
1912
- if (char === "{") {
1913
- depth++;
1914
- current += char;
1915
- continue;
1916
- }
1917
- if (char === "}") {
1918
- depth--;
1919
- current += char;
1920
- continue;
2259
+ startTTLHeartbeat(queryState, hash) {
2260
+ if (queryState.ttlTimer) return;
2261
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
2262
+ queryState.ttlTimer = setTimeout(() => {
2263
+ queryState.ttlTimer = null;
2264
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
2265
+ this.logger.debug({
2266
+ hash,
2267
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
2268
+ }, "TTL heartbeat: no subscribers, stopping");
2269
+ return;
1921
2270
  }
1922
- if (char === ";" && depth === 0) {
1923
- if (current.trim().length > 0) statements.push(current.trim());
1924
- current = "";
1925
- continue;
2271
+ this.onHeartbeat?.(hash);
2272
+ this.logger.debug({
2273
+ hash,
2274
+ id: encodeRecordId(queryState.config.id),
2275
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
2276
+ }, "TTL heartbeat sent");
2277
+ this.startTTLHeartbeat(queryState, hash);
2278
+ }, heartbeatTime);
2279
+ }
2280
+ async replaceRecordInQueries(record) {
2281
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
2282
+ const index = queryState.records.findIndex((r) => r.id === record.id);
2283
+ if (index !== -1) {
2284
+ queryState.records[index] = {
2285
+ ...queryState.records[index],
2286
+ ...record
2287
+ };
2288
+ const subscribers = this.subscriptions.get(queryHash);
2289
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1926
2290
  }
1927
- current += char;
1928
2291
  }
1929
- if (current.trim().length > 0) statements.push(current.trim());
1930
- return statements;
1931
- }
1932
- async createHashRecord(hash) {
1933
- await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
1934
2292
  }
1935
2293
  };
2294
+ /**
2295
+ * Parse update options to generate push event options
2296
+ */
2297
+ function parseUpdateOptions(id, data, options) {
2298
+ let pushEventOptions = {};
2299
+ if (options?.debounced) pushEventOptions = { debounced: {
2300
+ delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
2301
+ key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
2302
+ } };
2303
+ return pushEventOptions;
2304
+ }
1936
2305
 
1937
2306
  //#endregion
1938
2307
  //#region src/modules/sync/events/index.ts
@@ -2014,6 +2383,17 @@ var UpQueue = class {
2014
2383
  firstBeforeRecord
2015
2384
  });
2016
2385
  }
2386
+ /**
2387
+ * Cancel all pending debounce timers WITHOUT enqueueing their events. Used
2388
+ * on local-bucket switches: the mutation's `_00_pending_mutations` row was
2389
+ * already persisted at mutation time, so dropping the in-memory push only
2390
+ * defers it to that bucket's next `loadFromDatabase` — it must NOT be pushed
2391
+ * now, the remote session already belongs to the next user.
2392
+ */
2393
+ clearDebounceTimers() {
2394
+ for (const { timer } of this.debouncedMutations.values()) clearTimeout(timer);
2395
+ this.debouncedMutations.clear();
2396
+ }
2017
2397
  async next(fn, onRollback) {
2018
2398
  const event = this.queue.shift();
2019
2399
  if (event) {
@@ -2145,6 +2525,11 @@ var DownQueue = class {
2145
2525
  payload: { queueSize: this.queue.length }
2146
2526
  });
2147
2527
  }
2528
+ /** Drop all queued events (bucket switch: old-bucket register/sync work is
2529
+ * re-derived after the switch; replaying it would target dead query rows). */
2530
+ clear() {
2531
+ this.queue = [];
2532
+ }
2148
2533
  async next(fn) {
2149
2534
  const event = this.queue.shift();
2150
2535
  if (event) try {
@@ -2494,6 +2879,8 @@ var SyncEngine = class {
2494
2879
  var SyncScheduler = class {
2495
2880
  isSyncingUp = false;
2496
2881
  isSyncingDown = false;
2882
+ paused = false;
2883
+ pauseWaiters = [];
2497
2884
  constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
2498
2885
  this.upQueue = upQueue;
2499
2886
  this.downQueue = downQueue;
@@ -2521,14 +2908,36 @@ var SyncScheduler = class {
2521
2908
  this.downQueue.push(event);
2522
2909
  }
2523
2910
  /**
2911
+ * Suspend syncing for a local-bucket switch. Refuses new rounds and resolves
2912
+ * once any in-flight round has finished — the pause point is BETWEEN queue
2913
+ * items, never between an item's remote push and its outbox-row delete, so a
2914
+ * processed mutation's `DELETE _00_pending_mutations` always lands in the
2915
+ * store it was read from.
2916
+ */
2917
+ pause() {
2918
+ this.paused = true;
2919
+ if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
2920
+ return new Promise((resolve) => this.pauseWaiters.push(resolve));
2921
+ }
2922
+ resume() {
2923
+ this.paused = false;
2924
+ this.syncUp();
2925
+ }
2926
+ maybeResolvePause() {
2927
+ if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
2928
+ const waiters = this.pauseWaiters;
2929
+ this.pauseWaiters = [];
2930
+ for (const resolve of waiters) resolve();
2931
+ }
2932
+ /**
2524
2933
  * Process upload queue
2525
2934
  */
2526
2935
  async syncUp() {
2527
- if (this.isSyncingUp) return;
2936
+ if (this.isSyncingUp || this.paused) return;
2528
2937
  this.isSyncingUp = true;
2529
2938
  let processedAny = false;
2530
2939
  try {
2531
- while (this.upQueue.size > 0) {
2940
+ while (this.upQueue.size > 0 && !this.paused) {
2532
2941
  await this.upQueue.next(this.onProcessUp, this.onRollback);
2533
2942
  processedAny = true;
2534
2943
  }
@@ -2541,6 +2950,7 @@ var SyncScheduler = class {
2541
2950
  }, "syncUp halted on a queue error; item re-queued, will retry on next trigger");
2542
2951
  } finally {
2543
2952
  this.isSyncingUp = false;
2953
+ this.maybeResolvePause();
2544
2954
  this.syncDown();
2545
2955
  }
2546
2956
  }
@@ -2548,12 +2958,12 @@ var SyncScheduler = class {
2548
2958
  * Process download queue
2549
2959
  */
2550
2960
  async syncDown() {
2551
- if (this.isSyncingDown) return;
2961
+ if (this.isSyncingDown || this.paused) return;
2552
2962
  if (this.upQueue.size > 0) return;
2553
2963
  this.isSyncingDown = true;
2554
2964
  let processedAny = false;
2555
2965
  try {
2556
- while (this.downQueue.size > 0) {
2966
+ while (this.downQueue.size > 0 && !this.paused) {
2557
2967
  if (this.upQueue.size > 0) break;
2558
2968
  await this.downQueue.next(this.onProcessDown);
2559
2969
  processedAny = true;
@@ -2567,6 +2977,7 @@ var SyncScheduler = class {
2567
2977
  }, "syncDown halted on a queue error; item re-queued, will retry on next trigger");
2568
2978
  } finally {
2569
2979
  this.isSyncingDown = false;
2980
+ this.maybeResolvePause();
2570
2981
  }
2571
2982
  }
2572
2983
  get isSyncing() {
@@ -2574,53 +2985,6 @@ var SyncScheduler = class {
2574
2985
  }
2575
2986
  };
2576
2987
 
2577
- //#endregion
2578
- //#region src/modules/ref-tables.ts
2579
- /**
2580
- * Sentinel user id for unauthenticated clients when anonymous live queries are
2581
- * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
2582
- * so it can never collide with a real user id (those arrive as `user:<id>`);
2583
- * both sides resolve it to the shared `_00_list_ref_anon` table.
2584
- */
2585
- const ANON_USER_ID = "anon";
2586
- /**
2587
- * Default ref-storage mode for this client build. Mirrors the SSP's
2588
- * default (`RefMode::Dedicated`) so cross-session sync works out of the
2589
- * box.
2590
- */
2591
- const DEFAULT_REF_MODE = "dedicated";
2592
- /**
2593
- * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
2594
- * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
2595
- * the id is missing the `user:` prefix or contains characters that
2596
- * aren't valid in a SurrealDB table identifier — the server-side
2597
- * `ssp_protocol::sanitize_user_id` uses the same predicate.
2598
- *
2599
- * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
2600
- * objects (which only stringify cleanly via `.toString()`), since
2601
- * `AuthService` passes the record-id object as-is to its subscribers.
2602
- */
2603
- function sanitizeUserId(userId) {
2604
- if (userId === null || userId === void 0) return null;
2605
- const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
2606
- if (!asString) return null;
2607
- const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
2608
- if (raw.length === 0) return null;
2609
- if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
2610
- return raw;
2611
- }
2612
- /**
2613
- * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
2614
- * back to the global `_00_list_ref` when sanitization fails or in
2615
- * single mode.
2616
- */
2617
- function listRefTableFor(mode, userId) {
2618
- if (userId === ANON_USER_ID) return "_00_list_ref_anon";
2619
- if (mode === "single") return "_00_list_ref";
2620
- const uid = sanitizeUserId(userId);
2621
- return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
2622
- }
2623
-
2624
2988
  //#endregion
2625
2989
  //#region src/modules/sync/sync.ts
2626
2990
  /**
@@ -2651,6 +3015,7 @@ var Sp00kySync = class Sp00kySync {
2651
3015
  liveQueryUnsubscribe = null;
2652
3016
  listRefPollTimer = null;
2653
3017
  listRefPollRunning = false;
3018
+ listRefPollInFlight = null;
2654
3019
  refSyncIntervalMs;
2655
3020
  listRefIdleStreak = 0;
2656
3021
  stillRemoteStreaks = /* @__PURE__ */ new Map();
@@ -2831,6 +3196,39 @@ var Sp00kySync = class Sp00kySync {
2831
3196
  }
2832
3197
  }
2833
3198
  /**
3199
+ * Quiesce all sync activity ahead of a local-bucket switch. After this
3200
+ * resolves, nothing in the sync module writes to the local store: the poll
3201
+ * loop is stopped AND its in-flight tick awaited, LIVE is killed, debounce
3202
+ * timers are cancelled (their outbox rows are already persisted), and the
3203
+ * scheduler has drained its in-flight queue item — including that item's
3204
+ * outbox-row delete, which must land in the OLD bucket. Queued down-events
3205
+ * are dropped (they reference old-bucket query rows; the post-switch rebind
3206
+ * re-enqueues registrations). The old user's un-pushed outbox is deliberately
3207
+ * NOT drained: the remote session already belongs to the next user.
3208
+ */
3209
+ async prepareBucketSwitch() {
3210
+ this.stopSelfHeal();
3211
+ this.stopListRefPoll();
3212
+ if (this.listRefPollInFlight) await this.listRefPollInFlight;
3213
+ await this.killRefLiveQuery();
3214
+ this.upQueue.clearDebounceTimers();
3215
+ await this.scheduler.pause();
3216
+ this.downQueue.clear();
3217
+ this.stillRemoteStreaks.clear();
3218
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::prepareBucketSwitch" }, "Sync quiesced for bucket switch");
3219
+ }
3220
+ /**
3221
+ * Resume syncing against the freshly-opened bucket: reload the mutation
3222
+ * outbox from ITS `_00_pending_mutations` (the new user's own un-pushed
3223
+ * offline work) and restart the scheduler. LIVE + the list_ref poll restart
3224
+ * via the `setCurrentUserId` call that follows in the auth listener.
3225
+ */
3226
+ async completeBucketSwitch() {
3227
+ await this.upQueue.loadFromDatabase();
3228
+ this.scheduler.resume();
3229
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::completeBucketSwitch" }, "Sync resumed after bucket switch");
3230
+ }
3231
+ /**
2834
3232
  * Push the authenticated user's record id from the parent client's
2835
3233
  * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
2836
3234
  * any) and re-registers it under the new user's dedicated table so
@@ -2897,9 +3295,14 @@ var Sp00kySync = class Sp00kySync {
2897
3295
  this.listRefPollTimer = setTimeout(async () => {
2898
3296
  if (!this.listRefPollRunning) return;
2899
3297
  let changed = false;
2900
- try {
3298
+ const tick = (async () => {
2901
3299
  changed = await this.pollListRefForActiveQueries();
3300
+ })();
3301
+ this.listRefPollInFlight = tick.catch(() => {});
3302
+ try {
3303
+ await tick;
2902
3304
  } finally {
3305
+ this.listRefPollInFlight = null;
2903
3306
  if (!this.listRefPollRunning) return;
2904
3307
  this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
2905
3308
  schedule(listRefPollDelayMs({
@@ -3285,7 +3688,7 @@ var Sp00kySync = class Sp00kySync {
3285
3688
  };
3286
3689
  }
3287
3690
  const fetching = diff.added.length + diff.updated.length > 0;
3288
- if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
3691
+ if (fetching) this.dataModule.beginFetching(hash);
3289
3692
  try {
3290
3693
  const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
3291
3694
  if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
@@ -3310,7 +3713,18 @@ var Sp00kySync = class Sp00kySync {
3310
3713
  }
3311
3714
  }
3312
3715
  } finally {
3313
- if (fetching) this.dataModule.setQueryStatus(hash, "idle");
3716
+ if (fetching) {
3717
+ try {
3718
+ await this.dataModule.flushPendingStreamUpdate(hash);
3719
+ } catch (err) {
3720
+ this.logger.warn({
3721
+ err,
3722
+ hash,
3723
+ Category: "sp00ky-client::Sp00kySync::runSyncForQuery"
3724
+ }, "Failed to flush pending stream update before idle");
3725
+ }
3726
+ this.dataModule.endFetching(hash);
3727
+ }
3314
3728
  }
3315
3729
  }
3316
3730
  /**
@@ -3339,6 +3753,7 @@ var Sp00kySync = class Sp00kySync {
3339
3753
  this.scheduler.enqueueMutation(mutations);
3340
3754
  }
3341
3755
  async registerQuery(queryHash) {
3756
+ this.dataModule.beginFetching(queryHash);
3342
3757
  try {
3343
3758
  this.logger.debug({
3344
3759
  queryHash,
@@ -3346,6 +3761,7 @@ var Sp00kySync = class Sp00kySync {
3346
3761
  }, "Register Query state");
3347
3762
  await this.createRemoteQuery(queryHash);
3348
3763
  await this.syncQuery(queryHash);
3764
+ await this.dataModule.flushPendingStreamUpdate(queryHash);
3349
3765
  await this.dataModule.notifyQuerySynced(queryHash);
3350
3766
  } catch (e) {
3351
3767
  this.logger.error({
@@ -3353,6 +3769,8 @@ var Sp00kySync = class Sp00kySync {
3353
3769
  Category: "sp00ky-client::Sp00kySync::registerQuery"
3354
3770
  }, "registerQuery error");
3355
3771
  throw e;
3772
+ } finally {
3773
+ this.dataModule.endFetching(queryHash);
3356
3774
  }
3357
3775
  }
3358
3776
  async createRemoteQuery(queryHash) {
@@ -3515,8 +3933,8 @@ function parseBackendInfo(raw) {
3515
3933
 
3516
3934
  //#endregion
3517
3935
  //#region src/modules/devtools/index.ts
3518
- const CORE_VERSION = "0.0.1-canary.103";
3519
- const WASM_VERSION = "0.0.1-canary.103";
3936
+ const CORE_VERSION = "0.0.1-canary.104";
3937
+ const WASM_VERSION = "0.0.1-canary.104";
3520
3938
  const SURREAL_VERSION = "3.0.3";
3521
3939
  var DevToolsService = class {
3522
3940
  eventsHistory = [];
@@ -3572,13 +3990,14 @@ var DevToolsService = class {
3572
3990
  if (!this.dataManager) return result;
3573
3991
  this.dataManager.getActiveQueries().forEach((q) => {
3574
3992
  const queryHash = this.hashString(encodeRecordId(q.config.id));
3993
+ const createdAt = q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime();
3575
3994
  result.set(queryHash, {
3576
3995
  queryHash,
3577
3996
  status: "active",
3578
3997
  fetchStatus: q.status,
3579
3998
  isFetching: q.status === "fetching",
3580
- createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
3581
- lastUpdate: Date.now(),
3999
+ createdAt,
4000
+ lastUpdate: q.lastUpdatedAt ?? createdAt,
3582
4001
  updateCount: q.updateCount,
3583
4002
  ttl: q.config.ttl,
3584
4003
  query: q.config.surql,
@@ -4047,6 +4466,8 @@ var StreamProcessorService = class {
4047
4466
  authId: "",
4048
4467
  access: ""
4049
4468
  };
4469
+ stateKeySuffix = "";
4470
+ stateGeneration = 0;
4050
4471
  constructor(events, db, persistenceClient, logger) {
4051
4472
  this.events = events;
4052
4473
  this.db = db;
@@ -4150,10 +4571,34 @@ var StreamProcessorService = class {
4150
4571
  throw e;
4151
4572
  }
4152
4573
  }
4574
+ /** Route the persisted circuit snapshot to a per-bucket key. */
4575
+ setStateKeySuffix(bucketId) {
4576
+ this.stateKeySuffix = bucketId;
4577
+ }
4578
+ stateKey() {
4579
+ return this.stateKeySuffix ? `_00_stream_processor_state:${this.stateKeySuffix}` : "_00_stream_processor_state";
4580
+ }
4581
+ /**
4582
+ * Drop the current WASM processor and start a fresh, empty circuit. Used on
4583
+ * local-bucket switches: the old circuit holds the previous user's rows AND
4584
+ * views registered with the previous `$auth` context, so neither may survive.
4585
+ * Deliberately does NOT `loadState()` — a persisted snapshot references views
4586
+ * under a dead sessionId salt; the DataModule rebind re-registers every live
4587
+ * view against this fresh processor. Caller must re-seed `setPermissions`
4588
+ * afterwards (a fresh circuit default-denies every table).
4589
+ */
4590
+ async reset() {
4591
+ if (!this.isInitialized) return;
4592
+ this.stateGeneration++;
4593
+ this.batching = false;
4594
+ this.batchBuffer.clear();
4595
+ this.processor = new Sp00kyProcessor();
4596
+ this.logger.info({ Category: "sp00ky-client::StreamProcessorService::reset" }, "Stream processor reset (fresh circuit)");
4597
+ }
4153
4598
  async loadState() {
4154
4599
  if (!this.processor) return;
4155
4600
  try {
4156
- const result = await this.persistenceClient.get("_00_stream_processor_state");
4601
+ const result = await this.persistenceClient.get(this.stateKey());
4157
4602
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
4158
4603
  const state = result[0][0].state;
4159
4604
  this.logger.info({
@@ -4210,11 +4655,13 @@ var StreamProcessorService = class {
4210
4655
  }
4211
4656
  async saveState() {
4212
4657
  if (!this.processor) return;
4658
+ const generation = this.stateGeneration;
4213
4659
  try {
4214
4660
  if (typeof this.processor.save_state === "function") {
4215
4661
  const state = this.processor.save_state();
4662
+ if (generation !== this.stateGeneration) return;
4216
4663
  if (state) {
4217
- await this.persistenceClient.set("_00_stream_processor_state", state);
4664
+ await this.persistenceClient.set(this.stateKey(), state);
4218
4665
  this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
4219
4666
  }
4220
4667
  }
@@ -4457,6 +4904,11 @@ var CacheModule = class {
4457
4904
  lookup(recordId) {
4458
4905
  return this.versionLookups[recordId] ?? 0;
4459
4906
  }
4907
+ /** Drop the version cache on a bucket switch — a stale version would make
4908
+ * the sync diff skip fetching a body the new bucket legitimately needs. */
4909
+ clearVersionLookups() {
4910
+ this.versionLookups = {};
4911
+ }
4460
4912
  /**
4461
4913
  * Save a single record to local DB and ingest into DBSP
4462
4914
  * Used by mutations (create/update)
@@ -4471,6 +4923,7 @@ var CacheModule = class {
4471
4923
  */
4472
4924
  async saveBatch(records, skipDbInsert = false) {
4473
4925
  if (records.length === 0) return;
4926
+ const epoch = this.local.epoch;
4474
4927
  this.logger.debug({
4475
4928
  count: records.length,
4476
4929
  Category: "sp00ky-client::CacheModule::saveBatch"
@@ -4498,8 +4951,9 @@ var CacheModule = class {
4498
4951
  [`content${i}`]: content
4499
4952
  };
4500
4953
  }, {});
4501
- await this.local.execute(query, params);
4954
+ await this.local.execute(query, params, { epoch });
4502
4955
  }
4956
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
4503
4957
  const bulk = populatedRecords.map((record) => {
4504
4958
  const recordId = encodeRecordId(record.record.id);
4505
4959
  this.versionLookups[recordId] = record.version;
@@ -4516,6 +4970,13 @@ var CacheModule = class {
4516
4970
  Category: "sp00ky-client::CacheModule::saveBatch"
4517
4971
  }, "Batch saved successfully");
4518
4972
  } catch (err) {
4973
+ if (err instanceof StaleEpochError) {
4974
+ this.logger.debug({
4975
+ count: records.length,
4976
+ Category: "sp00ky-client::CacheModule::saveBatch"
4977
+ }, "Dropped batch from before a bucket switch");
4978
+ return;
4979
+ }
4519
4980
  this.logger.error({
4520
4981
  err,
4521
4982
  count: records.length,
@@ -4533,8 +4994,10 @@ var CacheModule = class {
4533
4994
  id,
4534
4995
  Category: "sp00ky-client::CacheModule::delete"
4535
4996
  }, "Deleting record");
4997
+ const epoch = this.local.epoch;
4536
4998
  try {
4537
- if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
4999
+ if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) }, { epoch });
5000
+ if (this.local.epoch !== epoch) throw new StaleEpochError();
4538
5001
  delete this.versionLookups[id];
4539
5002
  this.streamProcessor.ingest(table, "DELETE", id, recordData);
4540
5003
  this.logger.debug({
@@ -4543,6 +5006,14 @@ var CacheModule = class {
4543
5006
  Category: "sp00ky-client::CacheModule::delete"
4544
5007
  }, "Record deleted successfully");
4545
5008
  } catch (err) {
5009
+ if (err instanceof StaleEpochError) {
5010
+ this.logger.debug({
5011
+ table,
5012
+ id,
5013
+ Category: "sp00ky-client::CacheModule::delete"
5014
+ }, "Dropped delete from before a bucket switch");
5015
+ return;
5016
+ }
4546
5017
  this.logger.error({
4547
5018
  err,
4548
5019
  table,
@@ -4707,7 +5178,14 @@ var CrdtField = class {
4707
5178
  this.scheduleRemotePush();
4708
5179
  });
4709
5180
  }
4710
- stopSync() {
5181
+ /**
5182
+ * Stop syncing this field. Flushes one final remote push by default so the
5183
+ * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
5184
+ * the remote session already belongs to the NEXT user, and pushing this
5185
+ * (previous user's) snapshot under it would clobber the record remotely.
5186
+ */
5187
+ stopSync(options = {}) {
5188
+ const flush = options.flush ?? true;
4711
5189
  if (this.unsubscribe) {
4712
5190
  this.unsubscribe();
4713
5191
  this.unsubscribe = null;
@@ -4716,7 +5194,7 @@ var CrdtField = class {
4716
5194
  clearTimeout(this.pushTimer);
4717
5195
  this.pushTimer = null;
4718
5196
  }
4719
- if (this.remote && this.recordId) this.pushToRemote();
5197
+ if (flush && this.remote && this.recordId) this.pushToRemote();
4720
5198
  }
4721
5199
  importRemote(state) {
4722
5200
  if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
@@ -4937,8 +5415,13 @@ var CrdtManager = class {
4937
5415
  Category: "sp00ky-client::CrdtManager::close"
4938
5416
  }, "CrdtField closed");
4939
5417
  }
4940
- closeAll() {
4941
- for (const [_, field] of this.fields) field.stopSync();
5418
+ /**
5419
+ * Close every open field + table LIVE. Fields flush a final remote push by
5420
+ * default; pass `{ flush: false }` on a bucket switch, where that flush
5421
+ * would push the previous user's snapshot under the next user's session.
5422
+ */
5423
+ closeAll(options = {}) {
5424
+ for (const [_, field] of this.fields) field.stopSync(options);
4942
5425
  this.fields.clear();
4943
5426
  for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
4944
5427
  }
@@ -5375,6 +5858,28 @@ var BucketHandle = class {
5375
5858
  return result;
5376
5859
  }
5377
5860
  };
5861
+ /**
5862
+ * Boot hint for which local bucket to open before auth resolves. Written to
5863
+ * PLAIN localStorage (never the configured persistenceClient): the surrealdb
5864
+ * persistence client stores its keys INSIDE a bucket, and the whole point of
5865
+ * the hint is to pick the bucket before any bucket is open. A warm reload of a
5866
+ * signed-in user thus opens their own bucket immediately — zero switches.
5867
+ * Losing the hint is fail-closed: boot lands on the anon bucket and the auth
5868
+ * callback switches to the user's bucket (cache + outbox intact).
5869
+ */
5870
+ const LAST_BUCKET_KEY = "sp00ky:last_bucket";
5871
+ function readBootBucketHint() {
5872
+ try {
5873
+ return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
5874
+ } catch {
5875
+ return null;
5876
+ }
5877
+ }
5878
+ function writeBootBucketHint(bucketId) {
5879
+ try {
5880
+ if (typeof localStorage !== "undefined") localStorage.setItem(LAST_BUCKET_KEY, bucketId);
5881
+ } catch {}
5882
+ }
5378
5883
  var Sp00kyClient = class {
5379
5884
  local;
5380
5885
  remote;
@@ -5518,12 +6023,17 @@ var Sp00kyClient = class {
5518
6023
  async init() {
5519
6024
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
5520
6025
  try {
5521
- await this.local.connect();
5522
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
6026
+ const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
6027
+ await this.local.connect(bootBucket);
6028
+ this.logger.debug({
6029
+ bootBucket,
6030
+ Category: "sp00ky-client::Sp00kyClient::init"
6031
+ }, "Local database connected");
5523
6032
  await this.migrator.provision(this.config.schemaSurql);
5524
6033
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
5525
6034
  await this.remote.connect();
5526
6035
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
6036
+ this.streamProcessor.setStateKeySuffix(bootBucket);
5527
6037
  await this.streamProcessor.init();
5528
6038
  this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
5529
6039
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
@@ -5539,6 +6049,8 @@ var Sp00kyClient = class {
5539
6049
  this.auth.subscribe(async (userId) => {
5540
6050
  this.dataModule.setCurrentUserId(userId);
5541
6051
  this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
6052
+ writeBootBucketHint(bucketIdForUser(userId));
6053
+ await this.ensureLocalBucket(userId);
5542
6054
  const next = await this.fetchSessionId();
5543
6055
  this.dataModule.setSessionId(next);
5544
6056
  this.crdtManager.setSessionId(next);
@@ -5564,6 +6076,86 @@ var Sp00kyClient = class {
5564
6076
  throw e;
5565
6077
  }
5566
6078
  }
6079
+ bucketSwitchChain = Promise.resolve();
6080
+ pendingBucketTarget = null;
6081
+ /**
6082
+ * Ensure the local store is this user's bucket, switching if needed. Called
6083
+ * from the auth listener on every auth flip; concurrent calls are chained
6084
+ * and superseded intermediates are skipped (latest target wins).
6085
+ */
6086
+ ensureLocalBucket(userId) {
6087
+ const target = bucketIdForUser(userId);
6088
+ this.pendingBucketTarget = target;
6089
+ this.bucketSwitchChain = this.bucketSwitchChain.then(async () => {
6090
+ if (this.pendingBucketTarget !== target) return;
6091
+ if (this.local.currentBucketId === target) return;
6092
+ await this.doSwitchBucket(target);
6093
+ });
6094
+ const result = this.bucketSwitchChain;
6095
+ this.bucketSwitchChain = this.bucketSwitchChain.catch(() => {});
6096
+ return result;
6097
+ }
6098
+ /**
6099
+ * The bucket-switch choreography: drain → swap → rebind.
6100
+ *
6101
+ * Drain: sync quiesced (poll/LIVE stopped, in-flight round awaited so its
6102
+ * outbox delete lands in the OLD bucket, debounce timers cancelled),
6103
+ * DataModule timers cleared, CRDT fields closed WITHOUT their final flush
6104
+ * (the remote session already belongs to the next user).
6105
+ *
6106
+ * Swap: gate closes so any local query issued mid-switch (sibling auth
6107
+ * subscribers, FeatureFlagModule) waits and then runs against the NEW
6108
+ * bucket; store swaps open-new-before-close-old; schema provisions
6109
+ * (no-op for a returning bucket); stale `_00_query` rows are wiped (dead
6110
+ * sessionId-salted hashes with stale arrays — record bodies stay warm);
6111
+ * SSP resets to a fresh circuit with re-seeded permissions.
6112
+ *
6113
+ * Rebind: auth token re-persisted (the surrealdb persistence client wrote it
6114
+ * into the OLD bucket's `_00_kv` before this listener ran), active queries
6115
+ * re-homed keeping their hashes, sync resumed on the new bucket's own
6116
+ * outbox, and every query re-registered remotely to refill from the server.
6117
+ */
6118
+ async doSwitchBucket(target) {
6119
+ this.logger.info({
6120
+ target,
6121
+ from: this.local.currentBucketId,
6122
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
6123
+ }, "Switching local bucket");
6124
+ await this.sync.prepareBucketSwitch();
6125
+ this.dataModule.quiesce();
6126
+ this.crdtManager.closeAll({ flush: false });
6127
+ const reopen = this.local.beginSwitch();
6128
+ try {
6129
+ await this.local.switchStore(target);
6130
+ await this.migrator.provision(this.config.schemaSurql);
6131
+ await this.local.queryUngated("DELETE _00_query;");
6132
+ this.streamProcessor.setStateKeySuffix(target);
6133
+ await this.streamProcessor.reset();
6134
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
6135
+ this.cache.clearVersionLookups();
6136
+ } finally {
6137
+ reopen();
6138
+ }
6139
+ if (this.auth.token) try {
6140
+ await this.persistenceClient.set("sp00ky_auth_token", this.auth.token);
6141
+ } catch (e) {
6142
+ this.logger.warn({
6143
+ error: e,
6144
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
6145
+ }, "Failed to re-persist auth token into the new bucket");
6146
+ }
6147
+ const hashes = await this.dataModule.rebindAfterBucketSwitch();
6148
+ await this.sync.completeBucketSwitch();
6149
+ for (const hash of hashes) this.sync.enqueueDownEvent({
6150
+ type: "register",
6151
+ payload: { hash }
6152
+ });
6153
+ this.logger.info({
6154
+ target,
6155
+ queries: hashes.length,
6156
+ Category: "sp00ky-client::Sp00kyClient::doSwitchBucket"
6157
+ }, "Local bucket switch complete");
6158
+ }
5567
6159
  async close() {
5568
6160
  await this.featureFlags.closeAll();
5569
6161
  this.crdtManager.closeAll();