@spooky-sync/core 0.0.1-canary.1 → 0.0.1-canary.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +938 -339
  3. package/dist/index.js +2978 -423
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +548 -0
  7. package/package.json +39 -9
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +9 -1
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +59 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +848 -134
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +115 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/feature-flag/index.test.ts +120 -0
  30. package/src/modules/feature-flag/index.ts +209 -0
  31. package/src/modules/ref-tables.test.ts +66 -0
  32. package/src/modules/ref-tables.ts +69 -0
  33. package/src/modules/sync/engine.ts +101 -37
  34. package/src/modules/sync/events/index.ts +9 -2
  35. package/src/modules/sync/queue/queue-down.ts +5 -4
  36. package/src/modules/sync/queue/queue-up.ts +14 -13
  37. package/src/modules/sync/scheduler.ts +40 -3
  38. package/src/modules/sync/sync.ts +896 -59
  39. package/src/modules/sync/utils.test.ts +269 -2
  40. package/src/modules/sync/utils.ts +182 -17
  41. package/src/otel/index.ts +127 -0
  42. package/src/services/database/database.ts +11 -11
  43. package/src/services/database/events/index.ts +2 -1
  44. package/src/services/database/local-migrator.ts +21 -21
  45. package/src/services/database/local.test.ts +33 -0
  46. package/src/services/database/local.ts +192 -32
  47. package/src/services/database/remote.ts +13 -13
  48. package/src/services/logger/index.ts +6 -101
  49. package/src/services/persistence/localstorage.ts +2 -2
  50. package/src/services/persistence/resilient.ts +41 -0
  51. package/src/services/persistence/surrealdb.ts +9 -9
  52. package/src/services/stream-processor/index.ts +248 -37
  53. package/src/services/stream-processor/permissions.test.ts +47 -0
  54. package/src/services/stream-processor/permissions.ts +53 -0
  55. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  56. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  57. package/src/services/stream-processor/wasm-types.ts +18 -2
  58. package/src/sp00ky.auth-order.test.ts +65 -0
  59. package/src/sp00ky.ts +648 -0
  60. package/src/types.ts +197 -15
  61. package/src/utils/index.ts +42 -13
  62. package/src/utils/parser.ts +3 -2
  63. package/src/utils/surql.ts +24 -15
  64. package/src/utils/withRetry.test.ts +1 -1
  65. package/tsdown.config.ts +55 -1
  66. package/src/spooky.ts +0 -346
package/dist/index.js CHANGED
@@ -2,13 +2,14 @@ import { DateTime, Duration, RecordId, Surreal, Uuid, applyDiagnostics, createRe
2
2
  import { QueryBuilder, RecordId as RecordId$1 } from "@spooky-sync/query-builder";
3
3
  import { createWasmWorkerEngines } from "@surrealdb/wasm";
4
4
  import pino from "pino";
5
- import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
6
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
7
- import { resourceFromAttributes } from "@opentelemetry/resources";
8
- import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
9
- import { createContextKey } from "@opentelemetry/api";
10
- import init, { SpookyProcessor } from "@spooky-sync/ssp-wasm";
5
+ import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
6
+ import { LoroDoc } from "loro-crdt";
11
7
 
8
+ //#region src/types.ts
9
+ /** Cap on the rolling materialization-sample window kept per query in memory. */
10
+ const MATERIALIZATION_SAMPLE_WINDOW = 100;
11
+
12
+ //#endregion
12
13
  //#region src/utils/surql.ts
13
14
  const surql = {
14
15
  seal(query, options) {
@@ -33,7 +34,7 @@ const surql = {
33
34
  return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
34
35
  },
35
36
  selectByFieldsAnd(table, whereVar, returnValues) {
36
- return `SELECT ${returnValues.map((returnValues) => typeof returnValues === "string" ? returnValues : `${returnValues.field} as ${returnValues.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((whereVar) => typeof whereVar === "string" ? `${whereVar} = $${whereVar}` : `${whereVar.field} = $${whereVar.variable}`).join(" AND ")}`;
37
+ return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
37
38
  },
38
39
  create(idVar, dataVar) {
39
40
  return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
@@ -44,11 +45,14 @@ const surql = {
44
45
  upsert(idVar, dataVar) {
45
46
  return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
46
47
  },
48
+ upsertMerge(idVar, dataVar) {
49
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
50
+ },
47
51
  updateMerge(idVar, dataVar) {
48
52
  return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
49
53
  },
50
54
  updateSet(idVar, keyDataVar) {
51
- return `UPDATE $${idVar} SET ${keyDataVar.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
55
+ return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
52
56
  },
53
57
  delete(idVar) {
54
58
  return `DELETE $${idVar}`;
@@ -76,7 +80,7 @@ const surql = {
76
80
  //#region src/utils/parser.ts
77
81
  function cleanRecord(tableSchema, record) {
78
82
  const cleaned = {};
79
- for (const [key, value] of Object.entries(record)) if (key === "id" || key in tableSchema) cleaned[key] = value;
83
+ for (const [key, value] of Object.entries(record)) if (key === "id" || key.startsWith("_00_") || key in tableSchema) cleaned[key] = value;
80
84
  return cleaned;
81
85
  }
82
86
  function parseParams(tableSchema, params) {
@@ -148,12 +152,21 @@ function generateId() {
148
152
  return Uuid.v4().toString().replace(/-/g, "");
149
153
  }
150
154
  /**
155
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
156
+ * releases expose it under `milliseconds` (current) or the older private
157
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
158
+ */
159
+ function durationMillis(duration) {
160
+ const d = duration;
161
+ return Number(d.milliseconds || d._milliseconds || 0);
162
+ }
163
+ /**
151
164
  * Parse duration string or Duration object to milliseconds
152
165
  */
153
166
  function parseDuration(duration) {
154
167
  if (duration instanceof Duration) {
155
- const ms = duration.milliseconds || duration._milliseconds;
156
- if (ms) return Number(ms);
168
+ const ms = durationMillis(duration);
169
+ if (ms) return ms;
157
170
  const str = duration.toString();
158
171
  if (str !== "[object Object]") return parseDuration(str);
159
172
  return 6e5;
@@ -162,13 +175,24 @@ function parseDuration(duration) {
162
175
  if (typeof duration !== "string") return 6e5;
163
176
  const match = duration.match(/^(\d+)([smh])$/);
164
177
  if (!match) return 6e5;
165
- const val = parseInt(match[1], 10);
178
+ const val = Number.parseInt(match[1], 10);
166
179
  switch (match[2]) {
167
180
  case "s": return val * 1e3;
168
181
  case "h": return val * 36e5;
169
182
  default: return val * 6e4;
170
183
  }
171
184
  }
185
+ async function fileToUint8Array(file) {
186
+ const buffer = await file.arrayBuffer();
187
+ return new Uint8Array(buffer);
188
+ }
189
+ /**
190
+ * Convert plain text to simple HTML paragraphs.
191
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
192
+ */
193
+ function textToHtml(text) {
194
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").split("\n").map((l) => `<p>${l || "<br>"}</p>`).join("");
195
+ }
172
196
  /**
173
197
  * Helper for retrying DB operations with exponential backoff
174
198
  */
@@ -184,7 +208,7 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
184
208
  attempt: i + 1,
185
209
  retries,
186
210
  error: msg,
187
- Category: "spooky-client::utils::withRetry"
211
+ Category: "sp00ky-client::utils::withRetry"
188
212
  }, "Retrying DB operation");
189
213
  await new Promise((res) => setTimeout(res, delayMs * (i + 1)));
190
214
  continue;
@@ -194,8 +218,160 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
194
218
  throw lastError;
195
219
  }
196
220
 
221
+ //#endregion
222
+ //#region src/modules/data/window-query.ts
223
+ /**
224
+ * Rewrite a windowed (`LIMIT n START m`, m>0) SELECT so its rows are
225
+ * materialized from an explicit record-id set — the window the SSP already
226
+ * computed — instead of re-running the original query (with its `START m`)
227
+ * against the shared local DB.
228
+ *
229
+ * Why: `DataModule.processStreamUpdate` materializes a query's rows by
230
+ * re-querying the local in-browser SurrealDB with the original surql. For an
231
+ * offset query that re-applies `START m` against the *shared* local store —
232
+ * which, with sparse windowing, may hold only this window's rows — so it skips
233
+ * them all and returns nothing (the "page 2 returns 0 rows" bug). The SSP's
234
+ * materialized view (`StreamUpdate.localArray`) is exactly this window's row
235
+ * ids, so we select those ids directly and re-apply the original `ORDER BY` for
236
+ * stable display order.
237
+ *
238
+ * Returns `null` for non-offset queries (`START` absent or 0) — the caller
239
+ * keeps the normal re-query path, so only the broken case changes behavior.
240
+ *
241
+ * Preserves the `SELECT <projection>` clause and the top-level `ORDER BY`;
242
+ * drops the original `FROM`/`WHERE`/`LIMIT`/`START`. Subqueries inside the
243
+ * projection are preserved verbatim (the scanner is paren/quote aware), with
244
+ * one caveat: SurrealDB v3 drops `*` in the `SELECT *, <subquery> FROM $param`
245
+ * shape — such windowed+subquery queries are rare and were already returning 0,
246
+ * so this is no regression.
247
+ */
248
+ function buildWindowMaterialization(surql, idsParam = "__win") {
249
+ const kw = scanTopLevelClauses(surql);
250
+ if (kw.startValue === null || kw.startValue <= 0) return null;
251
+ if (kw.fromIndex === null) return null;
252
+ const selectClause = surql.slice(0, kw.fromIndex).trimEnd();
253
+ let orderBy = "";
254
+ if (kw.orderByIndex !== null) {
255
+ const ends = [
256
+ kw.limitIndex,
257
+ kw.startIndex,
258
+ kw.semicolonIndex,
259
+ surql.length
260
+ ].filter((n) => n !== null && n > kw.orderByIndex);
261
+ const end = Math.min(...ends);
262
+ orderBy = " " + surql.slice(kw.orderByIndex, end).trim();
263
+ }
264
+ return { query: `${selectClause} FROM $${idsParam}${orderBy}` };
265
+ }
266
+ function scanTopLevelClauses(sql) {
267
+ const out = {
268
+ fromIndex: null,
269
+ orderByIndex: null,
270
+ limitIndex: null,
271
+ startIndex: null,
272
+ startValue: null,
273
+ semicolonIndex: null
274
+ };
275
+ let depth = 0;
276
+ let inStr = false;
277
+ for (let i = 0; i < sql.length; i++) {
278
+ const ch = sql[i];
279
+ if (inStr) {
280
+ if (ch === "\\") i++;
281
+ else if (ch === "'") inStr = false;
282
+ continue;
283
+ }
284
+ if (ch === "'") {
285
+ inStr = true;
286
+ continue;
287
+ }
288
+ if (ch === "(") {
289
+ depth++;
290
+ continue;
291
+ }
292
+ if (ch === ")") {
293
+ depth--;
294
+ continue;
295
+ }
296
+ if (depth !== 0) continue;
297
+ if (ch === ";" && out.semicolonIndex === null) {
298
+ out.semicolonIndex = i;
299
+ continue;
300
+ }
301
+ if (!isWordBoundary(sql, i)) continue;
302
+ if (out.fromIndex === null && matchKeyword(sql, i, "FROM")) {
303
+ out.fromIndex = i;
304
+ continue;
305
+ }
306
+ if (out.fromIndex === null) continue;
307
+ if (out.orderByIndex === null && matchKeyword(sql, i, "ORDER BY")) {
308
+ out.orderByIndex = i;
309
+ continue;
310
+ }
311
+ if (out.limitIndex === null && matchKeyword(sql, i, "LIMIT")) {
312
+ out.limitIndex = i;
313
+ continue;
314
+ }
315
+ if (out.startIndex === null && matchKeyword(sql, i, "START")) {
316
+ out.startIndex = i;
317
+ out.startValue = readNumberAfter(sql, i + 5);
318
+ continue;
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+ function isWordBoundary(sql, i) {
324
+ if (i === 0) return true;
325
+ return !/[A-Za-z0-9_]/.test(sql[i - 1]);
326
+ }
327
+ function matchKeyword(sql, i, keyword) {
328
+ const parts = keyword.split(" ");
329
+ let pos = i;
330
+ for (let p = 0; p < parts.length; p++) {
331
+ const word = parts[p];
332
+ if (sql.slice(pos, pos + word.length).toUpperCase() !== word) return false;
333
+ pos += word.length;
334
+ if (p < parts.length - 1) {
335
+ const wsStart = pos;
336
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
337
+ if (pos === wsStart) return false;
338
+ }
339
+ }
340
+ return pos >= sql.length || !/[A-Za-z0-9_]/.test(sql[pos]);
341
+ }
342
+ function readNumberAfter(sql, from) {
343
+ let pos = from;
344
+ while (pos < sql.length && /\s/.test(sql[pos])) pos++;
345
+ const m = /^\d+/.exec(sql.slice(pos));
346
+ return m ? parseInt(m[0], 10) : null;
347
+ }
348
+
197
349
  //#endregion
198
350
  //#region src/modules/data/index.ts
351
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
352
+ function pushSample(samples, ms) {
353
+ samples.push(ms);
354
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
355
+ }
356
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
357
+ function phaseStatOf(samples, lastMs) {
358
+ if (samples.length === 0) return {
359
+ lastMs,
360
+ p50: null,
361
+ p90: null,
362
+ p99: null,
363
+ count: 0
364
+ };
365
+ const sorted = [...samples].sort((a, b) => a - b);
366
+ const pick = (q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
367
+ return {
368
+ lastMs,
369
+ p50: pick(.5),
370
+ p90: pick(.9),
371
+ p99: pick(.99),
372
+ count: samples.length
373
+ };
374
+ }
199
375
  /**
200
376
  * DataModule - Unified query and mutation management
201
377
  *
@@ -204,19 +380,76 @@ async function withRetry(logger, operation, retries = 3, delayMs = 100) {
204
380
  */
205
381
  var DataModule = class {
206
382
  activeQueries = /* @__PURE__ */ new Map();
383
+ pendingQueries = /* @__PURE__ */ new Map();
207
384
  subscriptions = /* @__PURE__ */ new Map();
385
+ statusSubscriptions = /* @__PURE__ */ new Map();
208
386
  mutationCallbacks = /* @__PURE__ */ new Set();
209
387
  debounceTimers = /* @__PURE__ */ new Map();
210
388
  logger;
211
- constructor(cache, local, schema, logger, streamDebounceTime = 100) {
389
+ /**
390
+ * Optional observer notified whenever a query's fetch status changes.
391
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
392
+ * settable field (rather than a constructor arg) because DevTools is
393
+ * constructed after DataModule.
394
+ */
395
+ onQueryStatusChange;
396
+ /**
397
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
398
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
399
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
400
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
401
+ * field (not a constructor arg) because the sync engine is wired after
402
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
403
+ */
404
+ onHeartbeat;
405
+ /**
406
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
407
+ * viewport-windowed list cancelling an off-screen window) loses its last
408
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
409
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
410
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
411
+ * {@link finalizeDeregister} only after that remote delete, so a fast
412
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
413
+ */
414
+ onDeregister;
415
+ sessionId = "";
416
+ currentUserId = null;
417
+ constructor(cache, local, schema, logger, streamDebounceTime = 50) {
212
418
  this.cache = cache;
213
419
  this.local = local;
214
420
  this.schema = schema;
215
421
  this.streamDebounceTime = streamDebounceTime;
216
422
  this.logger = logger.child({ service: "DataModule" });
217
423
  }
218
- async init() {
219
- this.logger.info({ Category: "spooky-client::DataModule::init" }, "DataModule initialized");
424
+ async init(sessionId) {
425
+ this.sessionId = sessionId;
426
+ this.logger.info({
427
+ sessionId,
428
+ Category: "sp00ky-client::DataModule::init"
429
+ }, "DataModule initialized");
430
+ }
431
+ /**
432
+ * Update the session salt used in query-id hashing. Call this when the
433
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
434
+ * registered queries will get fresh, session-scoped IDs.
435
+ */
436
+ setSessionId(sessionId) {
437
+ this.sessionId = sessionId;
438
+ }
439
+ /**
440
+ * Update the authenticated user record id. Pass `null` on sign-out.
441
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
442
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
443
+ * SSP writes to.
444
+ */
445
+ setCurrentUserId(userId) {
446
+ this.currentUserId = userId;
447
+ }
448
+ /** Read-only view of the authenticated user id used for per-user
449
+ * `_00_list_ref` routing. Other modules consult this so they pick the
450
+ * same table name DataModule does. */
451
+ getCurrentUserId() {
452
+ return this.currentUserId;
220
453
  }
221
454
  /**
222
455
  * Register a query and return its hash for subscriptions
@@ -228,46 +461,35 @@ var DataModule = class {
228
461
  });
229
462
  this.logger.debug({
230
463
  hash,
231
- Category: "spooky-client::DataModule::query"
464
+ Category: "sp00ky-client::DataModule::query"
232
465
  }, "Query Initialization: started");
233
- const recordId = new RecordId("_spooky_query", hash);
466
+ const recordId = new RecordId("_00_query", hash);
234
467
  if (this.activeQueries.has(hash)) {
235
468
  this.logger.debug({
236
469
  hash,
237
- Category: "spooky-client::DataModule::query"
470
+ Category: "sp00ky-client::DataModule::query"
238
471
  }, "Query Initialization: exists, returning");
239
472
  return hash;
240
473
  }
474
+ if (this.pendingQueries.has(hash)) {
475
+ this.logger.debug({
476
+ hash,
477
+ Category: "sp00ky-client::DataModule::query"
478
+ }, "Query Initialization: pending, waiting for existing creation");
479
+ await this.pendingQueries.get(hash);
480
+ return hash;
481
+ }
241
482
  this.logger.debug({
242
483
  hash,
243
- Category: "spooky-client::DataModule::query"
484
+ Category: "sp00ky-client::DataModule::query"
244
485
  }, "Query Initialization: not found, creating new query");
245
- const queryState = await this.createNewQuery({
246
- recordId,
247
- surql: surqlString,
248
- params,
249
- ttl,
250
- tableName
251
- });
252
- const { localArray } = this.cache.registerQuery({
253
- queryHash: hash,
254
- surql: surqlString,
255
- params,
256
- ttl: new Duration(ttl),
257
- lastActiveAt: /* @__PURE__ */ new Date()
258
- });
259
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
260
- id: recordId,
261
- localArray
262
- }));
263
- this.activeQueries.set(hash, queryState);
264
- this.startTTLHeartbeat(queryState);
265
- this.logger.debug({
266
- hash,
267
- tableName,
268
- recordCount: queryState.records.length,
269
- Category: "spooky-client::DataModule::query"
270
- }, "Query registered");
486
+ const promise = this.createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName);
487
+ this.pendingQueries.set(hash, promise);
488
+ try {
489
+ await promise;
490
+ } finally {
491
+ this.pendingQueries.delete(hash);
492
+ }
271
493
  return hash;
272
494
  }
273
495
  /**
@@ -289,6 +511,36 @@ var DataModule = class {
289
511
  };
290
512
  }
291
513
  /**
514
+ * Subscribe to a query's fetch-status changes (idle/fetching).
515
+ * With `{ immediate: true }` the callback fires synchronously with the
516
+ * current status (defaults to `idle` if the query isn't registered yet).
517
+ */
518
+ subscribeStatus(queryHash, callback, options = {}) {
519
+ if (!this.statusSubscriptions.has(queryHash)) this.statusSubscriptions.set(queryHash, /* @__PURE__ */ new Set());
520
+ this.statusSubscriptions.get(queryHash)?.add(callback);
521
+ if (options.immediate) callback(this.activeQueries.get(queryHash)?.status ?? "idle");
522
+ return () => {
523
+ const subs = this.statusSubscriptions.get(queryHash);
524
+ if (subs) {
525
+ subs.delete(callback);
526
+ if (subs.size === 0) this.statusSubscriptions.delete(queryHash);
527
+ }
528
+ };
529
+ }
530
+ /**
531
+ * Set a query's fetch status and notify status observers (DevTools +
532
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
533
+ * query is unknown.
534
+ */
535
+ setQueryStatus(queryHash, status) {
536
+ const queryState = this.activeQueries.get(queryHash);
537
+ if (!queryState || queryState.status === status) return;
538
+ queryState.status = status;
539
+ this.onQueryStatusChange?.(queryHash, status);
540
+ const subs = this.statusSubscriptions.get(queryHash);
541
+ if (subs) for (const callback of subs) callback(status);
542
+ }
543
+ /**
292
544
  * Subscribe to mutations (for sync)
293
545
  */
294
546
  onMutation(callback) {
@@ -302,56 +554,298 @@ var DataModule = class {
302
554
  */
303
555
  async onStreamUpdate(update) {
304
556
  const { queryHash, op } = update;
305
- if (op === "UPDATE") {
306
- if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
307
- const timer = setTimeout(async () => {
557
+ if (op === "DELETE") {
558
+ const existing = this.debounceTimers.get(queryHash);
559
+ if (existing) {
560
+ clearTimeout(existing);
308
561
  this.debounceTimers.delete(queryHash);
309
- await this.processStreamUpdate(update);
310
- }, this.streamDebounceTime);
311
- this.debounceTimers.set(queryHash, timer);
312
- } else await this.processStreamUpdate(update);
562
+ }
563
+ await this.processStreamUpdate(update);
564
+ return;
565
+ }
566
+ if (this.debounceTimers.has(queryHash)) clearTimeout(this.debounceTimers.get(queryHash));
567
+ const timer = setTimeout(async () => {
568
+ this.debounceTimers.delete(queryHash);
569
+ await this.processStreamUpdate(update);
570
+ }, this.streamDebounceTime);
571
+ this.debounceTimers.set(queryHash, timer);
572
+ }
573
+ async materializeRecords(queryState, sspArray) {
574
+ const t0 = performance.now();
575
+ const windowMat = buildWindowMaterialization(queryState.config.surql);
576
+ let records;
577
+ if (windowMat) {
578
+ const winIds = (queryState.config.remoteArray?.length && queryState.config.remoteArray || sspArray?.length && sspArray || queryState.config.localArray || []).map(([id]) => parseRecordIdString(id));
579
+ const [rows] = await this.local.query(windowMat.query, {
580
+ ...queryState.config.params,
581
+ __win: winIds
582
+ });
583
+ records = rows || [];
584
+ } else {
585
+ const [rows] = await this.local.query(queryState.config.surql, queryState.config.params);
586
+ records = rows || [];
587
+ }
588
+ this.recordPhase(queryState, "localFetch", performance.now() - t0);
589
+ return records;
313
590
  }
314
591
  async processStreamUpdate(update) {
315
- const { queryHash, localArray } = update;
592
+ const { queryHash, localArray, materializationTimeMs } = update;
316
593
  const queryState = this.activeQueries.get(queryHash);
317
594
  if (!queryState) {
318
595
  this.logger.warn({
319
596
  queryHash,
320
- Category: "spooky-client::DataModule::onStreamUpdate"
597
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
321
598
  }, "Received update for unknown query. Skipping...");
322
599
  return;
323
600
  }
601
+ if (typeof materializationTimeMs === "number") {
602
+ queryState.materializationSamples.push(materializationTimeMs);
603
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
604
+ queryState.lastIngestLatencyMs = materializationTimeMs;
605
+ }
606
+ if (typeof update.storeApplyMs === "number") this.recordPhase(queryState, "sspStoreApply", update.storeApplyMs);
607
+ if (typeof update.circuitStepMs === "number") this.recordPhase(queryState, "sspCircuitStep", update.circuitStepMs);
608
+ if (typeof update.transformMs === "number") this.recordPhase(queryState, "sspTransform", update.transformMs);
609
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
324
610
  try {
325
- const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
326
- queryState.records = records || [];
611
+ const newRecords = await this.materializeRecords(queryState, localArray);
327
612
  queryState.config.localArray = localArray;
328
- queryState.updateCount++;
329
- await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
613
+ const prevJson = JSON.stringify(queryState.records);
614
+ const newJson = JSON.stringify(newRecords);
615
+ queryState.records = newRecords;
616
+ const recordsChanged = prevJson !== newJson;
617
+ if (recordsChanged) queryState.updateCount++;
618
+ await this.local.query(surql.seal(surql.updateSet("id", [
619
+ "localArray",
620
+ "rowCount",
621
+ "updateCount",
622
+ "lastIngestLatency",
623
+ "materializationP55",
624
+ "materializationP90",
625
+ "materializationP99"
626
+ ])), {
330
627
  id: queryState.config.id,
331
- localArray
628
+ localArray,
629
+ rowCount: localArray.length,
630
+ updateCount: queryState.updateCount,
631
+ lastIngestLatency: queryState.lastIngestLatencyMs,
632
+ materializationP55: percentiles.p55,
633
+ materializationP90: percentiles.p90,
634
+ materializationP99: percentiles.p99
332
635
  });
636
+ if (!recordsChanged) {
637
+ this.logger.debug({
638
+ queryHash,
639
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
640
+ }, "Query records unchanged, skipping notification");
641
+ return;
642
+ }
333
643
  const subscribers = this.subscriptions.get(queryHash);
334
644
  if (subscribers) for (const callback of subscribers) callback(queryState.records);
335
645
  this.logger.debug({
336
646
  queryHash,
337
- recordCount: records?.length,
338
- Category: "spooky-client::DataModule::onStreamUpdate"
647
+ recordCount: newRecords?.length,
648
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
339
649
  }, "Query updated from stream");
340
650
  } catch (err) {
651
+ queryState.errorCount++;
341
652
  this.logger.error({
342
653
  err,
343
654
  queryHash,
344
- Category: "spooky-client::DataModule::onStreamUpdate"
655
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
345
656
  }, "Failed to fetch records for stream update");
657
+ try {
658
+ await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
659
+ id: queryState.config.id,
660
+ errorCount: queryState.errorCount
661
+ });
662
+ } catch (persistErr) {
663
+ this.logger.warn({
664
+ err: persistErr,
665
+ queryHash,
666
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
667
+ }, "Failed to persist incremented errorCount");
668
+ }
346
669
  }
347
670
  }
348
671
  /**
672
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
673
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
674
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
675
+ */
676
+ computeMaterializationPercentiles(samples) {
677
+ if (samples.length === 0) return {
678
+ p55: null,
679
+ p90: null,
680
+ p99: null
681
+ };
682
+ const sorted = [...samples].sort((a, b) => a - b);
683
+ const pick = (q) => {
684
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
685
+ };
686
+ return {
687
+ p55: pick(.55),
688
+ p90: pick(.9),
689
+ p99: pick(.99)
690
+ };
691
+ }
692
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
693
+ recordPhase(qs, phase, ms) {
694
+ if (!Number.isFinite(ms)) return;
695
+ pushSample(qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []), ms);
696
+ qs.phaseLast[phase] = ms;
697
+ }
698
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
699
+ recordRemoteFetch(hash, ms) {
700
+ const qs = this.activeQueries.get(hash);
701
+ if (qs) this.recordPhase(qs, "remoteFetch", ms);
702
+ }
703
+ /**
704
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
705
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
706
+ */
707
+ recordFrontendTiming(hash, ms) {
708
+ const qs = this.activeQueries.get(hash);
709
+ if (qs) this.recordPhase(qs, "frontend", ms);
710
+ }
711
+ /**
712
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
713
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
714
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
715
+ */
716
+ phaseTimings(q) {
717
+ const stat = (phase) => phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
718
+ return {
719
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
720
+ sspStoreApply: stat("sspStoreApply"),
721
+ sspCircuitStep: stat("sspCircuitStep"),
722
+ sspTransform: stat("sspTransform"),
723
+ localFetch: stat("localFetch"),
724
+ remoteFetch: stat("remoteFetch"),
725
+ frontend: stat("frontend"),
726
+ registration: q.registrationTimings,
727
+ updateCount: q.updateCount,
728
+ errorCount: q.errorCount
729
+ };
730
+ }
731
+ /**
349
732
  * Get query state (for sync and devtools)
350
733
  */
351
734
  getQueryByHash(hash) {
352
735
  return this.activeQueries.get(hash);
353
736
  }
354
737
  /**
738
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
739
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
740
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
741
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
742
+ * but it still hasn't loaded its own full window from the server — so it should
743
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
744
+ */
745
+ isCold(hash) {
746
+ const qs = this.activeQueries.get(hash);
747
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
748
+ }
749
+ /**
750
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
751
+ * `batch` (recursing for nested related fields). An embedded child is a
752
+ * value that is itself a record — a non-null object whose `id` is a
753
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
754
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
755
+ * so this never mistakes a FK column for an embedded body. Children are
756
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
757
+ * to their table's real columns (which strips the alias/related fields).
758
+ * `seen` dedupes within the batch.
759
+ */
760
+ collectEmbeddedChildren(record, batch, seen) {
761
+ const isEmbeddedRecord = (v) => !!v && typeof v === "object" && !(v instanceof RecordId) && v.id instanceof RecordId;
762
+ for (const value of Object.values(record)) {
763
+ const children = Array.isArray(value) ? value.filter(isEmbeddedRecord) : isEmbeddedRecord(value) ? [value] : [];
764
+ for (const child of children) {
765
+ const key = encodeRecordId(child.id);
766
+ if (seen.has(key)) continue;
767
+ seen.add(key);
768
+ this.collectEmbeddedChildren(child, batch, seen);
769
+ const table = child.id.table.toString();
770
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
771
+ batch.push({
772
+ table,
773
+ op: "CREATE",
774
+ record: tableSchema ? cleanRecord(tableSchema.columns, child) : child,
775
+ version: child._00_rv || 1
776
+ });
777
+ }
778
+ }
779
+ }
780
+ /**
781
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
782
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
783
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
784
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
785
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
786
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
787
+ */
788
+ async applyHydration(hash, rows) {
789
+ const queryState = this.activeQueries.get(hash);
790
+ if (!queryState) return;
791
+ queryState.hydrated = true;
792
+ if (rows.length === 0) return;
793
+ const tableName = queryState.config.tableName;
794
+ const batch = rows.map((record) => ({
795
+ table: tableName,
796
+ op: "CREATE",
797
+ record,
798
+ version: record._00_rv || 1
799
+ }));
800
+ const seen = new Set(rows.map((r) => encodeRecordId(r.id)));
801
+ for (const record of rows) this.collectEmbeddedChildren(record, batch, seen);
802
+ await this.cache.saveBatch(batch);
803
+ queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
804
+ queryState.records = await this.materializeRecords(queryState);
805
+ const subscribers = this.subscriptions.get(hash);
806
+ if (subscribers) for (const cb of subscribers) cb(queryState.records);
807
+ }
808
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
809
+ hasSubscribers(hash) {
810
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
811
+ }
812
+ /**
813
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
814
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
815
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
816
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
817
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
818
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
819
+ *
820
+ * NOTE: most queries should NOT use this — the default keep-alive on
821
+ * unsubscribe avoids re-registration churn on navigation.
822
+ */
823
+ deregisterQuery(hash) {
824
+ if (this.hasSubscribers(hash)) return;
825
+ if (!this.activeQueries.has(hash)) return;
826
+ this.onDeregister?.(hash);
827
+ }
828
+ /**
829
+ * Final local teardown after the remote `_00_query` row was deleted: free the
830
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
831
+ * (`cleanupQuery`) guarantees no subscriber remains.
832
+ */
833
+ finalizeDeregister(hash) {
834
+ const qs = this.activeQueries.get(hash);
835
+ if (qs?.ttlTimer) {
836
+ clearTimeout(qs.ttlTimer);
837
+ qs.ttlTimer = null;
838
+ }
839
+ const debounce = this.debounceTimers.get(hash);
840
+ if (debounce) {
841
+ clearTimeout(debounce);
842
+ this.debounceTimers.delete(hash);
843
+ }
844
+ this.cache.unregisterQuery(hash);
845
+ this.activeQueries.delete(hash);
846
+ this.subscriptions.delete(hash);
847
+ }
848
+ /**
355
849
  * Get query state by id (for sync and devtools)
356
850
  */
357
851
  getQueryById(id) {
@@ -363,12 +857,15 @@ var DataModule = class {
363
857
  getActiveQueries() {
364
858
  return Array.from(this.activeQueries.values());
365
859
  }
860
+ getActiveQueryHashes() {
861
+ return Array.from(this.activeQueries.keys());
862
+ }
366
863
  async updateQueryLocalArray(id, localArray) {
367
864
  const queryState = this.activeQueries.get(id);
368
865
  if (!queryState) {
369
866
  this.logger.warn({
370
867
  id,
371
- Category: "spooky-client::DataModule::updateQueryLocalArray"
868
+ Category: "sp00ky-client::DataModule::updateQueryLocalArray"
372
869
  }, "Query to update local array not found");
373
870
  return;
374
871
  }
@@ -383,7 +880,7 @@ var DataModule = class {
383
880
  if (!queryState) {
384
881
  this.logger.warn({
385
882
  hash,
386
- Category: "spooky-client::DataModule::updateQueryRemoteArray"
883
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
387
884
  }, "Query to update remote array not found");
388
885
  return;
389
886
  }
@@ -393,6 +890,22 @@ var DataModule = class {
393
890
  remoteArray
394
891
  });
395
892
  }
893
+ /**
894
+ * Called after a query's initial sync completes.
895
+ * Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
896
+ */
897
+ async notifyQuerySynced(queryHash) {
898
+ const queryState = this.activeQueries.get(queryHash);
899
+ if (!queryState) return;
900
+ const newRecords = await this.materializeRecords(queryState);
901
+ const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
902
+ queryState.records = newRecords;
903
+ if (changed || queryState.updateCount === 0) {
904
+ queryState.updateCount++;
905
+ const subscribers = this.subscriptions.get(queryHash);
906
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
907
+ }
908
+ }
396
909
  async run(backend, path, data, options) {
397
910
  const route = this.schema.backends?.[backend]?.routes?.[path];
398
911
  if (!route) throw new Error(`Route ${backend}.${path} not found`);
@@ -410,6 +923,8 @@ var DataModule = class {
410
923
  max_retries: options?.max_retries ?? 3,
411
924
  retry_strategy: options?.retry_strategy ?? "linear"
412
925
  };
926
+ if (options?.timeout != null) record.timeout = options.timeout;
927
+ if (options?.delay != null) record.delay = options.delay;
413
928
  if (options?.assignedTo) record.assigned_to = options.assignedTo;
414
929
  const recordId = `${tableName}:${generateId()}`;
415
930
  await this.create(recordId, record);
@@ -423,7 +938,7 @@ var DataModule = class {
423
938
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
424
939
  const rid = parseRecordIdString(id);
425
940
  const params = parseParams(tableSchema.columns, data);
426
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
941
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
427
942
  const dataKeys = Object.keys(params).map((key) => ({
428
943
  key,
429
944
  variable: `data_${key}`
@@ -453,7 +968,7 @@ var DataModule = class {
453
968
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
454
969
  this.logger.debug({
455
970
  id,
456
- Category: "spooky-client::DataModule::create"
971
+ Category: "sp00ky-client::DataModule::create"
457
972
  }, "Record created");
458
973
  return target;
459
974
  }
@@ -466,10 +981,10 @@ var DataModule = class {
466
981
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
467
982
  const rid = parseRecordIdString(id);
468
983
  const params = parseParams(tableSchema.columns, data);
469
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
984
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
470
985
  const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
471
986
  const query = surql.seal(surql.tx([
472
- surql.updateSet("id", [{ statement: "spooky_rv += 1" }]),
987
+ surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
473
988
  surql.let("updated", surql.updateMerge("id", "data")),
474
989
  surql.createMutation("update", "mid", "id", "data"),
475
990
  surql.returnObject([{
@@ -482,13 +997,16 @@ var DataModule = class {
482
997
  mid: mutationId,
483
998
  data: params
484
999
  }));
485
- this.replaceRecordInQueries(target);
1000
+ const updatedFields = { id: target.id };
1001
+ for (const key of Object.keys(data)) if (key in target) updatedFields[key] = target[key];
1002
+ if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
1003
+ this.replaceRecordInQueries(updatedFields);
486
1004
  const parsedRecord = parseParams(tableSchema.columns, target);
487
1005
  await this.cache.save({
488
1006
  table,
489
1007
  op: "UPDATE",
490
1008
  record: parsedRecord,
491
- version: target.spooky_rv
1009
+ version: target._00_rv
492
1010
  }, true);
493
1011
  const pushEventOptions = parseUpdateOptions(id, data, options);
494
1012
  const mutationEvent = {
@@ -503,7 +1021,7 @@ var DataModule = class {
503
1021
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
504
1022
  this.logger.debug({
505
1023
  id,
506
- Category: "spooky-client::DataModule::update"
1024
+ Category: "sp00ky-client::DataModule::update"
507
1025
  }, "Record updated");
508
1026
  return target;
509
1027
  }
@@ -514,13 +1032,32 @@ var DataModule = class {
514
1032
  const tableName = extractTablePart(id);
515
1033
  if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
516
1034
  const rid = parseRecordIdString(id);
517
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1035
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1036
+ const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
1037
+ const beforeRecord = beforeRecords ?? {};
518
1038
  const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
519
1039
  await withRetry(this.logger, () => this.local.execute(query, {
520
1040
  id: rid,
521
1041
  mid: mutationId
522
1042
  }));
523
- await this.cache.delete(table, id, true);
1043
+ try {
1044
+ await this.cache.delete(table, id, true, beforeRecord);
1045
+ } catch (err) {
1046
+ this.logger.error({
1047
+ err,
1048
+ id,
1049
+ Category: "sp00ky-client::DataModule::delete"
1050
+ }, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
1051
+ }
1052
+ for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
1053
+ await this.notifyQuerySynced(queryHash);
1054
+ } catch (err) {
1055
+ this.logger.error({
1056
+ err,
1057
+ queryHash,
1058
+ Category: "sp00ky-client::DataModule::delete"
1059
+ }, "notifyQuerySynced failed after delete");
1060
+ }
524
1061
  const mutationEvent = {
525
1062
  type: "delete",
526
1063
  mutation_id: mutationId,
@@ -529,7 +1066,7 @@ var DataModule = class {
529
1066
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
530
1067
  this.logger.debug({
531
1068
  id,
532
- Category: "spooky-client::DataModule::delete"
1069
+ Category: "sp00ky-client::DataModule::delete"
533
1070
  }, "Record deleted");
534
1071
  }
535
1072
  /**
@@ -544,14 +1081,14 @@ var DataModule = class {
544
1081
  this.logger.info({
545
1082
  id,
546
1083
  tableName,
547
- Category: "spooky-client::DataModule::rollbackCreate"
1084
+ Category: "sp00ky-client::DataModule::rollbackCreate"
548
1085
  }, "Rolled back optimistic create");
549
1086
  } catch (err) {
550
1087
  this.logger.error({
551
1088
  err,
552
1089
  id,
553
1090
  tableName,
554
- Category: "spooky-client::DataModule::rollbackCreate"
1091
+ Category: "sp00ky-client::DataModule::rollbackCreate"
555
1092
  }, "Failed to rollback create");
556
1093
  }
557
1094
  }
@@ -572,20 +1109,20 @@ var DataModule = class {
572
1109
  table: tableName,
573
1110
  op: "UPDATE",
574
1111
  record: parsedRecord,
575
- version: beforeRecord.spooky_rv || 1
1112
+ version: beforeRecord._00_rv || 1
576
1113
  }, true);
577
1114
  await this.replaceRecordInQueries(beforeRecord);
578
1115
  this.logger.info({
579
1116
  id,
580
1117
  tableName,
581
- Category: "spooky-client::DataModule::rollbackUpdate"
1118
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
582
1119
  }, "Rolled back optimistic update");
583
1120
  } catch (err) {
584
1121
  this.logger.error({
585
1122
  err,
586
1123
  id,
587
1124
  tableName,
588
- Category: "spooky-client::DataModule::rollbackUpdate"
1125
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
589
1126
  }, "Failed to rollback update");
590
1127
  }
591
1128
  }
@@ -605,6 +1142,64 @@ var DataModule = class {
605
1142
  }
606
1143
  }
607
1144
  }
1145
+ async createAndRegisterQuery(hash, recordId, surqlString, params, ttl, tableName) {
1146
+ const queryState = await this.createNewQuery({
1147
+ recordId,
1148
+ surql: surqlString,
1149
+ params,
1150
+ ttl,
1151
+ tableName
1152
+ });
1153
+ const t0 = performance.now();
1154
+ const { localArray, registrationTimings } = this.cache.registerQuery({
1155
+ queryHash: hash,
1156
+ surql: surqlString,
1157
+ params,
1158
+ ttl: new Duration(ttl),
1159
+ lastActiveAt: /* @__PURE__ */ new Date()
1160
+ });
1161
+ const registrationTime = performance.now() - t0;
1162
+ queryState.registrationTimings = {
1163
+ parseMs: registrationTimings?.parseMs ?? null,
1164
+ planMs: registrationTimings?.planMs ?? null,
1165
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1166
+ wallMs: registrationTime
1167
+ };
1168
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
1169
+ "localArray",
1170
+ "registrationTime",
1171
+ "rowCount"
1172
+ ])), {
1173
+ id: recordId,
1174
+ localArray,
1175
+ registrationTime,
1176
+ rowCount: localArray.length
1177
+ }));
1178
+ const windowMat = buildWindowMaterialization(surqlString);
1179
+ if (windowMat && localArray.length > 0) try {
1180
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1181
+ const [seeded] = await this.local.query(windowMat.query, {
1182
+ ...params,
1183
+ __win: winIds
1184
+ });
1185
+ queryState.records = seeded || [];
1186
+ } catch (err) {
1187
+ this.logger.warn({
1188
+ err,
1189
+ hash,
1190
+ Category: "sp00ky-client::DataModule::createAndRegisterQuery"
1191
+ }, "Failed to seed windowed initial records from localArray");
1192
+ }
1193
+ this.activeQueries.set(hash, queryState);
1194
+ this.startTTLHeartbeat(queryState, hash);
1195
+ this.logger.debug({
1196
+ hash,
1197
+ tableName,
1198
+ recordCount: queryState.records.length,
1199
+ Category: "sp00ky-client::DataModule::query"
1200
+ }, "Query registered");
1201
+ return hash;
1202
+ }
608
1203
  async createNewQuery({ recordId, surql: surqlString, params, ttl, tableName }) {
609
1204
  const tableSchema = this.schema.tables.find((t) => t.name === tableName);
610
1205
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
@@ -618,8 +1213,12 @@ var DataModule = class {
618
1213
  localArray: [],
619
1214
  remoteArray: [],
620
1215
  lastActiveAt: /* @__PURE__ */ new Date(),
1216
+ createdAt: /* @__PURE__ */ new Date(),
621
1217
  ttl,
622
- tableName
1218
+ tableName,
1219
+ updateCount: 0,
1220
+ rowCount: 0,
1221
+ errorCount: 0
623
1222
  }
624
1223
  }));
625
1224
  configRecord = createdRecord;
@@ -630,52 +1229,79 @@ var DataModule = class {
630
1229
  params: parseParams(tableSchema.columns, configRecord.params)
631
1230
  };
632
1231
  let records = [];
633
- try {
1232
+ if (buildWindowMaterialization(surqlString) === null) try {
634
1233
  const [result] = await this.local.query(surqlString, params);
635
1234
  records = result || [];
636
1235
  } catch (err) {
637
1236
  this.logger.warn({
638
1237
  err,
639
- Category: "spooky-client::DataModule::createNewQuery"
1238
+ Category: "sp00ky-client::DataModule::createNewQuery"
640
1239
  }, "Failed to load initial cached records");
641
1240
  }
1241
+ const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
1242
+ const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
642
1243
  return {
643
1244
  config,
644
1245
  records,
645
1246
  ttlTimer: null,
646
1247
  ttlDurationMs: parseDuration(ttl),
647
- updateCount: 0
1248
+ updateCount: persistedUpdateCount,
1249
+ materializationSamples: [],
1250
+ lastIngestLatencyMs: null,
1251
+ errorCount: persistedErrorCount,
1252
+ status: "idle",
1253
+ phaseSamples: {},
1254
+ phaseLast: {},
1255
+ registrationTimings: {
1256
+ parseMs: null,
1257
+ planMs: null,
1258
+ snapshotMs: null,
1259
+ wallMs: null
1260
+ }
648
1261
  };
649
1262
  }
650
1263
  async calculateHash(data) {
651
- const content = JSON.stringify(data);
1264
+ const content = JSON.stringify({
1265
+ ...data,
1266
+ sessionId: this.sessionId
1267
+ });
652
1268
  const msgBuffer = new TextEncoder().encode(content);
653
1269
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
654
1270
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
655
1271
  }
656
- startTTLHeartbeat(queryState) {
1272
+ startTTLHeartbeat(queryState, hash) {
657
1273
  if (queryState.ttlTimer) return;
658
1274
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
659
1275
  queryState.ttlTimer = setTimeout(() => {
1276
+ queryState.ttlTimer = null;
1277
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
1278
+ this.logger.debug({
1279
+ hash,
1280
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1281
+ }, "TTL heartbeat: no subscribers, stopping");
1282
+ return;
1283
+ }
1284
+ this.onHeartbeat?.(hash);
660
1285
  this.logger.debug({
1286
+ hash,
661
1287
  id: encodeRecordId(queryState.config.id),
662
- Category: "spooky-client::DataModule::startTTLHeartbeat"
663
- }, "TTL heartbeat");
664
- this.startTTLHeartbeat(queryState);
1288
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1289
+ }, "TTL heartbeat sent");
1290
+ this.startTTLHeartbeat(queryState, hash);
665
1291
  }, heartbeatTime);
666
1292
  }
667
- stopTTLHeartbeat(queryState) {
668
- if (queryState.ttlTimer) {
669
- clearTimeout(queryState.ttlTimer);
670
- queryState.ttlTimer = null;
671
- }
672
- }
673
1293
  async replaceRecordInQueries(record) {
674
- for (const queryState of this.activeQueries.values()) this.replaceRecordInQuery(queryState, record);
675
- }
676
- replaceRecordInQuery(queryState, record) {
677
- const index = queryState.records.findIndex((r) => r.id === record.id);
678
- if (index !== -1) queryState.records[index] = record;
1294
+ for (const [queryHash, queryState] of this.activeQueries.entries()) {
1295
+ const index = queryState.records.findIndex((r) => r.id === record.id);
1296
+ if (index !== -1) {
1297
+ queryState.records[index] = {
1298
+ ...queryState.records[index],
1299
+ ...record
1300
+ };
1301
+ const subscribers = this.subscriptions.get(queryHash);
1302
+ if (subscribers) for (const callback of subscribers) callback(queryState.records);
1303
+ }
1304
+ }
679
1305
  }
680
1306
  };
681
1307
  /**
@@ -685,7 +1311,7 @@ function parseUpdateOptions(id, data, options) {
685
1311
  let pushEventOptions = {};
686
1312
  if (options?.debounced) pushEventOptions = { debounced: {
687
1313
  delay: options.debounced !== true ? options.debounced?.delay ?? 200 : 200,
688
- key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).sort().join("#")}` : id
1314
+ key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
689
1315
  } };
690
1316
  return pushEventOptions;
691
1317
  }
@@ -722,7 +1348,7 @@ var AbstractDatabaseService = class {
722
1348
  this.logger.debug({
723
1349
  query,
724
1350
  vars,
725
- Category: "spooky-client::Database::query"
1351
+ Category: "sp00ky-client::Database::query"
726
1352
  }, "Executing query");
727
1353
  const result = await this.client.query(query, vars);
728
1354
  const duration = performance.now() - startTime;
@@ -737,7 +1363,7 @@ var AbstractDatabaseService = class {
737
1363
  this.logger.trace({
738
1364
  query,
739
1365
  result,
740
- Category: "spooky-client::Database::query"
1366
+ Category: "sp00ky-client::Database::query"
741
1367
  }, "Query executed successfully");
742
1368
  } catch (err) {
743
1369
  const duration = performance.now() - startTime;
@@ -753,7 +1379,7 @@ var AbstractDatabaseService = class {
753
1379
  query,
754
1380
  vars,
755
1381
  err,
756
- Category: "spooky-client::Database::query"
1382
+ Category: "sp00ky-client::Database::query"
757
1383
  }, "Query execution failed");
758
1384
  reject(err);
759
1385
  }
@@ -765,7 +1391,7 @@ var AbstractDatabaseService = class {
765
1391
  return query.extract(raw);
766
1392
  }
767
1393
  async close() {
768
- this.logger.info({ Category: "spooky-client::Database::close" }, "Closing database connection");
1394
+ this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
769
1395
  await this.client.close();
770
1396
  }
771
1397
  };
@@ -945,7 +1571,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
945
1571
  type,
946
1572
  phase,
947
1573
  service: "surrealdb:local",
948
- Category: "spooky-client::LocalDatabaseService::diagnostics"
1574
+ Category: "sp00ky-client::LocalDatabaseService::diagnostics"
949
1575
  }, `Local SurrealDB diagnostics captured ${type}:${phase}`);
950
1576
  })
951
1577
  }), logger, events);
@@ -956,38 +1582,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
956
1582
  }
957
1583
  async connect() {
958
1584
  const { namespace, database } = this.getConfig();
1585
+ const store = this.getConfig().store ?? "memory";
1586
+ const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
959
1587
  this.logger.info({
960
1588
  namespace,
961
1589
  database,
962
- Category: "spooky-client::LocalDatabaseService::connect"
1590
+ storeUrl,
1591
+ Category: "sp00ky-client::LocalDatabaseService::connect"
963
1592
  }, "Connecting to local database");
1593
+ this.registerUnloadClose();
964
1594
  try {
965
- const storeUrl = (this.getConfig().store ?? "memory") === "memory" ? "mem://" : "indxdb://spooky";
966
- this.logger.debug({
967
- storeUrl,
968
- Category: "spooky-client::LocalDatabaseService::connect"
969
- }, "[LocalDatabaseService] Calling client.connect");
970
- await this.client.connect(storeUrl, {});
971
- this.logger.debug({
972
- namespace,
973
- database,
974
- Category: "spooky-client::LocalDatabaseService::connect"
975
- }, "[LocalDatabaseService] client.connect returned. Calling client.use");
976
- await this.client.use({
977
- namespace,
978
- database
979
- });
980
- this.logger.debug({ Category: "spooky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
981
- this.logger.info({ Category: "spooky-client::LocalDatabaseService::connect" }, "Connected to local database");
1595
+ await this.openStore(storeUrl, namespace, database);
1596
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
1597
+ return;
982
1598
  } catch (err) {
983
- this.logger.error({
1599
+ if (store === "memory" || !isLocalStoreOpenError(err)) {
1600
+ this.logger.error({
1601
+ err,
1602
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1603
+ }, "Failed to connect to local database");
1604
+ throw err;
1605
+ }
1606
+ this.logger.warn({
984
1607
  err,
985
- Category: "spooky-client::LocalDatabaseService::connect"
986
- }, "Failed to connect to local database");
987
- throw err;
1608
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1609
+ }, "Local IndexedDB store failed to open; retrying before clearing");
1610
+ }
1611
+ for (let attempt = 1; attempt <= 2; attempt++) {
1612
+ try {
1613
+ await this.client.close();
1614
+ } catch {}
1615
+ await delay(150 * attempt);
1616
+ try {
1617
+ await this.openStore(storeUrl, namespace, database);
1618
+ this.logger.info({
1619
+ attempt,
1620
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1621
+ }, "Connected to local database on retry (cache preserved)");
1622
+ return;
1623
+ } catch (retryErr) {
1624
+ this.logger.warn({
1625
+ err: retryErr,
1626
+ attempt,
1627
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1628
+ }, "Local store retry failed");
1629
+ }
1630
+ }
1631
+ try {
1632
+ await this.client.close();
1633
+ } catch {}
1634
+ await dropLocalIndexedDbStores(this.logger);
1635
+ try {
1636
+ await this.openStore(storeUrl, namespace, database);
1637
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
1638
+ } catch (retryErr) {
1639
+ this.logger.error({
1640
+ err: retryErr,
1641
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1642
+ }, "Local store still failing after clear; falling back to in-memory");
1643
+ try {
1644
+ await this.client.close();
1645
+ } catch {}
1646
+ await this.openStore("mem://", namespace, database);
1647
+ this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
988
1648
  }
989
1649
  }
990
- };
1650
+ unloadCloseRegistered = false;
1651
+ /**
1652
+ * Close the local DB on page unload so the SurrealDB-WASM worker releases its
1653
+ * IndexedDB connection cleanly. Without this, the previous page's connection
1654
+ * lingers; the next load's `client.connect` opens the store but the first
1655
+ * write transaction in `client.use` hits an "IndexedDB error" — which then
1656
+ * (mis)triggered the corrupt-store recovery and WIPED the cache on every
1657
+ * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
1658
+ * unload signal (fires on bfcache + normal navigation); `close()` is async but
1659
+ * the WASM worker initiates the IndexedDB connection teardown synchronously.
1660
+ */
1661
+ registerUnloadClose() {
1662
+ if (this.unloadCloseRegistered || typeof window === "undefined") return;
1663
+ this.unloadCloseRegistered = true;
1664
+ const close = () => {
1665
+ try {
1666
+ this.client.close();
1667
+ } catch {}
1668
+ };
1669
+ window.addEventListener("pagehide", close);
1670
+ window.addEventListener("beforeunload", close);
1671
+ }
1672
+ async openStore(storeUrl, namespace, database) {
1673
+ this.logger.debug({
1674
+ storeUrl,
1675
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1676
+ }, "[LocalDatabaseService] Calling client.connect");
1677
+ await this.client.connect(storeUrl, {});
1678
+ this.logger.debug({
1679
+ namespace,
1680
+ database,
1681
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1682
+ }, "[LocalDatabaseService] client.connect returned. Calling client.use");
1683
+ await this.client.use({
1684
+ namespace,
1685
+ database
1686
+ });
1687
+ this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
1688
+ }
1689
+ };
1690
+ function delay(ms) {
1691
+ return new Promise((resolve) => setTimeout(resolve, ms));
1692
+ }
1693
+ /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
1694
+ * store can't be opened (corrupt / version-incompatible / blocked). Exported
1695
+ * for unit testing the error-message match. */
1696
+ function isLocalStoreOpenError(err) {
1697
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
1698
+ return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
1699
+ }
1700
+ /** Best-effort delete of this client's IndexedDB store(s). The persistent local
1701
+ * DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
1702
+ * IndexedDB databases whose names include `sp00ky`. Resolves even on
1703
+ * error/blocked so startup can proceed. No-op outside a browser. */
1704
+ async function dropLocalIndexedDbStores(logger) {
1705
+ if (typeof indexedDB === "undefined") return;
1706
+ const remove = (name) => new Promise((resolve) => {
1707
+ try {
1708
+ const req = indexedDB.deleteDatabase(name);
1709
+ req.onsuccess = () => resolve();
1710
+ req.onerror = () => resolve();
1711
+ req.onblocked = () => resolve();
1712
+ } catch {
1713
+ resolve();
1714
+ }
1715
+ });
1716
+ try {
1717
+ let names = [];
1718
+ if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
1719
+ if (names.length === 0) names = ["sp00ky"];
1720
+ await Promise.all(names.map(remove));
1721
+ logger.info({
1722
+ names,
1723
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1724
+ }, "Cleared local IndexedDB store(s)");
1725
+ } catch (e) {
1726
+ logger.warn({
1727
+ err: e,
1728
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1729
+ }, "Failed to enumerate/clear IndexedDB; proceeding anyway");
1730
+ }
1731
+ }
991
1732
 
992
1733
  //#endregion
993
1734
  //#region src/services/database/remote.ts
@@ -1003,7 +1744,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1003
1744
  type,
1004
1745
  phase,
1005
1746
  service: "surrealdb:remote",
1006
- Category: "spooky-client::RemoteDatabaseService::diagnostics"
1747
+ Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
1007
1748
  }, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
1008
1749
  }) }), logger, events);
1009
1750
  this.config = config;
@@ -1018,7 +1759,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1018
1759
  endpoint,
1019
1760
  namespace,
1020
1761
  database,
1021
- Category: "spooky-client::RemoteDatabaseService::connect"
1762
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
1022
1763
  }, "Connecting to remote database");
1023
1764
  try {
1024
1765
  await this.client.connect(endpoint);
@@ -1027,18 +1768,18 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1027
1768
  database
1028
1769
  });
1029
1770
  if (token) {
1030
- this.logger.debug({ Category: "spooky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
1771
+ this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
1031
1772
  await this.client.authenticate(token);
1032
1773
  }
1033
- this.logger.info({ Category: "spooky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
1774
+ this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
1034
1775
  } catch (err) {
1035
1776
  this.logger.error({
1036
1777
  err,
1037
- Category: "spooky-client::RemoteDatabaseService::connect"
1778
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
1038
1779
  }, "Failed to connect to remote database");
1039
1780
  throw err;
1040
1781
  }
1041
- } else this.logger.warn({ Category: "spooky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
1782
+ } else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
1042
1783
  }
1043
1784
  async signin(params) {
1044
1785
  return this.client.signin(params);
@@ -1056,70 +1797,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1056
1797
 
1057
1798
  //#endregion
1058
1799
  //#region src/services/logger/index.ts
1059
- createContextKey("Category");
1060
- function mapLevelToSeverityNumber(level) {
1061
- switch (level) {
1062
- case "trace": return 1;
1063
- case "debug": return 5;
1064
- case "info": return 9;
1065
- case "warn": return 13;
1066
- case "error": return 17;
1067
- case "fatal": return 21;
1068
- default: return 9;
1069
- }
1070
- }
1071
- function createLogger(level = "info", otelEndpoint) {
1800
+ function createLogger(level = "info", transmit) {
1072
1801
  const browserConfig = {
1073
1802
  asObject: true,
1074
1803
  write: (o) => {
1075
1804
  console.log(JSON.stringify(o));
1076
1805
  }
1077
1806
  };
1078
- if (otelEndpoint) {
1079
- const loggerProvider = new LoggerProvider({
1080
- resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "spooky-client" }),
1081
- processors: [new BatchLogRecordProcessor(new OTLPLogExporter({ url: otelEndpoint }))]
1082
- });
1083
- const otelLogger = {};
1084
- const getOtelLogger = (category) => {
1085
- if (!otelLogger[category]) otelLogger[category] = loggerProvider.getLogger(category);
1086
- return otelLogger[category];
1087
- };
1088
- browserConfig.transmit = {
1089
- level,
1090
- send: (levelLabel, logEvent) => {
1091
- try {
1092
- const messages = [...logEvent.messages];
1093
- const severityNumber = mapLevelToSeverityNumber(levelLabel);
1094
- let body = "";
1095
- const msg = messages.pop();
1096
- if (typeof msg === "string") body = msg;
1097
- else if (msg) body = JSON.stringify(msg);
1098
- let category = "spooky-client::unknown";
1099
- const attributes = {};
1100
- for (const msg of messages) if (typeof msg === "object") {
1101
- if (msg.Category) {
1102
- category = msg.Category;
1103
- delete msg.Category;
1104
- }
1105
- Object.assign(attributes, msg);
1106
- }
1107
- getOtelLogger(category).emit({
1108
- severityNumber,
1109
- severityText: levelLabel.toUpperCase(),
1110
- body,
1111
- attributes: {
1112
- ...logEvent.bindings[0],
1113
- ...attributes
1114
- },
1115
- timestamp: new Date(logEvent.ts)
1116
- });
1117
- } catch (e) {
1118
- console.warn("Failed to transmit log to OTEL endpoint", e);
1119
- }
1120
- }
1121
- };
1122
- }
1807
+ if (transmit) browserConfig.transmit = transmit;
1123
1808
  return pino({
1124
1809
  level,
1125
1810
  browser: browserConfig
@@ -1144,25 +1829,25 @@ var LocalMigrator = class {
1144
1829
  const hash = await sha1(schemaSurql);
1145
1830
  const { database } = this.localDb.getConfig();
1146
1831
  if (await this.isSchemaUpToDate(hash)) {
1147
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
1832
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
1148
1833
  return;
1149
1834
  }
1150
1835
  await this.recreateDatabase(database);
1151
- const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _spooky_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _spooky_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _spooky_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _spooky_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
1836
+ 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 ";
1152
1837
  const statements = this.splitStatements(fullSchema);
1153
1838
  for (let i = 0; i < statements.length; i++) {
1154
1839
  const statement = statements[i];
1155
1840
  const cleanStatement = statement.replace(/--.*/g, "").trim();
1156
1841
  if (cleanStatement.toUpperCase().startsWith("DEFINE INDEX")) {
1157
- this.logger.warn({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
1842
+ this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
1158
1843
  continue;
1159
1844
  }
1160
1845
  try {
1161
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
1846
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
1162
1847
  await this.localDb.query(statement);
1163
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
1848
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
1164
1849
  } catch (e) {
1165
- this.logger.error({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
1850
+ this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
1166
1851
  throw e;
1167
1852
  }
1168
1853
  }
@@ -1170,22 +1855,22 @@ var LocalMigrator = class {
1170
1855
  }
1171
1856
  async isSchemaUpToDate(hash) {
1172
1857
  try {
1173
- const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`);
1858
+ const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
1174
1859
  return lastSchemaRecord?.hash === hash;
1175
- } catch (error) {
1860
+ } catch (_error) {
1176
1861
  return false;
1177
1862
  }
1178
1863
  }
1179
1864
  async recreateDatabase(database) {
1180
1865
  try {
1181
- await this.localDb.query(`DEFINE DATABASE _spooky_temp;`);
1182
- } catch (e) {}
1866
+ await this.localDb.query(`DEFINE DATABASE _00_temp;`);
1867
+ } catch (_e) {}
1183
1868
  try {
1184
1869
  await this.localDb.query(`
1185
- USE DB _spooky_temp;
1870
+ USE DB _00_temp;
1186
1871
  REMOVE DATABASE ${database};
1187
1872
  `);
1188
- } catch (e) {}
1873
+ } catch (_e) {}
1189
1874
  await this.localDb.query(`
1190
1875
  DEFINE DATABASE ${database};
1191
1876
  USE DB ${database};
@@ -1243,7 +1928,7 @@ var LocalMigrator = class {
1243
1928
  return statements;
1244
1929
  }
1245
1930
  async createHashRecord(hash) {
1246
- await this.localDb.query(`UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
1931
+ await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
1247
1932
  }
1248
1933
  };
1249
1934
 
@@ -1264,13 +1949,15 @@ function createSyncQueueEventSystem() {
1264
1949
  const SyncEventTypes = {
1265
1950
  QueryUpdated: "SYNC_QUERY_UPDATED",
1266
1951
  RemoteDataIngested: "SYNC_REMOTE_DATA_INGESTED",
1267
- MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK"
1952
+ MutationRolledBack: "SYNC_MUTATION_ROLLED_BACK",
1953
+ SyncHealthChanged: "SYNC_HEALTH_CHANGED"
1268
1954
  };
1269
1955
  function createSyncEventSystem() {
1270
1956
  return createEventSystem([
1271
1957
  SyncEventTypes.QueryUpdated,
1272
1958
  SyncEventTypes.RemoteDataIngested,
1273
- SyncEventTypes.MutationRolledBack
1959
+ SyncEventTypes.MutationRolledBack,
1960
+ SyncEventTypes.SyncHealthChanged
1274
1961
  ]);
1275
1962
  }
1276
1963
 
@@ -1335,7 +2022,7 @@ var UpQueue = class {
1335
2022
  this.logger.error({
1336
2023
  error,
1337
2024
  event,
1338
- Category: "spooky-client::UpQueue::next"
2025
+ Category: "sp00ky-client::UpQueue::next"
1339
2026
  }, "Network error processing mutation, re-queuing");
1340
2027
  this.queue.unshift(event);
1341
2028
  throw error;
@@ -1343,7 +2030,7 @@ var UpQueue = class {
1343
2030
  this.logger.error({
1344
2031
  error,
1345
2032
  event,
1346
- Category: "spooky-client::UpQueue::next"
2033
+ Category: "sp00ky-client::UpQueue::next"
1347
2034
  }, "Application error processing mutation, rolling back");
1348
2035
  try {
1349
2036
  await this.removeEventFromDatabase(event.mutation_id);
@@ -1351,7 +2038,7 @@ var UpQueue = class {
1351
2038
  this.logger.error({
1352
2039
  error: removeError,
1353
2040
  event,
1354
- Category: "spooky-client::UpQueue::next"
2041
+ Category: "sp00ky-client::UpQueue::next"
1355
2042
  }, "Failed to remove rolled-back mutation from database");
1356
2043
  }
1357
2044
  if (onRollback) try {
@@ -1360,7 +2047,7 @@ var UpQueue = class {
1360
2047
  this.logger.error({
1361
2048
  error: rollbackError,
1362
2049
  event,
1363
- Category: "spooky-client::UpQueue::next"
2050
+ Category: "sp00ky-client::UpQueue::next"
1364
2051
  }, "Rollback handler failed");
1365
2052
  }
1366
2053
  this._events.addEvent({
@@ -1375,7 +2062,7 @@ var UpQueue = class {
1375
2062
  this.logger.error({
1376
2063
  error,
1377
2064
  event,
1378
- Category: "spooky-client::UpQueue::next"
2065
+ Category: "sp00ky-client::UpQueue::next"
1379
2066
  }, "Failed to remove mutation from database after successful processing");
1380
2067
  }
1381
2068
  this._events.addEvent({
@@ -1389,7 +2076,7 @@ var UpQueue = class {
1389
2076
  }
1390
2077
  async loadFromDatabase() {
1391
2078
  try {
1392
- const [records] = await this.local.query(`SELECT * FROM _spooky_pending_mutations ORDER BY created_at ASC`);
2079
+ const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
1393
2080
  this.queue = records.map((r) => {
1394
2081
  switch (r.mutationType) {
1395
2082
  case "create": return {
@@ -1415,7 +2102,7 @@ var UpQueue = class {
1415
2102
  this.logger.warn({
1416
2103
  mutationType: r.mutationType,
1417
2104
  record: r,
1418
- Category: "spooky-client::UpQueue::loadFromDatabase"
2105
+ Category: "sp00ky-client::UpQueue::loadFromDatabase"
1419
2106
  }, "Unknown mutation type");
1420
2107
  return null;
1421
2108
  }
@@ -1423,7 +2110,7 @@ var UpQueue = class {
1423
2110
  } catch (error) {
1424
2111
  this.logger.error({
1425
2112
  error,
1426
- Category: "spooky-client::UpQueue::loadFromDatabase"
2113
+ Category: "sp00ky-client::UpQueue::loadFromDatabase"
1427
2114
  }, "Failed to load pending mutations from database");
1428
2115
  }
1429
2116
  }
@@ -1464,7 +2151,7 @@ var DownQueue = class {
1464
2151
  this.logger.error({
1465
2152
  error,
1466
2153
  event,
1467
- Category: "spooky-client::DownQueue::next"
2154
+ Category: "sp00ky-client::DownQueue::next"
1468
2155
  }, "Failed to process query");
1469
2156
  this.queue.unshift(event);
1470
2157
  throw error;
@@ -1479,8 +2166,8 @@ var ArraySyncer = class {
1479
2166
  remoteArray;
1480
2167
  needsSort = false;
1481
2168
  constructor(localArray, remoteArray) {
1482
- this.remoteArray = remoteArray.sort((a, b) => a[0].localeCompare(b[0]));
1483
- this.localArray = localArray.sort((a, b) => a[0].localeCompare(b[0]));
2169
+ this.remoteArray = remoteArray.toSorted((a, b) => a[0].localeCompare(b[0]));
2170
+ this.localArray = localArray.toSorted((a, b) => a[0].localeCompare(b[0]));
1484
2171
  }
1485
2172
  /**
1486
2173
  * Inserts an item into the local array
@@ -1516,7 +2203,6 @@ var ArraySyncer = class {
1516
2203
  this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
1517
2204
  this.needsSort = false;
1518
2205
  }
1519
- console.log("xxxx555", this.localArray, this.remoteArray);
1520
2206
  return diffRecordVersionArray(this.localArray, this.remoteArray);
1521
2207
  }
1522
2208
  };
@@ -1547,14 +2233,12 @@ function diffRecordVersionArray(local, remote) {
1547
2233
  };
1548
2234
  }
1549
2235
  function createDiffFromDbOp(op, recordId, version, versions) {
1550
- if (op !== "DELETE") {
1551
- const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
1552
- if (old && old[1] >= version) return {
1553
- added: [],
1554
- updated: [],
1555
- removed: []
1556
- };
1557
- }
2236
+ const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
2237
+ if (old && old[1] >= version) return {
2238
+ added: [],
2239
+ updated: [],
2240
+ removed: []
2241
+ };
1558
2242
  if (op === "CREATE") return {
1559
2243
  added: [{
1560
2244
  id: recordId,
@@ -1577,6 +2261,98 @@ function createDiffFromDbOp(op, recordId, version, versions) {
1577
2261
  removed: [recordId]
1578
2262
  };
1579
2263
  }
2264
+ /**
2265
+ * Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
2266
+ * poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
2267
+ * deliveries; 500ms is aggressive enough to feel real-time on the
2268
+ * happy path while keeping the per-session query load bounded.
2269
+ */
2270
+ const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
2271
+ /**
2272
+ * Build the SurrealQL select that powers both the initial-fetch and
2273
+ * the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
2274
+ * predicate excludes subquery entries (rows with `parent_rel` set)
2275
+ * because the client's `RecordVersionArray` only tracks primary rows;
2276
+ * including subquery rows would surface them as spurious "added"
2277
+ * diffs every tick.
2278
+ */
2279
+ function buildListRefSelect(table) {
2280
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
2281
+ }
2282
+ /**
2283
+ * Build the SurrealQL select for a query's SUBQUERY child edges — the
2284
+ * mirror of {@link buildListRefSelect}. `.related()` queries register a
2285
+ * correlated subquery; the SSP materializes each matched child as a
2286
+ * `_00_list_ref` edge tagged with `parent`/`parent_rel` (see
2287
+ * `apps/ssp` edge writer). `parent IS NONE` (the primary select) drops
2288
+ * these, so their bodies never reach the local cache and a cold-reload
2289
+ * re-materialization of the correlated surql yields empty related
2290
+ * fields. This `parent IS NOT NONE` variant pulls the child `out`+`version`
2291
+ * pairs (any nesting depth) so we can sync their bodies into the local
2292
+ * store SEPARATELY from the primary window array.
2293
+ */
2294
+ function buildSubqueryListRefSelect(table) {
2295
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
2296
+ }
2297
+ /**
2298
+ * Resolve the effective list-ref poll interval. Negative or zero
2299
+ * values fall back to the default — accepting them would either
2300
+ * disable polling silently or busy-loop the event loop.
2301
+ */
2302
+ function resolveListRefPollInterval(opt) {
2303
+ if (typeof opt !== "number" || !Number.isFinite(opt) || opt <= 0) return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
2304
+ return opt;
2305
+ }
2306
+ /**
2307
+ * Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
2308
+ * events, no poll-detected list_ref changes) coasts up to this cadence;
2309
+ * the existing healthy-LIVE safety net runs at the same 5s, so this keeps
2310
+ * the worst-case catch-up latency for a missed cross-session change at the
2311
+ * cadence the codebase already treats as acceptable.
2312
+ */
2313
+ const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
2314
+ /**
2315
+ * Adaptive poll delay: stay at the responsive `baseIntervalMs` while
2316
+ * changes are arriving, and exponentially back off toward `maxIntervalMs`
2317
+ * while the `_00_list_ref` is quiet.
2318
+ *
2319
+ * `idleStreak` is the count of consecutive poll cycles that observed *no*
2320
+ * change. `Sp00kySync` resets it to 0 whenever a poll detects a real
2321
+ * remoteArray change OR a LIVE event lands, so any activity snaps the poll
2322
+ * straight back to `baseIntervalMs`. A streak of 0 (something just
2323
+ * happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
2324
+ *
2325
+ * This replaces {@link nextPollDelayMs}: the old helper slowed the poll
2326
+ * only while LIVE was *delivering*, but the cross-session LIVE-permission
2327
+ * gap means LIVE frequently never fires here, so it left a fully idle page
2328
+ * polling every `base` ms forever (the "continuous queries while idle"
2329
+ * symptom). Backing off on observed idleness instead covers the
2330
+ * LIVE-healthy case for free (LIVE applies the change → the next poll sees
2331
+ * nothing new → the streak grows → it backs off).
2332
+ */
2333
+ function listRefPollDelayMs(args) {
2334
+ const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
2335
+ const cap = Math.max(baseIntervalMs, maxIntervalMs);
2336
+ if (idleStreak <= 0) return baseIntervalMs;
2337
+ const exponent = Math.min(idleStreak, 30);
2338
+ return Math.min(baseIntervalMs * 2 ** exponent, cap);
2339
+ }
2340
+ /**
2341
+ * Order-insensitive equality for two `RecordVersionArray`s (each a list of
2342
+ * `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
2343
+ * BY`, so row order can differ between polls without anything having
2344
+ * actually changed — comparing as an id→version map avoids false
2345
+ * "changed" verdicts that would defeat the idle backoff. Record ids are
2346
+ * unique within a query's list_ref, so a map is a faithful representation.
2347
+ */
2348
+ function recordVersionArraysEqual(a, b) {
2349
+ if (a === b) return true;
2350
+ if (a.length !== b.length) return false;
2351
+ const byId = /* @__PURE__ */ new Map();
2352
+ for (const [id, version] of a) byId.set(id, version);
2353
+ for (const [id, version] of b) if (byId.get(id) !== version) return false;
2354
+ return true;
2355
+ }
1580
2356
 
1581
2357
  //#endregion
1582
2358
  //#region src/modules/sync/engine.ts
@@ -1584,7 +2360,7 @@ function createDiffFromDbOp(op, recordId, version, versions) {
1584
2360
  * SyncEngine handles the core sync operations: fetching remote records,
1585
2361
  * caching them locally, and ingesting into DBSP.
1586
2362
  *
1587
- * This is extracted from SpookySync to separate "how to sync" from "when to sync".
2363
+ * This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
1588
2364
  */
1589
2365
  var SyncEngine = class {
1590
2366
  logger;
@@ -1593,7 +2369,7 @@ var SyncEngine = class {
1593
2369
  this.remote = remote;
1594
2370
  this.cache = cache;
1595
2371
  this.schema = schema;
1596
- this.logger = logger.child({ service: "SpookySync:SyncEngine" });
2372
+ this.logger = logger.child({ service: "Sp00kySync:SyncEngine" });
1597
2373
  }
1598
2374
  /**
1599
2375
  * Sync missing/updated/removed records between local and remote.
@@ -1606,33 +2382,42 @@ var SyncEngine = class {
1606
2382
  added,
1607
2383
  updated,
1608
2384
  removed,
1609
- Category: "spooky-client::SyncEngine::syncRecords"
2385
+ Category: "sp00ky-client::SyncEngine::syncRecords"
1610
2386
  }, "SyncEngine.syncRecords diff");
1611
- if (removed.length > 0) await this.handleRemovedRecords(removed);
1612
- const idsToFetch = [...added, ...updated].map((x) => x.id);
1613
- if (idsToFetch.length === 0) return;
1614
- const [remoteResults] = await this.remote.query("SELECT (SELECT * FROM ONLY <record>$parent.id) AS record, (SELECT version FROM ONLY _spooky_version WHERE record_id = <record>$parent.id)['version'] as spooky_rv FROM $idsToFetch", { idsToFetch });
1615
- console.log("remoteResults>", remoteResults);
2387
+ let stillRemoteIds = [];
2388
+ if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
2389
+ const toFetch = [...added, ...updated];
2390
+ const idsToFetch = toFetch.map((x) => x.id);
2391
+ if (idsToFetch.length === 0) return {
2392
+ remoteFetchMs: 0,
2393
+ stillRemoteIds
2394
+ };
2395
+ const versionMap = /* @__PURE__ */ new Map();
2396
+ for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
2397
+ const remoteFetchStart = performance.now();
2398
+ const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
2399
+ const remoteFetchMs = performance.now() - remoteFetchStart;
1616
2400
  const cacheBatch = [];
1617
- for (const { spooky_rv, record } of remoteResults) {
2401
+ for (const record of remoteResults) {
1618
2402
  if (!record?.id) {
1619
2403
  this.logger.warn({
1620
2404
  record,
1621
2405
  idsToFetch,
1622
- Category: "spooky-client::SyncEngine::syncRecords"
1623
- }, "Remote record has no id. Skipping record");
2406
+ Category: "sp00ky-client::SyncEngine::syncRecords"
2407
+ }, "Remote record has no id (possibly deleted). Skipping record");
1624
2408
  continue;
1625
2409
  }
1626
2410
  const fullId = encodeRecordId(record.id);
1627
2411
  const table = record.id.table.toString();
1628
2412
  const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
2413
+ const version = versionMap.get(fullId) ?? 0;
1629
2414
  const localVersion = this.cache.lookup(fullId);
1630
- if (localVersion && spooky_rv <= localVersion) {
2415
+ if (localVersion && version <= localVersion) {
1631
2416
  this.logger.info({
1632
2417
  recordId: fullId,
1633
- version: spooky_rv,
2418
+ version,
1634
2419
  localVersion,
1635
- Category: "spooky-client::SyncEngine::syncRecords"
2420
+ Category: "sp00ky-client::SyncEngine::syncRecords"
1636
2421
  }, "Local version is higher than remote version. Skipping record");
1637
2422
  continue;
1638
2423
  }
@@ -1642,37 +2427,59 @@ var SyncEngine = class {
1642
2427
  table,
1643
2428
  op: isAdded ? "CREATE" : "UPDATE",
1644
2429
  record: cleanedRecord,
1645
- version: spooky_rv
2430
+ version
1646
2431
  });
1647
2432
  }
1648
2433
  if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
1649
2434
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
2435
+ return {
2436
+ remoteFetchMs,
2437
+ stillRemoteIds
2438
+ };
1650
2439
  }
1651
2440
  /**
1652
2441
  * Handle records that exist locally but not in remote array.
2442
+ *
2443
+ * "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
2444
+ * longer references a record that exists locally. That can mean the row
2445
+ * was genuinely deleted upstream — but it can also be a benign race
2446
+ * (e.g. a record we just created hasn't propagated into the SSP's
2447
+ * incantation list yet). Before deleting locally we verify against
2448
+ * upstream SurrealDB: if the row still exists there, skip the delete.
2449
+ *
2450
+ * On verification failure we skip deletion too. Losing a stale local
2451
+ * row to a later sync round is recoverable; deleting a fresh row that
2452
+ * upstream still has is not.
1653
2453
  */
1654
2454
  async handleRemovedRecords(removed) {
1655
2455
  this.logger.debug({
1656
2456
  removed: removed.map((r) => r.toString()),
1657
- Category: "spooky-client::SyncEngine::handleRemovedRecords"
2457
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1658
2458
  }, "Checking removed records");
1659
- let existingRemoteIds = /* @__PURE__ */ new Set();
2459
+ let existingRemoteIds;
1660
2460
  try {
1661
- const [existingRemote] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
1662
- existingRemoteIds = new Set(existingRemote.map((r) => encodeRecordId(r.id)));
1663
- } catch {
1664
- this.logger.debug({ Category: "spooky-client::SyncEngine::handleRemovedRecords" }, "Remote existence check failed, proceeding with deletion");
2461
+ const [existing] = await this.remote.query("SELECT VALUE id FROM $ids", { ids: removed });
2462
+ existingRemoteIds = new Set((existing ?? []).filter((id) => id != null).map((id) => encodeRecordId(id)));
2463
+ } catch (err) {
2464
+ this.logger.warn({
2465
+ err,
2466
+ removed: removed.map((r) => r.toString()),
2467
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
2468
+ }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
2469
+ return [];
1665
2470
  }
2471
+ const stillRemoteIds = [];
1666
2472
  for (const recordId of removed) {
1667
2473
  const recordIdStr = encodeRecordId(recordId);
1668
2474
  if (!existingRemoteIds.has(recordIdStr)) {
1669
2475
  this.logger.debug({
1670
2476
  recordId: recordIdStr,
1671
- Category: "spooky-client::SyncEngine::handleRemovedRecords"
2477
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1672
2478
  }, "Deleting confirmed removed record");
1673
2479
  await this.cache.delete(recordId.table.name, recordIdStr);
1674
- }
2480
+ } else stillRemoteIds.push(recordIdStr);
1675
2481
  }
2482
+ return stillRemoteIds;
1676
2483
  }
1677
2484
  };
1678
2485
 
@@ -1685,13 +2492,14 @@ var SyncEngine = class {
1685
2492
  var SyncScheduler = class {
1686
2493
  isSyncingUp = false;
1687
2494
  isSyncingDown = false;
1688
- constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback) {
2495
+ constructor(upQueue, downQueue, onProcessUp, onProcessDown, logger, onRollback, onSyncOutcome) {
1689
2496
  this.upQueue = upQueue;
1690
2497
  this.downQueue = downQueue;
1691
2498
  this.onProcessUp = onProcessUp;
1692
2499
  this.onProcessDown = onProcessDown;
1693
2500
  this.logger = logger;
1694
2501
  this.onRollback = onRollback;
2502
+ this.onSyncOutcome = onSyncOutcome;
1695
2503
  }
1696
2504
  async init() {
1697
2505
  await this.upQueue.loadFromDatabase();
@@ -1716,8 +2524,19 @@ var SyncScheduler = class {
1716
2524
  async syncUp() {
1717
2525
  if (this.isSyncingUp) return;
1718
2526
  this.isSyncingUp = true;
2527
+ let processedAny = false;
1719
2528
  try {
1720
- while (this.upQueue.size > 0) await this.upQueue.next(this.onProcessUp, this.onRollback);
2529
+ while (this.upQueue.size > 0) {
2530
+ await this.upQueue.next(this.onProcessUp, this.onRollback);
2531
+ processedAny = true;
2532
+ }
2533
+ if (processedAny) this.onSyncOutcome?.(true);
2534
+ } catch (error) {
2535
+ this.onSyncOutcome?.(false, error);
2536
+ this.logger.debug({
2537
+ error,
2538
+ Category: "sp00ky-client::SyncScheduler::syncUp"
2539
+ }, "syncUp halted on a queue error; item re-queued, will retry on next trigger");
1721
2540
  } finally {
1722
2541
  this.isSyncingUp = false;
1723
2542
  this.syncDown();
@@ -1730,11 +2549,20 @@ var SyncScheduler = class {
1730
2549
  if (this.isSyncingDown) return;
1731
2550
  if (this.upQueue.size > 0) return;
1732
2551
  this.isSyncingDown = true;
2552
+ let processedAny = false;
1733
2553
  try {
1734
2554
  while (this.downQueue.size > 0) {
1735
2555
  if (this.upQueue.size > 0) break;
1736
2556
  await this.downQueue.next(this.onProcessDown);
2557
+ processedAny = true;
1737
2558
  }
2559
+ if (processedAny) this.onSyncOutcome?.(true);
2560
+ } catch (error) {
2561
+ this.onSyncOutcome?.(false, error);
2562
+ this.logger.debug({
2563
+ error,
2564
+ Category: "sp00ky-client::SyncScheduler::syncDown"
2565
+ }, "syncDown halted on a queue error; item re-queued, will retry on next trigger");
1738
2566
  } finally {
1739
2567
  this.isSyncingDown = false;
1740
2568
  }
@@ -1744,23 +2572,91 @@ var SyncScheduler = class {
1744
2572
  }
1745
2573
  };
1746
2574
 
2575
+ //#endregion
2576
+ //#region src/modules/ref-tables.ts
2577
+ /**
2578
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
2579
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
2580
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
2581
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
2582
+ */
2583
+ const ANON_USER_ID = "anon";
2584
+ /**
2585
+ * Default ref-storage mode for this client build. Mirrors the SSP's
2586
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
2587
+ * box.
2588
+ */
2589
+ const DEFAULT_REF_MODE = "dedicated";
2590
+ /**
2591
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
2592
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
2593
+ * the id is missing the `user:` prefix or contains characters that
2594
+ * aren't valid in a SurrealDB table identifier — the server-side
2595
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
2596
+ *
2597
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
2598
+ * objects (which only stringify cleanly via `.toString()`), since
2599
+ * `AuthService` passes the record-id object as-is to its subscribers.
2600
+ */
2601
+ function sanitizeUserId(userId) {
2602
+ if (userId === null || userId === void 0) return null;
2603
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
2604
+ if (!asString) return null;
2605
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
2606
+ if (raw.length === 0) return null;
2607
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
2608
+ return raw;
2609
+ }
2610
+ /**
2611
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
2612
+ * back to the global `_00_list_ref` when sanitization fails or in
2613
+ * single mode.
2614
+ */
2615
+ function listRefTableFor(mode, userId) {
2616
+ if (userId === ANON_USER_ID) return "_00_list_ref_anon";
2617
+ if (mode === "single") return "_00_list_ref";
2618
+ const uid = sanitizeUserId(userId);
2619
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
2620
+ }
2621
+
1747
2622
  //#endregion
1748
2623
  //#region src/modules/sync/sync.ts
1749
2624
  /**
1750
- * The main synchronization engine for Spooky.
2625
+ * The main synchronization engine for Sp00ky.
1751
2626
  * Handles the bidirectional synchronization between the local database and the remote backend.
1752
2627
  * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
1753
2628
  * @template S The schema structure type.
1754
2629
  */
1755
- var SpookySync = class {
1756
- clientId = "";
2630
+ var Sp00kySync = class Sp00kySync {
1757
2631
  upQueue;
1758
2632
  downQueue;
1759
2633
  isInit = false;
1760
2634
  logger;
1761
2635
  syncEngine;
2636
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
2637
+ * from `this.events`, which carries Sp00kySync-level events like
2638
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
2639
+ get engineEvents() {
2640
+ return this.syncEngine.events;
2641
+ }
1762
2642
  scheduler;
2643
+ wasDisconnected = false;
1763
2644
  events = createSyncEventSystem();
2645
+ currentUserId = null;
2646
+ refMode = DEFAULT_REF_MODE;
2647
+ anonLiveEnabled;
2648
+ currentLiveQueryUuid = null;
2649
+ liveQueryUnsubscribe = null;
2650
+ listRefPollTimer = null;
2651
+ listRefPollRunning = false;
2652
+ refSyncIntervalMs;
2653
+ listRefIdleStreak = 0;
2654
+ stillRemoteStreaks = /* @__PURE__ */ new Map();
2655
+ lastLiveEventAt = null;
2656
+ _liveRetryCount = 0;
2657
+ get liveRetryCount() {
2658
+ return this._liveRetryCount;
2659
+ }
1764
2660
  get isSyncing() {
1765
2661
  return this.scheduler.isSyncing;
1766
2662
  }
@@ -1775,60 +2671,415 @@ var SpookySync = class {
1775
2671
  this.upQueue.events.unsubscribe(id2);
1776
2672
  };
1777
2673
  }
1778
- constructor(local, remote, cache, dataModule, schema, logger) {
2674
+ degradeAfterFailures;
2675
+ consecutiveSyncFailures = 0;
2676
+ syncHealthStatus = "healthy";
2677
+ lastSyncErrorKind;
2678
+ lastSyncErrorMessage;
2679
+ selfHealTimer = null;
2680
+ selfHealAttempts = 0;
2681
+ static SELF_HEAL_BASE_MS = 2e3;
2682
+ static SELF_HEAL_MAX_MS = 3e4;
2683
+ /** Current sync-health snapshot. */
2684
+ get syncHealth() {
2685
+ return {
2686
+ status: this.syncHealthStatus,
2687
+ consecutiveFailures: this.consecutiveSyncFailures,
2688
+ kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
2689
+ error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0
2690
+ };
2691
+ }
2692
+ /**
2693
+ * Observe sync health. The callback fires immediately with the current
2694
+ * status and again on every healthy↔degraded transition. Returns an
2695
+ * unsubscribe. Mirrors {@link subscribeToPendingMutations}.
2696
+ */
2697
+ subscribeToSyncHealth(cb) {
2698
+ cb(this.syncHealth);
2699
+ const id = this.events.subscribe(SyncEventTypes.SyncHealthChanged, (event) => cb(event.payload));
2700
+ return () => this.events.unsubscribe(id);
2701
+ }
2702
+ emitSyncHealth() {
2703
+ this.events.emit(SyncEventTypes.SyncHealthChanged, this.syncHealth);
2704
+ }
2705
+ /**
2706
+ * Fed by the scheduler once per drained sync round. Individual failures are
2707
+ * absorbed by the queue's retry; only a run of `degradeAfterFailures`
2708
+ * consecutive failures flips the status to `degraded`, and the next clean
2709
+ * round flips it back. No-op when reporting is disabled (`degradeAfterFailures`
2710
+ * is 0).
2711
+ */
2712
+ recordSyncOutcome(ok, error) {
2713
+ if (this.degradeAfterFailures <= 0) return;
2714
+ if (ok) {
2715
+ if (this.consecutiveSyncFailures === 0) return;
2716
+ this.consecutiveSyncFailures = 0;
2717
+ if (this.syncHealthStatus !== "healthy") {
2718
+ this.syncHealthStatus = "healthy";
2719
+ this.lastSyncErrorKind = void 0;
2720
+ this.lastSyncErrorMessage = void 0;
2721
+ this.stopSelfHeal();
2722
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::syncHealth" }, "Sync recovered; health back to healthy");
2723
+ this.emitSyncHealth();
2724
+ }
2725
+ return;
2726
+ }
2727
+ this.consecutiveSyncFailures++;
2728
+ this.lastSyncErrorKind = classifySyncError(error);
2729
+ this.lastSyncErrorMessage = error instanceof Error ? error.message : String(error);
2730
+ if (this.syncHealthStatus !== "degraded" && this.consecutiveSyncFailures >= this.degradeAfterFailures) {
2731
+ this.syncHealthStatus = "degraded";
2732
+ this.logger.warn({
2733
+ consecutiveFailures: this.consecutiveSyncFailures,
2734
+ kind: this.lastSyncErrorKind,
2735
+ error,
2736
+ Category: "sp00ky-client::Sp00kySync::syncHealth"
2737
+ }, "Sync degraded after sustained failures");
2738
+ this.emitSyncHealth();
2739
+ this.startSelfHeal();
2740
+ }
2741
+ }
2742
+ /**
2743
+ * Begin self-heal retries (no-op if already running). Started on the
2744
+ * healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
2745
+ */
2746
+ startSelfHeal() {
2747
+ if (this.selfHealTimer !== null) return;
2748
+ this.selfHealAttempts = 0;
2749
+ this.scheduleSelfHeal();
2750
+ }
2751
+ scheduleSelfHeal() {
2752
+ const delay = Math.min(Sp00kySync.SELF_HEAL_MAX_MS, Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts);
2753
+ this.selfHealTimer = setTimeout(async () => {
2754
+ this.selfHealTimer = null;
2755
+ if (this.syncHealthStatus !== "degraded") return;
2756
+ this.selfHealAttempts++;
2757
+ this.logger.debug({
2758
+ attempt: this.selfHealAttempts,
2759
+ delayMs: delay,
2760
+ Category: "sp00ky-client::Sp00kySync::selfHeal"
2761
+ }, "Self-heal: re-driving sync while degraded");
2762
+ try {
2763
+ if (this.upQueue.size > 0) await this.scheduler.syncUp();
2764
+ else if (this.downQueue.size > 0) await this.scheduler.syncDown();
2765
+ else {
2766
+ const hashes = this.dataModule.getActiveQueryHashes();
2767
+ if (hashes.length > 0) {
2768
+ for (const hash of hashes) this.scheduler.enqueueDownEvent({
2769
+ type: "register",
2770
+ payload: { hash }
2771
+ });
2772
+ await this.scheduler.syncDown();
2773
+ } else {
2774
+ await this.remote.query("RETURN true");
2775
+ this.recordSyncOutcome(true);
2776
+ }
2777
+ }
2778
+ } catch (err) {
2779
+ this.recordSyncOutcome(false, err);
2780
+ }
2781
+ if (this.syncHealthStatus === "degraded") this.scheduleSelfHeal();
2782
+ }, delay);
2783
+ }
2784
+ stopSelfHeal() {
2785
+ if (this.selfHealTimer !== null) {
2786
+ clearTimeout(this.selfHealTimer);
2787
+ this.selfHealTimer = null;
2788
+ }
2789
+ this.selfHealAttempts = 0;
2790
+ }
2791
+ constructor(local, remote, cache, dataModule, schema, logger, options) {
1779
2792
  this.local = local;
1780
2793
  this.remote = remote;
1781
2794
  this.cache = cache;
1782
2795
  this.dataModule = dataModule;
1783
2796
  this.schema = schema;
1784
- this.logger = logger.child({ service: "SpookySync" });
2797
+ this.logger = logger.child({ service: "Sp00kySync" });
1785
2798
  this.upQueue = new UpQueue(this.local, this.logger);
1786
2799
  this.downQueue = new DownQueue(this.local, this.logger);
1787
2800
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
1788
- this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
2801
+ this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this), this.recordSyncOutcome.bind(this));
2802
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
2803
+ this.anonLiveEnabled = options?.anonymousLiveQueries ?? false;
2804
+ this.degradeAfterFailures = Math.max(0, options?.degradeAfterConsecutiveFailures ?? 3);
1789
2805
  }
1790
2806
  /**
1791
2807
  * Initializes the synchronization system.
1792
2808
  * Starts the scheduler and initiates the initial sync cycles.
1793
- * @param clientId The unique identifier for this client instance.
1794
2809
  * @throws Error if already initialized.
1795
2810
  */
1796
- async init(clientId) {
1797
- if (this.isInit) throw new Error("SpookySync is already initialized");
1798
- this.clientId = clientId;
2811
+ async init() {
2812
+ if (this.isInit) throw new Error("Sp00kySync is already initialized");
1799
2813
  this.isInit = true;
1800
2814
  await this.scheduler.init();
1801
- this.scheduler.syncUp();
2815
+ this.subscribeToReconnect();
1802
2816
  this.scheduler.syncUp();
1803
2817
  this.scheduler.syncDown();
1804
- this.startRefLiveQueries();
2818
+ if (this.anonLiveEnabled && !this.currentUserId) {
2819
+ this.startListRefPoll();
2820
+ this.restartRefLiveQuery().catch((err) => {
2821
+ this.logger.debug({
2822
+ err,
2823
+ Category: "sp00ky-client::Sp00kySync::init"
2824
+ }, "Anonymous ref LIVE start failed; relying on periodic poll fallback");
2825
+ });
2826
+ }
2827
+ }
2828
+ /**
2829
+ * Push the authenticated user's record id from the parent client's
2830
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
2831
+ * any) and re-registers it under the new user's dedicated table so
2832
+ * SurrealDB binds the permission rule under the post-flip auth
2833
+ * context. Pass `null` on sign-out.
2834
+ *
2835
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
2836
+ * the SSP when the first query registration arrives, which may be
2837
+ * concurrent with this call. We retry the LIVE registration with a
2838
+ * short backoff so a "table not found" race resolves without
2839
+ * surfacing as a permanent auth-loading hang.
2840
+ */
2841
+ async setCurrentUserId(userId) {
2842
+ if (this.currentUserId === userId) return;
2843
+ this.currentUserId = userId;
2844
+ if (!userId) {
2845
+ if (this.anonLiveEnabled) {
2846
+ this.startListRefPoll();
2847
+ await this.restartRefLiveQuery().catch((err) => {
2848
+ this.logger.debug({
2849
+ err,
2850
+ Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
2851
+ }, "Anonymous ref LIVE restart failed; relying on periodic poll fallback");
2852
+ });
2853
+ return;
2854
+ }
2855
+ await this.killRefLiveQuery();
2856
+ this.stopListRefPoll();
2857
+ return;
2858
+ }
2859
+ this.startListRefPoll();
2860
+ const attemptDelays = [
2861
+ 0,
2862
+ 250,
2863
+ 500,
2864
+ 1e3,
2865
+ 2e3
2866
+ ];
2867
+ for (let i = 0; i < attemptDelays.length; i++) {
2868
+ if (attemptDelays[i] > 0) {
2869
+ this._liveRetryCount++;
2870
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
2871
+ }
2872
+ try {
2873
+ await this.restartRefLiveQuery();
2874
+ return;
2875
+ } catch (err) {
2876
+ this.logger.debug({
2877
+ err,
2878
+ attempt: i + 1,
2879
+ Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
2880
+ }, "Ref LIVE start failed; relying on periodic poll fallback");
2881
+ }
2882
+ }
2883
+ }
2884
+ startListRefPoll() {
2885
+ if (this.listRefPollRunning) return;
2886
+ this.listRefPollRunning = true;
2887
+ this.logger.debug({
2888
+ intervalMs: this.refSyncIntervalMs,
2889
+ Category: "sp00ky-client::Sp00kySync::startListRefPoll"
2890
+ }, "list_ref poll loop started");
2891
+ const schedule = (delayMs) => {
2892
+ this.listRefPollTimer = setTimeout(async () => {
2893
+ if (!this.listRefPollRunning) return;
2894
+ let changed = false;
2895
+ try {
2896
+ changed = await this.pollListRefForActiveQueries();
2897
+ } finally {
2898
+ if (!this.listRefPollRunning) return;
2899
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
2900
+ schedule(listRefPollDelayMs({
2901
+ idleStreak: this.listRefIdleStreak,
2902
+ baseIntervalMs: this.refSyncIntervalMs
2903
+ }));
2904
+ }
2905
+ }, delayMs);
2906
+ };
2907
+ schedule(this.refSyncIntervalMs);
2908
+ }
2909
+ stopListRefPoll() {
2910
+ this.listRefPollRunning = false;
2911
+ if (this.listRefPollTimer !== null) {
2912
+ clearTimeout(this.listRefPollTimer);
2913
+ this.listRefPollTimer = null;
2914
+ }
2915
+ }
2916
+ /**
2917
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
2918
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
2919
+ * to drive the adaptive idle backoff.
2920
+ */
2921
+ async pollListRefForActiveQueries() {
2922
+ const hashes = this.dataModule.getActiveQueryHashes();
2923
+ if (hashes.length === 0) return false;
2924
+ let anyChanged = false;
2925
+ for (const hash of hashes) try {
2926
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
2927
+ } catch (err) {
2928
+ this.logger.debug({
2929
+ err: err?.message ?? err,
2930
+ hash,
2931
+ Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2932
+ }, "Per-query list_ref poll failed");
2933
+ }
2934
+ return anyChanged;
2935
+ }
2936
+ /**
2937
+ * Pull the upstream list_ref entries for `queryHash`, diff them
2938
+ * against the local `remoteArray` cache, sync any added/updated rows
2939
+ * through the SyncEngine, then persist the new remoteArray. This is
2940
+ * the same shape `createRemoteQuery` does for its initial fetch and
2941
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
2942
+ * it on a timer as a fallback for missed LIVE notifications.
2943
+ */
2944
+ async refetchListRefForQuery(queryHash) {
2945
+ const queryState = this.dataModule.getQueryByHash(queryHash);
2946
+ if (!queryState) return false;
2947
+ const listRefTbl = this.listRefTable();
2948
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2949
+ if (!Array.isArray(items)) return false;
2950
+ const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
2951
+ const prevRemote = queryState.config.remoteArray ?? [];
2952
+ const freshIds = new Set(fresh.map(([id]) => id));
2953
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
2954
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
2955
+ if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
2956
+ try {
2957
+ await this.syncQuery(queryHash);
2958
+ } catch (err) {
2959
+ this.logger.info({
2960
+ err: err?.message ?? err,
2961
+ queryHash,
2962
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2963
+ }, "syncQuery failed during poll");
2964
+ }
2965
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
2966
+ this.logger.info({
2967
+ err: err?.message ?? err,
2968
+ queryHash,
2969
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2970
+ }, "Subquery child sync failed during poll");
2971
+ });
2972
+ if (removedIds.length > 0) try {
2973
+ await this.dataModule.notifyQuerySynced(queryHash);
2974
+ } catch (err) {
2975
+ this.logger.info({
2976
+ err: err?.message ?? err,
2977
+ queryHash,
2978
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2979
+ }, "notifyQuerySynced failed during poll-removal re-render");
2980
+ }
2981
+ return changed;
2982
+ }
2983
+ /**
2984
+ * Resolve the current `_00_list_ref` table name for the active auth
2985
+ * context. Public so the `createRemoteQuery` initial-fetch path can
2986
+ * read from the right per-user table.
2987
+ *
2988
+ * Reads the user id from `DataModule` rather than the local mirror,
2989
+ * because `DataModule.setCurrentUserId` runs synchronously from the
2990
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
2991
+ * is async — the userQuery's initial fetch can fire between those
2992
+ * two points and we need the correct table name immediately.
2993
+ */
2994
+ listRefTable() {
2995
+ const userId = this.dataModule.getCurrentUserId();
2996
+ if (userId == null && this.anonLiveEnabled) return listRefTableFor(this.refMode, ANON_USER_ID);
2997
+ return listRefTableFor(this.refMode, userId);
2998
+ }
2999
+ async killRefLiveQuery() {
3000
+ if (this.liveQueryUnsubscribe) {
3001
+ try {
3002
+ this.liveQueryUnsubscribe();
3003
+ } catch {}
3004
+ this.liveQueryUnsubscribe = null;
3005
+ }
3006
+ if (this.currentLiveQueryUuid !== null) {
3007
+ try {
3008
+ await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
3009
+ } catch (err) {
3010
+ this.logger.debug({
3011
+ err,
3012
+ Category: "sp00ky-client::Sp00kySync::killRefLiveQuery"
3013
+ }, "Prior LIVE KILL failed; continuing");
3014
+ }
3015
+ this.currentLiveQueryUuid = null;
3016
+ }
3017
+ }
3018
+ async restartRefLiveQuery() {
3019
+ await this.killRefLiveQuery();
3020
+ await this.startRefLiveQueries();
3021
+ }
3022
+ subscribeToReconnect() {
3023
+ const client = this.remote.getClient();
3024
+ client.subscribe("disconnected", () => {
3025
+ this.wasDisconnected = true;
3026
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
3027
+ });
3028
+ client.subscribe("connected", () => {
3029
+ if (!this.wasDisconnected) return;
3030
+ this.wasDisconnected = false;
3031
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
3032
+ for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
3033
+ type: "register",
3034
+ payload: { hash }
3035
+ });
3036
+ if (this.currentUserId || this.anonLiveEnabled) this.restartRefLiveQuery().catch((err) => {
3037
+ this.logger.debug({
3038
+ err,
3039
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
3040
+ }, "LIVE restart after reconnect failed; relying on poll fallback");
3041
+ });
3042
+ });
1805
3043
  }
1806
3044
  async startRefLiveQueries() {
3045
+ const tableName = this.listRefTable();
1807
3046
  this.logger.debug({
1808
- clientId: this.clientId,
1809
- Category: "spooky-client::SpookySync::startRefLiveQueries"
3047
+ tableName,
3048
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1810
3049
  }, "Starting ref live queries");
1811
- const [queryUuid] = await this.remote.query("LIVE SELECT * FROM _spooky_list_ref");
1812
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
3050
+ const [queryUuid] = await this.remote.query(`LIVE SELECT * FROM ${tableName}`);
3051
+ this.currentLiveQueryUuid = queryUuid;
3052
+ this.liveQueryUnsubscribe = (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
1813
3053
  this.logger.debug({
1814
3054
  message,
1815
- Category: "spooky-client::SpookySync::startRefLiveQueries"
3055
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1816
3056
  }, "Live update received");
1817
3057
  if (message.action === "KILLED") return;
3058
+ if (message.value.parent != null) {
3059
+ this.handleRemoteSubqueryChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
3060
+ this.logger.error({
3061
+ err,
3062
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
3063
+ }, "Error handling remote subquery change");
3064
+ });
3065
+ return;
3066
+ }
1818
3067
  this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
1819
3068
  this.logger.error({
1820
3069
  err,
1821
- Category: "spooky-client::SpookySync::startRefLiveQueries"
3070
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1822
3071
  }, "Error handling remote list ref change");
1823
3072
  });
1824
3073
  });
1825
3074
  }
1826
3075
  async handleRemoteListRefChange(action, queryId, recordId, version) {
3076
+ this.lastLiveEventAt = Date.now();
3077
+ this.listRefIdleStreak = 0;
1827
3078
  const existing = this.dataModule.getQueryById(queryId);
1828
3079
  if (!existing) {
1829
3080
  this.logger.warn({
1830
3081
  queryId: queryId.toString(),
1831
- Category: "spooky-client::SpookySync::handleRemoteListRefChange"
3082
+ Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
1832
3083
  }, "Received remote update for unknown local query");
1833
3084
  return;
1834
3085
  }
@@ -1839,10 +3090,46 @@ var SpookySync = class {
1839
3090
  recordId,
1840
3091
  version,
1841
3092
  localArray,
1842
- Category: "spooky-client::SpookySync::handleRemoteListRefChange"
3093
+ Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
1843
3094
  }, "Live update is being processed");
1844
3095
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
1845
- await this.syncEngine.syncRecords(diff);
3096
+ const hash = extractIdPart(existing.config.id);
3097
+ await this.runSyncForQuery(hash, diff);
3098
+ }
3099
+ /**
3100
+ * Handle a LIVE change to a SUBQUERY child edge (a `_00_list_ref` row with
3101
+ * `parent` set) for a `.related()` query. Unlike primary rows, child rows
3102
+ * must NOT touch the query's `localArray`/`remoteArray`/`rowCount`; we only
3103
+ * keep the child BODY fresh in the local cache so the in-browser SSP's
3104
+ * subquery-table dependency re-materializes the parent view.
3105
+ *
3106
+ * CREATE/UPDATE fetch+upsert the child body. DELETE is intentionally a
3107
+ * no-op: a child leaving this query's set must not delete a body another
3108
+ * query may still show (see `syncSubqueryChildren` deletion-safety note);
3109
+ * a genuine record delete propagates via the normal delete path.
3110
+ */
3111
+ async handleRemoteSubqueryChange(action, queryId, childId, version) {
3112
+ this.lastLiveEventAt = Date.now();
3113
+ this.listRefIdleStreak = 0;
3114
+ if (action === "DELETE") return;
3115
+ const existing = this.dataModule.getQueryById(queryId);
3116
+ if (!existing) return;
3117
+ const item = {
3118
+ id: childId,
3119
+ version
3120
+ };
3121
+ await this.syncEngine.syncRecords(action === "CREATE" ? {
3122
+ added: [item],
3123
+ updated: [],
3124
+ removed: []
3125
+ } : {
3126
+ added: [],
3127
+ updated: [item],
3128
+ removed: []
3129
+ });
3130
+ const key = encodeRecordId(childId);
3131
+ const prev = existing.config.subqueryRemoteArray ?? [];
3132
+ existing.config.subqueryRemoteArray = [...prev.filter(([id]) => id !== key), [key, version]];
1846
3133
  }
1847
3134
  /**
1848
3135
  * Enqueues a 'down' event (from remote to local) for processing.
@@ -1854,11 +3141,10 @@ var SpookySync = class {
1854
3141
  async processUpEvent(event) {
1855
3142
  this.logger.debug({
1856
3143
  event,
1857
- Category: "spooky-client::SpookySync::processUpEvent"
3144
+ Category: "sp00ky-client::Sp00kySync::processUpEvent"
1858
3145
  }, "Processing up event");
1859
- console.log("xx1", event);
1860
3146
  switch (event.type) {
1861
- case "create":
3147
+ case "create": {
1862
3148
  const dataKeys = Object.keys(event.data).map((key) => ({
1863
3149
  key,
1864
3150
  variable: `data_${key}`
@@ -1870,6 +3156,7 @@ var SpookySync = class {
1870
3156
  ...prefixedParams
1871
3157
  });
1872
3158
  break;
3159
+ }
1873
3160
  case "update":
1874
3161
  await this.remote.query(`UPDATE $id MERGE $data`, {
1875
3162
  id: event.record_id,
@@ -1882,7 +3169,7 @@ var SpookySync = class {
1882
3169
  default:
1883
3170
  this.logger.error({
1884
3171
  event,
1885
- Category: "spooky-client::SpookySync::processUpEvent"
3172
+ Category: "sp00ky-client::Sp00kySync::processUpEvent"
1886
3173
  }, "processUpEvent unknown event type");
1887
3174
  return;
1888
3175
  }
@@ -1895,7 +3182,7 @@ var SpookySync = class {
1895
3182
  recordId,
1896
3183
  tableName,
1897
3184
  error: error.message,
1898
- Category: "spooky-client::SpookySync::handleRollback"
3185
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1899
3186
  }, "Rolling back failed mutation");
1900
3187
  switch (event.type) {
1901
3188
  case "create":
@@ -1905,13 +3192,13 @@ var SpookySync = class {
1905
3192
  if (event.beforeRecord) await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
1906
3193
  else this.logger.warn({
1907
3194
  recordId,
1908
- Category: "spooky-client::SpookySync::handleRollback"
3195
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1909
3196
  }, "Cannot rollback update: no beforeRecord available. Down-sync will reconcile.");
1910
3197
  break;
1911
3198
  case "delete":
1912
3199
  this.logger.warn({
1913
3200
  recordId,
1914
- Category: "spooky-client::SpookySync::handleRollback"
3201
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1915
3202
  }, "Delete rollback not implemented. Down-sync will reconcile.");
1916
3203
  break;
1917
3204
  }
@@ -1924,7 +3211,7 @@ var SpookySync = class {
1924
3211
  async processDownEvent(event) {
1925
3212
  this.logger.debug({
1926
3213
  event,
1927
- Category: "spooky-client::SpookySync::processDownEvent"
3214
+ Category: "sp00ky-client::Sp00kySync::processDownEvent"
1928
3215
  }, "Processing down event");
1929
3216
  switch (event.type) {
1930
3217
  case "register": return this.registerQuery(event.payload.hash);
@@ -1943,13 +3230,76 @@ var SpookySync = class {
1943
3230
  if (!queryState) {
1944
3231
  this.logger.warn({
1945
3232
  hash,
1946
- Category: "spooky-client::SpookySync::syncQuery"
3233
+ Category: "sp00ky-client::Sp00kySync::syncQuery"
1947
3234
  }, "Query not found");
1948
3235
  return;
1949
3236
  }
1950
3237
  const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
1951
3238
  if (!diff) return;
1952
- return this.syncEngine.syncRecords(diff);
3239
+ return this.runSyncForQuery(hash, diff);
3240
+ }
3241
+ /**
3242
+ * Run a sync for a single query while reflecting its fetch status. Marks the
3243
+ * query `fetching` for the duration when the diff actually pulls records
3244
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
3245
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
3246
+ * means the single resulting UI update lands after this completes.
3247
+ */
3248
+ async runSyncForQuery(hash, diff) {
3249
+ if (diff.added.length > 0 || diff.updated.length > 0) {
3250
+ const pendingDeletes = await this.getPendingDeleteIds();
3251
+ if (pendingDeletes.size > 0) diff = {
3252
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
3253
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
3254
+ removed: diff.removed
3255
+ };
3256
+ }
3257
+ const fetching = diff.added.length + diff.updated.length > 0;
3258
+ if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
3259
+ try {
3260
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
3261
+ if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
3262
+ if (stillRemoteIds.length > 0) {
3263
+ const CONVERGE_AFTER = 3;
3264
+ const toConverge = [];
3265
+ for (const id of stillRemoteIds) {
3266
+ const key = `${hash}:${id}`;
3267
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
3268
+ if (n >= CONVERGE_AFTER) {
3269
+ this.stillRemoteStreaks.delete(key);
3270
+ toConverge.push(id);
3271
+ } else this.stillRemoteStreaks.set(key, n);
3272
+ }
3273
+ if (toConverge.length > 0) {
3274
+ const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
3275
+ if (local && local.length > 0) {
3276
+ const drop = new Set(toConverge);
3277
+ const next = local.filter(([id]) => !drop.has(id));
3278
+ if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
3279
+ }
3280
+ }
3281
+ }
3282
+ } finally {
3283
+ if (fetching) this.dataModule.setQueryStatus(hash, "idle");
3284
+ }
3285
+ }
3286
+ /**
3287
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
3288
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
3289
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
3290
+ * would otherwise resurrect a just-deleted record.
3291
+ */
3292
+ async getPendingDeleteIds() {
3293
+ try {
3294
+ const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
3295
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
3296
+ } catch (err) {
3297
+ this.logger.warn({
3298
+ err,
3299
+ Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
3300
+ }, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
3301
+ return /* @__PURE__ */ new Set();
3302
+ }
1953
3303
  }
1954
3304
  /**
1955
3305
  * Enqueues a list of mutations (up events) to be sent to the remote.
@@ -1962,14 +3312,15 @@ var SpookySync = class {
1962
3312
  try {
1963
3313
  this.logger.debug({
1964
3314
  queryHash,
1965
- Category: "spooky-client::SpookySync::registerQuery"
3315
+ Category: "sp00ky-client::Sp00kySync::registerQuery"
1966
3316
  }, "Register Query state");
1967
3317
  await this.createRemoteQuery(queryHash);
1968
3318
  await this.syncQuery(queryHash);
3319
+ await this.dataModule.notifyQuerySynced(queryHash);
1969
3320
  } catch (e) {
1970
3321
  this.logger.error({
1971
3322
  err: e,
1972
- Category: "spooky-client::SpookySync::registerQuery"
3323
+ Category: "sp00ky-client::Sp00kySync::registerQuery"
1973
3324
  }, "registerQuery error");
1974
3325
  throw e;
1975
3326
  }
@@ -1979,37 +3330,80 @@ var SpookySync = class {
1979
3330
  if (!queryState) {
1980
3331
  this.logger.warn({
1981
3332
  queryHash,
1982
- Category: "spooky-client::SpookySync::createRemoteQuery"
3333
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
1983
3334
  }, "Query to register not found");
1984
3335
  throw new Error("Query to register not found");
1985
3336
  }
1986
3337
  await this.remote.query("fn::query::register($config)", { config: {
1987
- clientId: this.clientId,
1988
3338
  id: queryState.config.id,
1989
3339
  surql: queryState.config.surql,
1990
3340
  params: queryState.config.params,
1991
3341
  ttl: queryState.config.ttl
1992
3342
  } });
1993
- const [items] = await this.remote.query(surql.selectByFieldsAnd("_spooky_list_ref", ["in"], ["out", "version"]), { in: queryState.config.id });
3343
+ const listRefTbl = this.listRefTable();
3344
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
1994
3345
  this.logger.trace({
1995
3346
  queryId: encodeRecordId(queryState.config.id),
1996
3347
  items,
1997
- Category: "spooky-client::SpookySync::createRemoteQuery"
3348
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
1998
3349
  }, "Got query record version array from remote");
1999
3350
  const array = items.map((item) => [encodeRecordId(item.out), item.version]);
2000
3351
  this.logger.debug({
2001
3352
  queryId: encodeRecordId(queryState.config.id),
2002
3353
  array,
2003
- Category: "spooky-client::SpookySync::createRemoteQuery"
3354
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
2004
3355
  }, "createdRemoteQuery");
2005
3356
  if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
3357
+ await this.syncSubqueryChildren(queryHash).catch((err) => {
3358
+ this.logger.info({
3359
+ err: err?.message ?? err,
3360
+ queryHash,
3361
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
3362
+ }, "Subquery child sync failed during registration; poll will retry");
3363
+ });
3364
+ }
3365
+ /**
3366
+ * Sync the BODIES of a `.related()` query's subquery child rows into the
3367
+ * local cache, separately from the primary window array. The SSP writes
3368
+ * each matched child as a `_00_list_ref` edge tagged `parent`/`parent_rel`;
3369
+ * `buildSubqueryListRefSelect` pulls those `out`+`version` pairs (any
3370
+ * nesting depth). We diff against the in-memory `subqueryRemoteArray` and
3371
+ * fetch added/updated bodies through the SyncEngine — which `saveBatch`s
3372
+ * them into the local DB AND the in-browser SSP, whose subquery-table
3373
+ * dependency then re-materializes the parent view (no explicit notify).
3374
+ *
3375
+ * Deletion safety: we pass `removed: []` deliberately. A child body can be
3376
+ * shared by other queries; letting `handleRemovedRecords` delete one that
3377
+ * merely left THIS query's child set would clobber data another query still
3378
+ * shows. Genuine record deletes flow through the normal delete path; a
3379
+ * lingering orphan body is invisible (the correlated WHERE stops matching).
3380
+ *
3381
+ * Kept off `runSyncForQuery` on purpose so child fetches never flip the
3382
+ * query to `fetching` or skew its DevTools timings.
3383
+ */
3384
+ async syncSubqueryChildren(queryHash) {
3385
+ const queryState = this.dataModule.getQueryByHash(queryHash);
3386
+ if (!queryState) return;
3387
+ const listRefTbl = this.listRefTable();
3388
+ const [items] = await this.remote.query(buildSubqueryListRefSelect(listRefTbl), { in: queryState.config.id });
3389
+ if (!Array.isArray(items)) return;
3390
+ const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
3391
+ const prev = queryState.config.subqueryRemoteArray ?? [];
3392
+ if (recordVersionArraysEqual(fresh, prev)) return;
3393
+ const diff = diffRecordVersionArray(prev, fresh);
3394
+ if (diff.added.length > 0 || diff.updated.length > 0) await this.syncEngine.syncRecords({
3395
+ added: diff.added,
3396
+ updated: diff.updated,
3397
+ removed: []
3398
+ });
3399
+ queryState.config.subqueryRemoteArray = fresh;
2006
3400
  }
2007
3401
  async heartbeatQuery(queryHash) {
2008
3402
  const queryState = this.dataModule.getQueryByHash(queryHash);
2009
3403
  if (!queryState) {
2010
3404
  this.logger.warn({
2011
3405
  queryHash,
2012
- Category: "spooky-client::SpookySync::heartbeatQuery"
3406
+ Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
2013
3407
  }, "Query to register not found");
2014
3408
  throw new Error("Query to register not found");
2015
3409
  }
@@ -2017,14 +3411,17 @@ var SpookySync = class {
2017
3411
  }
2018
3412
  async cleanupQuery(queryHash) {
2019
3413
  const queryState = this.dataModule.getQueryByHash(queryHash);
2020
- if (!queryState) {
2021
- this.logger.warn({
2022
- queryHash,
2023
- Category: "spooky-client::SpookySync::cleanupQuery"
2024
- }, "Query to register not found");
2025
- throw new Error("Query to register not found");
2026
- }
3414
+ if (!queryState) return;
3415
+ if (this.dataModule.hasSubscribers(queryHash)) return;
2027
3416
  await this.remote.query(`DELETE $id`, { id: queryState.config.id });
3417
+ if (this.dataModule.hasSubscribers(queryHash)) {
3418
+ this.enqueueDownEvent({
3419
+ type: "register",
3420
+ payload: { hash: queryHash }
3421
+ });
3422
+ return;
3423
+ }
3424
+ this.dataModule.finalizeDeregister(queryHash);
2028
3425
  }
2029
3426
  };
2030
3427
 
@@ -2036,11 +3433,67 @@ function createAuthEventSystem() {
2036
3433
  }
2037
3434
 
2038
3435
  //#endregion
2039
- //#region src/modules/devtools/index.ts
3436
+ //#region src/modules/devtools/versions.ts
3437
+ const UNAVAILABLE = "unavailable";
3438
+ function emptyBackendVersions() {
3439
+ return {
3440
+ ssp: UNAVAILABLE,
3441
+ scheduler: UNAVAILABLE,
3442
+ surrealdb: UNAVAILABLE
3443
+ };
3444
+ }
3445
+ function emptyBackendInfo() {
3446
+ return {
3447
+ versions: emptyBackendVersions(),
3448
+ entities: []
3449
+ };
3450
+ }
3451
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
3452
+ function normalizeServerVersion(v) {
3453
+ return String(v).replace(/^surrealdb-/i, "").trim();
3454
+ }
3455
+ /**
3456
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
3457
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
3458
+ * how the result is unwrapped it may arrive as the array itself, a single
3459
+ * object, or `null`. Tolerant of all three.
3460
+ */
3461
+ function toEntityArray(raw) {
3462
+ if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
3463
+ if (raw && typeof raw === "object") return [raw];
3464
+ return [];
3465
+ }
3466
+ /**
3467
+ * Derive component versions + the full entity list from a `/info` entity array.
3468
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
3469
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
3470
+ */
3471
+ function parseBackendInfo(raw) {
3472
+ const entities = toEntityArray(raw);
3473
+ const versions = emptyBackendVersions();
3474
+ for (const entity of entities) {
3475
+ const version = entity.version ? String(entity.version) : void 0;
3476
+ if (entity.entity === "ssp" && version) versions.ssp = version;
3477
+ else if (entity.entity === "scheduler" && version) versions.scheduler = version;
3478
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
3479
+ }
3480
+ return {
3481
+ versions,
3482
+ entities
3483
+ };
3484
+ }
3485
+
3486
+ //#endregion
3487
+ //#region src/modules/devtools/index.ts
3488
+ const CORE_VERSION = "0.0.1-canary.100";
3489
+ const WASM_VERSION = "0.0.1-canary.100";
3490
+ const SURREAL_VERSION = "3.0.3";
2040
3491
  var DevToolsService = class {
2041
3492
  eventsHistory = [];
2042
3493
  eventIdCounter = 0;
2043
- version = "1.0.0";
3494
+ version = CORE_VERSION;
3495
+ backendInfo = emptyBackendInfo();
3496
+ enabled = false;
2044
3497
  constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
2045
3498
  this.databaseService = databaseService;
2046
3499
  this.remoteDatabaseService = remoteDatabaseService;
@@ -2049,10 +3502,37 @@ var DevToolsService = class {
2049
3502
  this.authService = authService;
2050
3503
  this.dataManager = dataManager;
2051
3504
  this.exposeToWindow();
3505
+ if (typeof window !== "undefined") window.addEventListener("message", (e) => {
3506
+ if (e.source !== window) return;
3507
+ const type = e.data?.type;
3508
+ if (type === "SP00KY_DEVTOOLS_CONNECT") {
3509
+ this.enabled = true;
3510
+ this.notifyDevTools();
3511
+ } else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
3512
+ });
2052
3513
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
2053
3514
  this.notifyDevTools();
2054
3515
  });
2055
- this.logger.debug({ Category: "spooky-client::DevToolsService::init" }, "Service initialized");
3516
+ this.refreshBackendVersions();
3517
+ this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
3518
+ }
3519
+ /**
3520
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
3521
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
3522
+ * Never throws: on failure the info stays empty/'unavailable'.
3523
+ */
3524
+ async refreshBackendVersions() {
3525
+ try {
3526
+ const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
3527
+ this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
3528
+ } catch (err) {
3529
+ this.logger.debug({
3530
+ err,
3531
+ Category: "sp00ky-client::DevToolsService::versions"
3532
+ }, "fn::spooky::info() unavailable; backend versions stay unavailable");
3533
+ this.backendInfo = emptyBackendInfo();
3534
+ }
3535
+ this.notifyDevTools();
2056
3536
  }
2057
3537
  getActiveQueries() {
2058
3538
  const result = /* @__PURE__ */ new Map();
@@ -2062,6 +3542,8 @@ var DevToolsService = class {
2062
3542
  result.set(queryHash, {
2063
3543
  queryHash,
2064
3544
  status: "active",
3545
+ fetchStatus: q.status,
3546
+ isFetching: q.status === "fetching",
2065
3547
  createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
2066
3548
  lastUpdate: Date.now(),
2067
3549
  updateCount: q.updateCount,
@@ -2070,7 +3552,8 @@ var DevToolsService = class {
2070
3552
  dataSize: q.records?.length || 0,
2071
3553
  data: q.records,
2072
3554
  localArray: q.config.localArray,
2073
- remoteArray: q.config.remoteArray
3555
+ remoteArray: q.config.remoteArray,
3556
+ timings: this.dataManager.phaseTimings(q)
2074
3557
  });
2075
3558
  });
2076
3559
  return result;
@@ -2078,7 +3561,7 @@ var DevToolsService = class {
2078
3561
  onQueryInitialized(payload) {
2079
3562
  this.logger.debug({
2080
3563
  payload,
2081
- Category: "spooky-client::DevToolsService::onQueryInitialized"
3564
+ Category: "sp00ky-client::DevToolsService::onQueryInitialized"
2082
3565
  }, "QueryInitialized");
2083
3566
  const queryHash = this.hashString(payload.queryId.toString());
2084
3567
  this.addEvent("QUERY_REQUEST_INIT", {
@@ -2091,7 +3574,7 @@ var DevToolsService = class {
2091
3574
  onQueryUpdated(payload) {
2092
3575
  this.logger.debug({
2093
3576
  id: payload.queryId?.toString(),
2094
- Category: "spooky-client::DevToolsService::onQueryUpdated"
3577
+ Category: "sp00ky-client::DevToolsService::onQueryUpdated"
2095
3578
  }, "QueryUpdated");
2096
3579
  const queryHash = this.hashString(payload.queryId.toString());
2097
3580
  this.addEvent("QUERY_UPDATED", {
@@ -2103,7 +3586,7 @@ var DevToolsService = class {
2103
3586
  onStreamUpdate(update) {
2104
3587
  this.logger.debug({
2105
3588
  update,
2106
- Category: "spooky-client::DevToolsService::onStreamUpdate"
3589
+ Category: "sp00ky-client::DevToolsService::onStreamUpdate"
2107
3590
  }, "StreamUpdate");
2108
3591
  this.addEvent("STREAM_UPDATE", { updates: [update] });
2109
3592
  this.notifyDevTools();
@@ -2133,6 +3616,7 @@ var DevToolsService = class {
2133
3616
  this.notifyDevTools();
2134
3617
  }
2135
3618
  addEvent(eventType, payload) {
3619
+ if (!this.enabled) return;
2136
3620
  this.eventsHistory.push({
2137
3621
  id: this.eventIdCounter++,
2138
3622
  timestamp: Date.now(),
@@ -2150,6 +3634,15 @@ var DevToolsService = class {
2150
3634
  userId: this.authService.currentUser?.id
2151
3635
  },
2152
3636
  version: this.version,
3637
+ versions: {
3638
+ frontend: {
3639
+ core: CORE_VERSION,
3640
+ wasm: WASM_VERSION,
3641
+ surrealdb: SURREAL_VERSION
3642
+ },
3643
+ backend: this.backendInfo.versions,
3644
+ entities: this.backendInfo.entities
3645
+ },
2153
3646
  database: {
2154
3647
  tables: this.schema.tables.map((t) => t.name),
2155
3648
  tableData: {}
@@ -2157,9 +3650,10 @@ var DevToolsService = class {
2157
3650
  });
2158
3651
  }
2159
3652
  notifyDevTools() {
3653
+ if (!this.enabled) return;
2160
3654
  if (typeof window !== "undefined") window.postMessage({
2161
- type: "SPOOKY_STATE_CHANGED",
2162
- source: "spooky-devtools-page",
3655
+ type: "SP00KY_STATE_CHANGED",
3656
+ source: "sp00ky-devtools-page",
2163
3657
  state: this.getState()
2164
3658
  }, "*");
2165
3659
  }
@@ -2185,13 +3679,14 @@ var DevToolsService = class {
2185
3679
  }
2186
3680
  exposeToWindow() {
2187
3681
  if (typeof window !== "undefined") {
2188
- window.__SPOOKY__ = {
3682
+ window.__00__ = {
2189
3683
  version: this.version,
2190
3684
  getState: () => this.getState(),
2191
3685
  clearHistory: () => {
2192
3686
  this.eventsHistory = [];
2193
3687
  this.notifyDevTools();
2194
3688
  },
3689
+ refreshVersions: () => this.refreshBackendVersions(),
2195
3690
  getTableData: async (tableName) => {
2196
3691
  try {
2197
3692
  const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
@@ -2206,7 +3701,7 @@ var DevToolsService = class {
2206
3701
  } catch (e) {
2207
3702
  this.logger.error({
2208
3703
  err: e,
2209
- Category: "spooky-client::DevToolsService::exposeToWindow"
3704
+ Category: "sp00ky-client::DevToolsService::exposeToWindow"
2210
3705
  }, "Failed to get table data");
2211
3706
  return [];
2212
3707
  }
@@ -2238,7 +3733,7 @@ var DevToolsService = class {
2238
3733
  this.logger.debug({
2239
3734
  query,
2240
3735
  target,
2241
- Category: "spooky-client::DevToolsService::runQuery"
3736
+ Category: "sp00ky-client::DevToolsService::runQuery"
2242
3737
  }, "Running query (START)");
2243
3738
  const service = target === "remote" ? this.remoteDatabaseService : this.databaseService;
2244
3739
  const startTime = Date.now();
@@ -2249,7 +3744,7 @@ var DevToolsService = class {
2249
3744
  time: queryTime,
2250
3745
  resultType: typeof result,
2251
3746
  isArray: Array.isArray(result),
2252
- Category: "spooky-client::DevToolsService::runQuery"
3747
+ Category: "sp00ky-client::DevToolsService::runQuery"
2253
3748
  }, "Database returned result");
2254
3749
  const serializeStart = Date.now();
2255
3750
  const serialized = this.serializeForDevTools(result);
@@ -2257,7 +3752,7 @@ var DevToolsService = class {
2257
3752
  this.logger.debug({
2258
3753
  serializeTime,
2259
3754
  serializedLength: JSON.stringify(serialized).length,
2260
- Category: "spooky-client::DevToolsService::runQuery"
3755
+ Category: "sp00ky-client::DevToolsService::runQuery"
2261
3756
  }, "Serialization complete");
2262
3757
  return {
2263
3758
  success: true,
@@ -2269,7 +3764,7 @@ var DevToolsService = class {
2269
3764
  err: e,
2270
3765
  query,
2271
3766
  target,
2272
- Category: "spooky-client::DevToolsService::runQuery"
3767
+ Category: "sp00ky-client::DevToolsService::runQuery"
2273
3768
  }, "Query execution failed");
2274
3769
  return {
2275
3770
  success: false,
@@ -2279,23 +3774,52 @@ var DevToolsService = class {
2279
3774
  }
2280
3775
  };
2281
3776
  window.postMessage({
2282
- type: "SPOOKY_DETECTED",
2283
- source: "spooky-devtools-page",
3777
+ type: "SP00KY_DETECTED",
3778
+ source: "sp00ky-devtools-page",
2284
3779
  data: {
2285
3780
  version: this.version,
2286
3781
  detected: true
2287
3782
  }
2288
3783
  }, "*");
3784
+ window.dispatchEvent(new CustomEvent("sp00ky:init"));
2289
3785
  }
2290
3786
  }
2291
3787
  };
2292
3788
 
2293
3789
  //#endregion
2294
3790
  //#region src/modules/auth/index.ts
3791
+ /**
3792
+ * Read the `AC` (access-method name) claim from a SurrealDB record-access
3793
+ * JWT without verifying it — we only need the claim, the server enforces the
3794
+ * token. Returns null on any malformed input. The in-browser SSP needs this
3795
+ * to resolve `$access` in table permission predicates (mirrors the session's
3796
+ * `$access` that the server's `fn::query::register` reads).
3797
+ */
3798
+ function decodeAccessFromToken(token) {
3799
+ try {
3800
+ const payload = token.split(".")[1];
3801
+ if (!payload) return null;
3802
+ let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3803
+ b64 += "=".repeat((4 - b64.length % 4) % 4);
3804
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
3805
+ const claims = JSON.parse(json);
3806
+ const ac = claims.AC ?? claims.ac;
3807
+ return typeof ac === "string" ? ac : null;
3808
+ } catch {
3809
+ return null;
3810
+ }
3811
+ }
2295
3812
  var AuthService = class {
2296
3813
  token = null;
2297
3814
  currentUser = null;
2298
3815
  isAuthenticated = false;
3816
+ /**
3817
+ * The record-access method name for the current session (e.g. `"account"`),
3818
+ * derived from the token's `AC` claim. Consumed by the in-browser SSP's
3819
+ * permission injection so `$access`-gated table predicates resolve locally,
3820
+ * mirroring the server's `$access`. Null when logged out.
3821
+ */
3822
+ access = null;
2299
3823
  isLoading = true;
2300
3824
  events = createAuthEventSystem();
2301
3825
  get eventSystem() {
@@ -2336,9 +3860,9 @@ var AuthService = class {
2336
3860
  async check(accessToken) {
2337
3861
  this.isLoading = true;
2338
3862
  try {
2339
- const token = accessToken || await this.persistenceClient.get("spooky_auth_token");
3863
+ const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
2340
3864
  if (!token) {
2341
- this.logger.debug({ Category: "spooky-client::AuthService::check" }, "No token found in storage or arguments");
3865
+ this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
2342
3866
  this.isLoading = false;
2343
3867
  this.isAuthenticated = false;
2344
3868
  this.notifyListeners();
@@ -2351,22 +3875,22 @@ var AuthService = class {
2351
3875
  if (user && user.id) {
2352
3876
  this.logger.info({
2353
3877
  user,
2354
- Category: "spooky-client::AuthService::check"
3878
+ Category: "sp00ky-client::AuthService::check"
2355
3879
  }, "Auth check complete (via $auth.id)");
2356
3880
  await this.setSession(token, user);
2357
3881
  } else {
2358
- this.logger.warn({ Category: "spooky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
3882
+ this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
2359
3883
  const manualResult = await this.remote.query("SELECT * FROM user WHERE id = $auth.id LIMIT 1");
2360
3884
  const manualItems = Array.isArray(manualResult) && Array.isArray(manualResult[0]) ? manualResult[0] : manualResult;
2361
3885
  const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
2362
3886
  if (manualUser && manualUser.id) {
2363
3887
  this.logger.info({
2364
3888
  user: manualUser,
2365
- Category: "spooky-client::AuthService::check"
3889
+ Category: "sp00ky-client::AuthService::check"
2366
3890
  }, "Auth check complete (via manual fetch)");
2367
3891
  await this.setSession(token, manualUser);
2368
3892
  } else {
2369
- this.logger.warn({ Category: "spooky-client::AuthService::check" }, "Token valid but user not found via fallback");
3893
+ this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
2370
3894
  await this.signOut();
2371
3895
  }
2372
3896
  }
@@ -2374,7 +3898,7 @@ var AuthService = class {
2374
3898
  this.logger.error({
2375
3899
  error,
2376
3900
  stack: error.stack,
2377
- Category: "spooky-client::AuthService::check"
3901
+ Category: "sp00ky-client::AuthService::check"
2378
3902
  }, "Auth check failed");
2379
3903
  await this.signOut();
2380
3904
  } finally {
@@ -2388,19 +3912,27 @@ var AuthService = class {
2388
3912
  this.token = null;
2389
3913
  this.currentUser = null;
2390
3914
  this.isAuthenticated = false;
2391
- await this.persistenceClient.remove("spooky_auth_token");
3915
+ this.access = null;
3916
+ await this.persistenceClient.remove("sp00ky_auth_token");
2392
3917
  try {
2393
3918
  await this.remote.getClient().invalidate();
2394
- } catch (e) {}
3919
+ } catch (_e) {}
2395
3920
  this.notifyListeners();
2396
3921
  }
2397
3922
  async setSession(token, user) {
2398
3923
  this.token = token;
2399
3924
  this.currentUser = user;
2400
3925
  this.isAuthenticated = true;
2401
- await this.persistenceClient.set("spooky_auth_token", token);
3926
+ this.access = decodeAccessFromToken(token) ?? this.defaultAccessName();
3927
+ await this.persistenceClient.set("sp00ky_auth_token", token);
2402
3928
  this.notifyListeners();
2403
3929
  }
3930
+ /** Fallback when the token carries no `AC` claim: if the schema defines
3931
+ * exactly one record-access method, assume the session used it. */
3932
+ defaultAccessName() {
3933
+ const names = Object.keys(this.schema.access ?? {});
3934
+ return names.length === 1 ? names[0] : null;
3935
+ }
2404
3936
  async signUp(accessName, params) {
2405
3937
  const def = this.getAccessDefinition(accessName);
2406
3938
  if (!def) throw new Error(`Access definition '${accessName}' not found`);
@@ -2410,13 +3942,13 @@ var AuthService = class {
2410
3942
  this.logger.info({
2411
3943
  accessName,
2412
3944
  runtimeParams,
2413
- Category: "spooky-client::AuthService::signUp"
3945
+ Category: "sp00ky-client::AuthService::signUp"
2414
3946
  }, "Attempting signup");
2415
3947
  const { access } = await this.remote.getClient().signup({
2416
3948
  access: accessName,
2417
3949
  variables: runtimeParams
2418
3950
  });
2419
- this.logger.info({ Category: "spooky-client::AuthService::signUp" }, "Signup successful, token received");
3951
+ this.logger.info({ Category: "sp00ky-client::AuthService::signUp" }, "Signup successful, token received");
2420
3952
  await this.check(access);
2421
3953
  }
2422
3954
  async signIn(accessName, params) {
@@ -2427,7 +3959,7 @@ var AuthService = class {
2427
3959
  if (missingParams.length > 0) throw new Error(`Missing required signin params for '${accessName}': ${missingParams.join(", ")}`);
2428
3960
  this.logger.info({
2429
3961
  accessName,
2430
- Category: "spooky-client::AuthService::signIn"
3962
+ Category: "sp00ky-client::AuthService::signIn"
2431
3963
  }, "Attempting signin");
2432
3964
  const { access } = await this.remote.getClient().signin({
2433
3965
  access: accessName,
@@ -2444,6 +3976,12 @@ var StreamProcessorService = class {
2444
3976
  processor;
2445
3977
  isInitialized = false;
2446
3978
  receivers = [];
3979
+ batching = false;
3980
+ batchBuffer = /* @__PURE__ */ new Map();
3981
+ sessionAuth = {
3982
+ authId: "",
3983
+ access: ""
3984
+ };
2447
3985
  constructor(events, db, persistenceClient, logger) {
2448
3986
  this.events = events;
2449
3987
  this.db = db;
@@ -2458,25 +3996,91 @@ var StreamProcessorService = class {
2458
3996
  this.receivers.push(receiver);
2459
3997
  }
2460
3998
  notifyUpdates(updates) {
3999
+ if (this.batching) {
4000
+ for (const update of updates) {
4001
+ const prev = this.batchBuffer.get(update.queryHash);
4002
+ const sum = (a, b) => (a ?? 0) + (b ?? 0);
4003
+ this.batchBuffer.set(update.queryHash, {
4004
+ ...update,
4005
+ op: "CREATE",
4006
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
4007
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
4008
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
4009
+ transformMs: sum(prev?.transformMs, update.transformMs)
4010
+ });
4011
+ }
4012
+ return;
4013
+ }
4014
+ this.dispatchUpdates(updates);
4015
+ }
4016
+ dispatchUpdates(updates) {
2461
4017
  for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
2462
4018
  }
2463
4019
  /**
4020
+ * Ingest a batch of record changes as a single bulk operation, firing only
4021
+ * one coalesced `StreamUpdate` per affected query once every record has been
4022
+ * ingested (instead of one update per record). Use this whenever multiple
4023
+ * records land at once — e.g. sync fetching N missing rows — so a list query
4024
+ * re-runs and the UI re-renders once for the whole batch rather than
4025
+ * row-by-row.
4026
+ *
4027
+ * Internally opens a coalescing window, ingests each record, then flushes;
4028
+ * processor state is persisted once for the whole batch. No-op for an empty
4029
+ * batch.
4030
+ */
4031
+ ingestMany(records) {
4032
+ if (records.length === 0) return;
4033
+ this.beginCoalescing();
4034
+ try {
4035
+ for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
4036
+ } finally {
4037
+ this.flushCoalescing();
4038
+ }
4039
+ }
4040
+ /**
4041
+ * Open a coalescing window. While open, the per-record stream updates
4042
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
4043
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
4044
+ * `ingestMany` so the window always closes — otherwise the processor stays
4045
+ * stuck buffering forever.
4046
+ *
4047
+ * No-op if a window is already open (nested batches aren't expected here).
4048
+ */
4049
+ beginCoalescing() {
4050
+ if (this.batching) return;
4051
+ this.batching = true;
4052
+ this.batchBuffer.clear();
4053
+ }
4054
+ /**
4055
+ * Close the coalescing window and flush: dispatch one coalesced
4056
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
4057
+ * for the whole batch (instead of once per ingest).
4058
+ */
4059
+ flushCoalescing() {
4060
+ if (!this.batching) return;
4061
+ this.batching = false;
4062
+ const buffered = Array.from(this.batchBuffer.values());
4063
+ this.batchBuffer.clear();
4064
+ if (buffered.length > 0) this.dispatchUpdates(buffered);
4065
+ this.saveState();
4066
+ }
4067
+ /**
2464
4068
  * Initialize the WASM module and processor.
2465
4069
  * This must be called before using other methods.
2466
4070
  */
2467
4071
  async init() {
2468
4072
  if (this.isInitialized) return;
2469
- this.logger.info({ Category: "spooky-client::StreamProcessorService::init" }, "Initializing WASM...");
4073
+ this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initializing WASM...");
2470
4074
  try {
2471
4075
  await init();
2472
- this.processor = new SpookyProcessor();
4076
+ this.processor = new Sp00kyProcessor();
2473
4077
  await this.loadState();
2474
4078
  this.isInitialized = true;
2475
- this.logger.info({ Category: "spooky-client::StreamProcessorService::init" }, "Initialized successfully");
4079
+ this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
2476
4080
  } catch (e) {
2477
4081
  this.logger.error({
2478
4082
  error: e,
2479
- Category: "spooky-client::StreamProcessorService::init"
4083
+ Category: "sp00ky-client::StreamProcessorService::init"
2480
4084
  }, "Failed to initialize");
2481
4085
  throw e;
2482
4086
  }
@@ -2484,37 +4088,75 @@ var StreamProcessorService = class {
2484
4088
  async loadState() {
2485
4089
  if (!this.processor) return;
2486
4090
  try {
2487
- const result = await this.persistenceClient.get("_spooky_stream_processor_state");
4091
+ const result = await this.persistenceClient.get("_00_stream_processor_state");
2488
4092
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
2489
4093
  const state = result[0][0].state;
2490
4094
  this.logger.info({
2491
4095
  stateLength: state.length,
2492
- Category: "spooky-client::StreamProcessorService::loadState"
4096
+ Category: "sp00ky-client::StreamProcessorService::loadState"
2493
4097
  }, "Loading state from DB");
2494
4098
  if (typeof this.processor.load_state === "function") this.processor.load_state(state);
2495
- else this.logger.warn({ Category: "spooky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
2496
- } else this.logger.info({ Category: "spooky-client::StreamProcessorService::loadState" }, "No saved state found");
4099
+ else this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
4100
+ } else this.logger.info({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "No saved state found");
2497
4101
  } catch (e) {
2498
4102
  this.logger.error({
2499
4103
  error: e,
2500
- Category: "spooky-client::StreamProcessorService::loadState"
4104
+ Category: "sp00ky-client::StreamProcessorService::loadState"
2501
4105
  }, "Failed to load state");
2502
4106
  }
2503
4107
  }
4108
+ /**
4109
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
4110
+ * Must run after the processor exists and before any `register_view`, else
4111
+ * non-`_00_` tables are default-denied and registration fails.
4112
+ */
4113
+ setPermissions(permissions) {
4114
+ if (!this.processor) return;
4115
+ if (typeof this.processor.set_permissions !== "function") {
4116
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
4117
+ return;
4118
+ }
4119
+ this.processor.set_permissions(permissions);
4120
+ this.logger.info({
4121
+ tables: Object.keys(permissions).length,
4122
+ Category: "sp00ky-client::StreamProcessorService::setPermissions"
4123
+ }, "Seeded table permissions");
4124
+ }
4125
+ /**
4126
+ * Set the current session's auth identity for permission injection,
4127
+ * mirroring the server's `fn::query::register`
4128
+ * (`object::extend(params, { auth: { id: $auth.id }, access: $access })`).
4129
+ * Stored as strings (empty when logged out) and applied to every
4130
+ * `register_view` in {@link registerQueryPlan}. Must be set before a
4131
+ * `$auth`-gated query registers (and re-set on auth state changes), or the
4132
+ * in-browser SSP's `permission_inject` rejects it with
4133
+ * "requires $auth but registration params lack it".
4134
+ */
4135
+ setSessionAuth(authId, access) {
4136
+ this.sessionAuth = {
4137
+ authId: authId ?? "",
4138
+ access: access ?? ""
4139
+ };
4140
+ this.logger.debug({
4141
+ authId: this.sessionAuth.authId,
4142
+ access: this.sessionAuth.access,
4143
+ Category: "sp00ky-client::StreamProcessorService::setSessionAuth"
4144
+ }, "Session auth context updated");
4145
+ }
2504
4146
  async saveState() {
2505
4147
  if (!this.processor) return;
2506
4148
  try {
2507
4149
  if (typeof this.processor.save_state === "function") {
2508
4150
  const state = this.processor.save_state();
2509
4151
  if (state) {
2510
- await this.persistenceClient.set("_spooky_stream_processor_state", state);
2511
- this.logger.trace({ Category: "spooky-client::StreamProcessorService::saveState" }, "State saved");
4152
+ await this.persistenceClient.set("_00_stream_processor_state", state);
4153
+ this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
2512
4154
  }
2513
4155
  }
2514
4156
  } catch (e) {
2515
4157
  this.logger.error({
2516
4158
  error: e,
2517
- Category: "spooky-client::StreamProcessorService::saveState"
4159
+ Category: "sp00ky-client::StreamProcessorService::saveState"
2518
4160
  }, "Failed to save state");
2519
4161
  }
2520
4162
  }
@@ -2528,36 +4170,43 @@ var StreamProcessorService = class {
2528
4170
  table,
2529
4171
  op,
2530
4172
  id,
2531
- Category: "spooky-client::StreamProcessorService::ingest"
4173
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2532
4174
  }, "Ingesting into ssp");
2533
4175
  if (!this.processor) {
2534
- this.logger.warn({ Category: "spooky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
4176
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
2535
4177
  return [];
2536
4178
  }
2537
4179
  try {
2538
4180
  const normalizedRecord = this.normalizeValue(record);
4181
+ const t0 = performance.now();
2539
4182
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
4183
+ const materializationTimeMs = performance.now() - t0;
2540
4184
  this.logger.debug({
2541
4185
  table,
2542
4186
  op,
2543
4187
  id,
2544
4188
  rawUpdates: rawUpdates.length,
2545
- Category: "spooky-client::StreamProcessorService::ingest"
4189
+ materializationTimeMs,
4190
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2546
4191
  }, "Ingesting into ssp done");
2547
4192
  if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
2548
4193
  const updates = rawUpdates.map((u) => ({
2549
4194
  queryHash: u.query_id,
2550
4195
  localArray: u.result_data,
2551
- op
4196
+ op,
4197
+ materializationTimeMs,
4198
+ storeApplyMs: u.timing_store_apply_ms,
4199
+ circuitStepMs: u.timing_circuit_step_ms,
4200
+ transformMs: u.timing_transform_ms
2552
4201
  }));
2553
4202
  this.notifyUpdates(updates);
2554
4203
  }
2555
- this.saveState();
4204
+ if (!this.batching) this.saveState();
2556
4205
  return rawUpdates;
2557
4206
  } catch (e) {
2558
4207
  this.logger.error({
2559
4208
  error: e,
2560
- Category: "spooky-client::StreamProcessorService::ingest"
4209
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2561
4210
  }, "Ingesting into ssp failed");
2562
4211
  }
2563
4212
  return [];
@@ -2568,46 +4217,55 @@ var StreamProcessorService = class {
2568
4217
  */
2569
4218
  registerQueryPlan(queryPlan) {
2570
4219
  if (!this.processor) {
2571
- this.logger.warn({ Category: "spooky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
4220
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
2572
4221
  return;
2573
4222
  }
2574
4223
  this.logger.debug({
2575
4224
  queryHash: queryPlan.queryHash,
2576
4225
  surql: queryPlan.surql,
2577
4226
  params: queryPlan.params,
2578
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
4227
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2579
4228
  }, "Registering query plan");
2580
4229
  try {
2581
- const normalizedParams = this.normalizeValue(queryPlan.params);
4230
+ const paramsWithAuth = {
4231
+ ...this.normalizeValue(queryPlan.params),
4232
+ auth: { id: this.sessionAuth.authId },
4233
+ access: this.sessionAuth.access
4234
+ };
2582
4235
  const initialUpdate = this.processor.register_view({
2583
4236
  id: queryPlan.queryHash,
2584
4237
  surql: queryPlan.surql,
2585
- params: normalizedParams,
4238
+ params: paramsWithAuth,
2586
4239
  clientId: "local",
2587
4240
  ttl: queryPlan.ttl.toString(),
2588
4241
  lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
2589
4242
  });
2590
4243
  this.logger.debug({
2591
4244
  initialUpdate,
2592
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
4245
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2593
4246
  }, "register_view result");
2594
4247
  if (!initialUpdate) throw new Error("Failed to register query plan");
2595
4248
  const update = {
2596
4249
  queryHash: initialUpdate.query_id,
2597
- localArray: initialUpdate.result_data
4250
+ localArray: initialUpdate.result_data,
4251
+ registration: {
4252
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
4253
+ planMs: initialUpdate.timing_plan_ms ?? 0,
4254
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
4255
+ }
2598
4256
  };
2599
4257
  this.saveState();
2600
4258
  this.logger.debug({
2601
4259
  queryHash: queryPlan.queryHash,
2602
4260
  surql: queryPlan.surql,
2603
4261
  params: queryPlan.params,
2604
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
4262
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2605
4263
  }, "Registered query plan");
2606
4264
  return update;
2607
4265
  } catch (e) {
2608
4266
  this.logger.error({
2609
4267
  error: e,
2610
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
4268
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2611
4269
  }, "Error registering query plan");
2612
4270
  throw e;
2613
4271
  }
@@ -2623,13 +4281,14 @@ var StreamProcessorService = class {
2623
4281
  } catch (e) {
2624
4282
  this.logger.error({
2625
4283
  error: e,
2626
- Category: "spooky-client::StreamProcessorService::unregisterQueryPlan"
4284
+ Category: "sp00ky-client::StreamProcessorService::unregisterQueryPlan"
2627
4285
  }, "Error unregistering query plan");
2628
4286
  }
2629
4287
  }
2630
4288
  normalizeValue(value) {
2631
4289
  if (value === null || value === void 0) return value;
2632
4290
  if (typeof value === "object") {
4291
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
2633
4292
  const hasTable = "table" in value && typeof value.table?.toString === "function";
2634
4293
  const hasId = "id" in value;
2635
4294
  const hasToString = typeof value.toString === "function";
@@ -2638,7 +4297,7 @@ var StreamProcessorService = class {
2638
4297
  const result = value.toString();
2639
4298
  this.logger.trace({
2640
4299
  result,
2641
- Category: "spooky-client::StreamProcessorService::normalizeValue"
4300
+ Category: "sp00ky-client::StreamProcessorService::normalizeValue"
2642
4301
  }, "RecordId detected");
2643
4302
  return result;
2644
4303
  }
@@ -2654,6 +4313,51 @@ var StreamProcessorService = class {
2654
4313
  }
2655
4314
  };
2656
4315
 
4316
+ //#endregion
4317
+ //#region src/services/stream-processor/permissions.ts
4318
+ /**
4319
+ * Extract per-table `select` permission predicates from a raw `.surql` schema.
4320
+ *
4321
+ * The in-browser SSP default-denies any non-`_00_` table that has no permission
4322
+ * predicate registered (see `permission_inject::build_predicate`). The client
4323
+ * already holds the full schema text (`config.schemaSurql`), so we parse each
4324
+ * `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
4325
+ * processor via `set_permissions` at boot — mirroring the native path that
4326
+ * seeds the same map from `INFO FOR DB`.
4327
+ *
4328
+ * Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
4329
+ * expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
4330
+ * `select` permission).
4331
+ */
4332
+ function extractSelectPermissions(schemaSurql) {
4333
+ const out = {};
4334
+ if (!schemaSurql) return out;
4335
+ const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
4336
+ const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
4337
+ let m;
4338
+ while ((m = tableStmt.exec(cleaned)) !== null) {
4339
+ const table = m[1];
4340
+ const body = m[2];
4341
+ out[table] = selectPredicateFromBody(body);
4342
+ }
4343
+ return out;
4344
+ }
4345
+ function selectPredicateFromBody(body) {
4346
+ const permIdx = body.search(/\bPERMISSIONS\b/i);
4347
+ if (permIdx === -1) return "false";
4348
+ const perms = body.slice(permIdx + 11).trim();
4349
+ if (/^FULL\b/i.test(perms)) return "true";
4350
+ if (/^NONE\b/i.test(perms)) return "false";
4351
+ const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
4352
+ for (const group of groups) {
4353
+ const where = group.search(/\bWHERE\b/i);
4354
+ if (where === -1) continue;
4355
+ const actions = group.slice(0, where).toLowerCase();
4356
+ if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
4357
+ }
4358
+ return "false";
4359
+ }
4360
+
2657
4361
  //#endregion
2658
4362
  //#region src/modules/cache/index.ts
2659
4363
  /**
@@ -2681,7 +4385,7 @@ var CacheModule = class {
2681
4385
  this.logger.debug({
2682
4386
  queryHash: update.queryHash,
2683
4387
  arrayLength: update.localArray?.length,
2684
- Category: "spooky-client::CacheModule::onStreamUpdate"
4388
+ Category: "sp00ky-client::CacheModule::onStreamUpdate"
2685
4389
  }, "Stream update received");
2686
4390
  this.streamUpdateCallback(update);
2687
4391
  }
@@ -2704,7 +4408,7 @@ var CacheModule = class {
2704
4408
  if (records.length === 0) return;
2705
4409
  this.logger.debug({
2706
4410
  count: records.length,
2707
- Category: "spooky-client::CacheModule::saveBatch"
4411
+ Category: "sp00ky-client::CacheModule::saveBatch"
2708
4412
  }, "Saving record batch");
2709
4413
  try {
2710
4414
  const populatedRecords = records.map((record) => {
@@ -2713,13 +4417,13 @@ var CacheModule = class {
2713
4417
  ...record,
2714
4418
  record: {
2715
4419
  ...record.record,
2716
- spooky_rv: record.version
4420
+ _00_rv: record.version
2717
4421
  }
2718
4422
  };
2719
4423
  });
2720
4424
  if (!skipDbInsert) {
2721
4425
  const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
2722
- return surql.upsert(`id${i}`, `content${i}`);
4426
+ return surql.upsertMerge(`id${i}`, `content${i}`);
2723
4427
  })));
2724
4428
  const params = populatedRecords.reduce((acc, record, i) => {
2725
4429
  const { id, ...content } = record.record;
@@ -2731,20 +4435,26 @@ var CacheModule = class {
2731
4435
  }, {});
2732
4436
  await this.local.execute(query, params);
2733
4437
  }
2734
- for (const record of populatedRecords) {
4438
+ const bulk = populatedRecords.map((record) => {
2735
4439
  const recordId = encodeRecordId(record.record.id);
2736
4440
  this.versionLookups[recordId] = record.version;
2737
- this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
2738
- }
4441
+ return {
4442
+ table: record.table,
4443
+ op: record.op,
4444
+ id: recordId,
4445
+ record: record.record
4446
+ };
4447
+ });
4448
+ this.streamProcessor.ingestMany(bulk);
2739
4449
  this.logger.debug({
2740
4450
  count: records.length,
2741
- Category: "spooky-client::CacheModule::saveBatch"
4451
+ Category: "sp00ky-client::CacheModule::saveBatch"
2742
4452
  }, "Batch saved successfully");
2743
4453
  } catch (err) {
2744
4454
  this.logger.error({
2745
4455
  err,
2746
4456
  count: records.length,
2747
- Category: "spooky-client::CacheModule::saveBatch"
4457
+ Category: "sp00ky-client::CacheModule::saveBatch"
2748
4458
  }, "Failed to save batch");
2749
4459
  throw err;
2750
4460
  }
@@ -2752,27 +4462,27 @@ var CacheModule = class {
2752
4462
  /**
2753
4463
  * Delete a record from local DB and ingest deletion into DBSP
2754
4464
  */
2755
- async delete(table, id, skipDbDelete = false) {
4465
+ async delete(table, id, skipDbDelete = false, recordData = {}) {
2756
4466
  this.logger.debug({
2757
4467
  table,
2758
4468
  id,
2759
- Category: "spooky-client::CacheModule::delete"
4469
+ Category: "sp00ky-client::CacheModule::delete"
2760
4470
  }, "Deleting record");
2761
4471
  try {
2762
4472
  if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
2763
4473
  delete this.versionLookups[id];
2764
- await this.streamProcessor.ingest(table, "DELETE", id, {});
4474
+ this.streamProcessor.ingest(table, "DELETE", id, recordData);
2765
4475
  this.logger.debug({
2766
4476
  table,
2767
4477
  id,
2768
- Category: "spooky-client::CacheModule::delete"
4478
+ Category: "sp00ky-client::CacheModule::delete"
2769
4479
  }, "Record deleted successfully");
2770
4480
  } catch (err) {
2771
4481
  this.logger.error({
2772
4482
  err,
2773
4483
  table,
2774
4484
  id,
2775
- Category: "spooky-client::CacheModule::delete"
4485
+ Category: "sp00ky-client::CacheModule::delete"
2776
4486
  }, "Failed to delete record");
2777
4487
  throw err;
2778
4488
  }
@@ -2785,7 +4495,7 @@ var CacheModule = class {
2785
4495
  this.logger.debug({
2786
4496
  queryHash: config.queryHash,
2787
4497
  surql: config.surql,
2788
- Category: "spooky-client::CacheModule::registerQuery"
4498
+ Category: "sp00ky-client::CacheModule::registerQuery"
2789
4499
  }, "Registering query");
2790
4500
  try {
2791
4501
  const update = this.streamProcessor.registerQueryPlan({
@@ -2802,14 +4512,17 @@ var CacheModule = class {
2802
4512
  this.logger.debug({
2803
4513
  queryHash: config.queryHash,
2804
4514
  arrayLength: update.localArray?.length,
2805
- Category: "spooky-client::CacheModule::registerQuery"
4515
+ Category: "sp00ky-client::CacheModule::registerQuery"
2806
4516
  }, "Query registered successfully");
2807
- return { localArray: update.localArray };
4517
+ return {
4518
+ localArray: update.localArray,
4519
+ registrationTimings: update.registration
4520
+ };
2808
4521
  } catch (err) {
2809
4522
  this.logger.error({
2810
4523
  err,
2811
4524
  queryHash: config.queryHash,
2812
- Category: "spooky-client::CacheModule::registerQuery"
4525
+ Category: "sp00ky-client::CacheModule::registerQuery"
2813
4526
  }, "Failed to register query");
2814
4527
  throw err;
2815
4528
  }
@@ -2820,24 +4533,639 @@ var CacheModule = class {
2820
4533
  unregisterQuery(queryHash) {
2821
4534
  this.logger.debug({
2822
4535
  queryHash,
2823
- Category: "spooky-client::CacheModule::unregisterQuery"
4536
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2824
4537
  }, "Unregistering query");
2825
4538
  try {
2826
4539
  this.streamProcessor.unregisterQueryPlan(queryHash);
2827
4540
  this.logger.debug({
2828
4541
  queryHash,
2829
- Category: "spooky-client::CacheModule::unregisterQuery"
4542
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2830
4543
  }, "Query unregistered successfully");
2831
4544
  } catch (err) {
2832
4545
  this.logger.error({
2833
4546
  err,
2834
4547
  queryHash,
2835
- Category: "spooky-client::CacheModule::unregisterQuery"
4548
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2836
4549
  }, "Failed to unregister query");
2837
4550
  }
2838
4551
  }
2839
4552
  };
2840
4553
 
4554
+ //#endregion
4555
+ //#region src/modules/crdt/crdt-field.ts
4556
+ const CURSOR_COLORS = [
4557
+ "#3b82f6",
4558
+ "#ef4444",
4559
+ "#22c55e",
4560
+ "#f59e0b",
4561
+ "#8b5cf6",
4562
+ "#ec4899",
4563
+ "#14b8a6",
4564
+ "#f97316"
4565
+ ];
4566
+ function cursorColorFromName(name) {
4567
+ let hash = 0;
4568
+ for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
4569
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
4570
+ }
4571
+ var CrdtField = class {
4572
+ doc;
4573
+ pushTimer = null;
4574
+ local = null;
4575
+ remote = null;
4576
+ recordId = null;
4577
+ sessionId = "";
4578
+ unsubscribe = null;
4579
+ lastPushTime = 0;
4580
+ lastCursorPushTime = 0;
4581
+ loadedFromCrdt = false;
4582
+ pushRetryCount = 0;
4583
+ logger;
4584
+ cursorsEnabled;
4585
+ /** Remote-push debounce. Local writes happen immediately on every Loro
4586
+ * update; the remote UPSERT is coalesced over this window. Configured
4587
+ * via `Sp00kyConfig.crdtDebounceMs`, default 500. */
4588
+ remoteDebounceMs = 500;
4589
+ _onCursorUpdate = null;
4590
+ pendingCursorUpdate = null;
4591
+ /** Callback set by the editor to receive remote cursor updates.
4592
+ * Any cursor data that arrived before this callback was set will be replayed. */
4593
+ set onCursorUpdate(cb) {
4594
+ this._onCursorUpdate = cb;
4595
+ if (cb && this.pendingCursorUpdate) {
4596
+ try {
4597
+ cb(this.pendingCursorUpdate);
4598
+ } catch (e) {
4599
+ this.logger?.warn({
4600
+ error: e,
4601
+ Category: "sp00ky-client::CrdtField::onCursorUpdate"
4602
+ }, "Failed to replay pending cursor update");
4603
+ }
4604
+ this.pendingCursorUpdate = null;
4605
+ }
4606
+ }
4607
+ get onCursorUpdate() {
4608
+ return this._onCursorUpdate;
4609
+ }
4610
+ constructor(fieldName, cursorsEnabled, initialState, logger) {
4611
+ this.fieldName = fieldName;
4612
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) throw new Error(`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`);
4613
+ this.logger = logger ?? null;
4614
+ this.cursorsEnabled = cursorsEnabled;
4615
+ this.doc = new LoroDoc();
4616
+ if (initialState && initialState.length > 0) try {
4617
+ this.doc.import(initialState);
4618
+ this.loadedFromCrdt = true;
4619
+ } catch (e) {
4620
+ this.logger?.warn({
4621
+ error: e,
4622
+ fieldName,
4623
+ Category: "sp00ky-client::CrdtField::constructor"
4624
+ }, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
4625
+ }
4626
+ }
4627
+ getDoc() {
4628
+ return this.doc;
4629
+ }
4630
+ /** Whether the LoroDoc was loaded from saved CRDT state */
4631
+ hasContent() {
4632
+ return this.loadedFromCrdt;
4633
+ }
4634
+ startSync(local, remote, recordId, sessionId, debounceMs) {
4635
+ this.local = local;
4636
+ this.remote = remote;
4637
+ this.recordId = recordId;
4638
+ this.sessionId = sessionId;
4639
+ this.remoteDebounceMs = debounceMs;
4640
+ this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
4641
+ this.persistLocal();
4642
+ this.scheduleRemotePush();
4643
+ });
4644
+ }
4645
+ stopSync() {
4646
+ if (this.unsubscribe) {
4647
+ this.unsubscribe();
4648
+ this.unsubscribe = null;
4649
+ }
4650
+ if (this.pushTimer) {
4651
+ clearTimeout(this.pushTimer);
4652
+ this.pushTimer = null;
4653
+ }
4654
+ if (this.remote && this.recordId) this.pushToRemote();
4655
+ }
4656
+ importRemote(state) {
4657
+ if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
4658
+ try {
4659
+ this.doc.import(state);
4660
+ this.persistLocal();
4661
+ } catch (e) {
4662
+ this.logger?.warn({
4663
+ error: e,
4664
+ Category: "sp00ky-client::CrdtField::importRemote"
4665
+ }, "Failed to import remote CRDT state");
4666
+ }
4667
+ }
4668
+ exportSnapshot() {
4669
+ return this.doc.export({ mode: "snapshot" });
4670
+ }
4671
+ /** Push this session's cursor blob into the parent row at
4672
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
4673
+ * field — the editor still calls this method optimistically, but
4674
+ * without `@cursor` on the schema there's nowhere to store the blob.
4675
+ * The UPDATE itself fires the parent table's LIVE feed, so other
4676
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
4677
+ async pushCursorState(encoded) {
4678
+ if (!this.remote || !this.recordId) return;
4679
+ if (!this.cursorsEnabled) return;
4680
+ this.lastCursorPushTime = Date.now();
4681
+ try {
4682
+ const state = encodeBase64(encoded);
4683
+ await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
4684
+ id: parseRecordIdString(this.recordId),
4685
+ sid: this.sessionId,
4686
+ state
4687
+ });
4688
+ } catch (e) {
4689
+ this.logger?.warn({
4690
+ error: e,
4691
+ Category: "sp00ky-client::CrdtField::pushCursorState"
4692
+ }, "Failed to push cursor state");
4693
+ }
4694
+ }
4695
+ /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
4696
+ importRemoteCursor(base64State) {
4697
+ if (Date.now() - this.lastCursorPushTime < 300) return;
4698
+ try {
4699
+ const data = decodeBase64(base64State);
4700
+ if (this._onCursorUpdate) this._onCursorUpdate(data);
4701
+ else this.pendingCursorUpdate = data;
4702
+ } catch (e) {
4703
+ this.logger?.warn({
4704
+ error: e,
4705
+ Category: "sp00ky-client::CrdtField::importRemoteCursor"
4706
+ }, "Failed to apply remote cursor data");
4707
+ }
4708
+ }
4709
+ scheduleRemotePush() {
4710
+ if (this.pushTimer) clearTimeout(this.pushTimer);
4711
+ this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
4712
+ }
4713
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
4714
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
4715
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
4716
+ * `<field>.state` next to per-session cursor blobs. */
4717
+ statePath() {
4718
+ return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
4719
+ }
4720
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
4721
+ * every local update and every remote import so reloads (online or
4722
+ * offline) see the freshest content immediately. Failures are
4723
+ * swallowed — a stale local write must never block user input. */
4724
+ async persistLocal() {
4725
+ if (!this.local || !this.recordId) return;
4726
+ try {
4727
+ await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
4728
+ id: parseRecordIdString(this.recordId),
4729
+ state: this.exportSnapshot()
4730
+ });
4731
+ } catch (e) {
4732
+ this.logger?.debug({
4733
+ error: e,
4734
+ Category: "sp00ky-client::CrdtField::persistLocal"
4735
+ }, "Local CRDT persist failed (best-effort)");
4736
+ }
4737
+ }
4738
+ async pushToRemote() {
4739
+ if (!this.remote || !this.recordId) return;
4740
+ this.lastPushTime = Date.now();
4741
+ try {
4742
+ await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
4743
+ id: parseRecordIdString(this.recordId),
4744
+ state: this.exportSnapshot()
4745
+ });
4746
+ this.pushRetryCount = 0;
4747
+ } catch (e) {
4748
+ this.logger?.warn({
4749
+ error: e,
4750
+ Category: "sp00ky-client::CrdtField::pushToRemote"
4751
+ }, "Failed to push CRDT state to remote");
4752
+ if (this.pushRetryCount < 2) {
4753
+ this.pushRetryCount++;
4754
+ this.scheduleRemotePush();
4755
+ }
4756
+ }
4757
+ }
4758
+ };
4759
+ function decodeBase64(b64) {
4760
+ if (typeof atob === "function") {
4761
+ const binary = atob(b64);
4762
+ const bytes = new Uint8Array(binary.length);
4763
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
4764
+ return bytes;
4765
+ }
4766
+ return new Uint8Array(Buffer.from(b64, "base64"));
4767
+ }
4768
+ function encodeBase64(bytes) {
4769
+ if (typeof btoa === "function") {
4770
+ let binary = "";
4771
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
4772
+ return btoa(binary);
4773
+ }
4774
+ return Buffer.from(bytes).toString("base64");
4775
+ }
4776
+
4777
+ //#endregion
4778
+ //#region src/modules/crdt/index.ts
4779
+ /**
4780
+ * CrdtManager manages active CrdtField instances and their sync channels.
4781
+ *
4782
+ * Collaborative state lives in two dedicated tables (defined in
4783
+ * `apps/cli/src/meta_tables_remote.surql`):
4784
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
4785
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
4786
+ * (record, session, field)
4787
+ *
4788
+ * Splitting them off the parent row is what makes offline edits mergeable:
4789
+ * each (record, field) gets its own row, so concurrent offline writes don't
4790
+ * collide on the parent's last-write-wins semantics.
4791
+ *
4792
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
4793
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
4794
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
4795
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
4796
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
4797
+ * via subquery. Permission inheritance happens server-side via
4798
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
4799
+ */
4800
+ var CrdtManager = class {
4801
+ fields = /* @__PURE__ */ new Map();
4802
+ liveByTable = /* @__PURE__ */ new Map();
4803
+ pendingLive = /* @__PURE__ */ new Map();
4804
+ logger;
4805
+ sessionId = "";
4806
+ constructor(schema, local, remote, logger, debounceMs = 500) {
4807
+ this.schema = schema;
4808
+ this.local = local;
4809
+ this.remote = remote;
4810
+ this.debounceMs = debounceMs;
4811
+ this.logger = logger.child({ service: "CrdtManager" });
4812
+ }
4813
+ /** Set the session id that scopes this client's cursor entries. Must be
4814
+ * called before `open()` for cursors to be pushed under a stable key.
4815
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
4816
+ * for the data-module salt). */
4817
+ setSessionId(sessionId) {
4818
+ this.sessionId = sessionId;
4819
+ }
4820
+ /**
4821
+ * Open a CRDT field for collaborative editing.
4822
+ *
4823
+ * @param table - Table name
4824
+ * @param recordId - Full record ID (e.g., "thread:abc")
4825
+ * @param field - Field name (e.g., "title", "content")
4826
+ * @param fallbackText - Current plain text from the record, used to seed the
4827
+ * LoroDoc if no CRDT state exists yet (migration path)
4828
+ */
4829
+ async open(table, recordId, field, fallbackText) {
4830
+ this.assertCrdtField(table, field);
4831
+ const cursorsEnabled = this.fieldHasCursor(table, field);
4832
+ const key = this.makeKey(table, recordId, field);
4833
+ let crdtField = this.fields.get(key);
4834
+ if (crdtField) return crdtField;
4835
+ let initialCrdtState;
4836
+ try {
4837
+ const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
4838
+ const snapshot = this.extractSnapshot(result, cursorsEnabled);
4839
+ if (snapshot) initialCrdtState = snapshot;
4840
+ } catch (e) {
4841
+ this.logger.info({
4842
+ error: String(e),
4843
+ recordId,
4844
+ field,
4845
+ Category: "sp00ky-client::CrdtManager::open"
4846
+ }, "No existing CRDT state found in local cache (continuing with empty doc)");
4847
+ }
4848
+ crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
4849
+ crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
4850
+ this.fields.set(key, crdtField);
4851
+ this.logger.info({
4852
+ key,
4853
+ hasInitialState: !!initialCrdtState,
4854
+ hasFallback: !!fallbackText,
4855
+ Category: "sp00ky-client::CrdtManager::open"
4856
+ }, "CrdtField opened");
4857
+ this.ensureTableSubscription(table);
4858
+ if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
4859
+ return crdtField;
4860
+ }
4861
+ close(table, recordId, field) {
4862
+ const key = this.makeKey(table, recordId, field);
4863
+ const crdtField = this.fields.get(key);
4864
+ if (crdtField) {
4865
+ crdtField.stopSync();
4866
+ this.fields.delete(key);
4867
+ }
4868
+ const tablePrefix = `${table}:`;
4869
+ if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
4870
+ this.logger.debug({
4871
+ key,
4872
+ Category: "sp00ky-client::CrdtManager::close"
4873
+ }, "CrdtField closed");
4874
+ }
4875
+ closeAll() {
4876
+ for (const [_, field] of this.fields) field.stopSync();
4877
+ this.fields.clear();
4878
+ for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
4879
+ }
4880
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
4881
+ * every open CrdtField on `table`. */
4882
+ async ensureTableSubscription(table) {
4883
+ if (this.liveByTable.has(table)) return;
4884
+ const pending = this.pendingLive.get(table);
4885
+ if (pending) return pending;
4886
+ const start = (async () => {
4887
+ try {
4888
+ const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
4889
+ (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
4890
+ if (message.action === "KILLED") return;
4891
+ if (message.action !== "CREATE" && message.action !== "UPDATE") return;
4892
+ this.dispatchRow(table, message.value);
4893
+ });
4894
+ this.liveByTable.set(table, uuid);
4895
+ this.logger.info({
4896
+ table,
4897
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
4898
+ }, "LIVE SELECT started");
4899
+ } catch (e) {
4900
+ this.logger.warn({
4901
+ error: e,
4902
+ table,
4903
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
4904
+ }, "Failed to start LIVE SELECT");
4905
+ }
4906
+ })();
4907
+ this.pendingLive.set(table, start);
4908
+ try {
4909
+ await start;
4910
+ } finally {
4911
+ this.pendingLive.delete(table);
4912
+ }
4913
+ }
4914
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
4915
+ * list_ref-driven sync engine, when the cross-user LIVE on the
4916
+ * parent table is filtered out by the SurrealDB cross-session
4917
+ * permission gap). Same semantics as the internal `dispatchRow`. */
4918
+ applyRow(table, row) {
4919
+ this.dispatchRow(table, row);
4920
+ }
4921
+ /** Dispatch a parent-row LIVE event to every open CrdtField on that
4922
+ * record. Each open field reads its slice of the row directly — the
4923
+ * CRDT snapshot is a column on the parent now, so there is no
4924
+ * follow-up subquery. */
4925
+ dispatchRow(table, row) {
4926
+ const id = row.id != null ? String(row.id) : "";
4927
+ if (!id) return;
4928
+ const rowKeyPrefix = `${table}:${id}:`;
4929
+ for (const [key, crdtField] of this.fields) {
4930
+ if (!key.startsWith(rowKeyPrefix)) continue;
4931
+ const fieldName = key.slice(rowKeyPrefix.length);
4932
+ const cursorsEnabled = this.fieldHasCursor(table, fieldName);
4933
+ const slice = row[fieldName];
4934
+ const snapshot = this.extractSnapshot(slice, cursorsEnabled);
4935
+ if (snapshot) crdtField.importRemote(snapshot);
4936
+ if (cursorsEnabled && slice && typeof slice === "object") {
4937
+ const cursors = slice.cursors;
4938
+ if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
4939
+ if (sid === this.sessionId) continue;
4940
+ if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
4941
+ }
4942
+ }
4943
+ }
4944
+ }
4945
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
4946
+ * locally yet (fresh device, memory-backed local DB after reload, …).
4947
+ * Used by `open()` when the local read came up empty. Subsequent
4948
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
4949
+ async fetchAndDispatchRow(table, id) {
4950
+ try {
4951
+ const recordId = parseRecordIdString(id);
4952
+ const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
4953
+ if (!row || typeof row !== "object") return;
4954
+ this.dispatchRow(table, row);
4955
+ } catch (e) {
4956
+ this.logger.warn({
4957
+ error: e,
4958
+ table,
4959
+ id,
4960
+ Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
4961
+ }, "Failed to fetch parent row for CRDT hydration");
4962
+ }
4963
+ }
4964
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
4965
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
4966
+ fieldHasCursor(table, field) {
4967
+ return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
4968
+ }
4969
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
4970
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
4971
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
4972
+ extractSnapshot(value, cursorsEnabled) {
4973
+ const asBytes = (v) => {
4974
+ if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
4975
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
4976
+ if (ArrayBuffer.isView(v)) {
4977
+ const view = v;
4978
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
4979
+ }
4980
+ if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
4981
+ };
4982
+ if (cursorsEnabled) {
4983
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
4984
+ return;
4985
+ }
4986
+ return asBytes(value);
4987
+ }
4988
+ killTableSubscription(table) {
4989
+ const uuid = this.liveByTable.get(table);
4990
+ if (uuid) {
4991
+ this.remote.query("KILL $uuid", { uuid }).catch((err) => {
4992
+ this.logger.debug({
4993
+ err,
4994
+ table,
4995
+ Category: "sp00ky-client::CrdtManager::killTableSubscription"
4996
+ }, "KILL of table LIVE failed (already closed?)");
4997
+ });
4998
+ this.liveByTable.delete(table);
4999
+ }
5000
+ }
5001
+ makeKey(table, recordId, field) {
5002
+ return `${table}:${recordId}:${field}`;
5003
+ }
5004
+ /**
5005
+ * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
5006
+ * typos, removed annotations, and stale schema codegen at the call site instead
5007
+ * of silently producing a non-CRDT writer.
5008
+ */
5009
+ assertCrdtField(table, field) {
5010
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) throw new Error(`openCrdtField: refusing unsafe field identifier '${field}' — must match [a-zA-Z_][a-zA-Z0-9_]*`);
5011
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
5012
+ if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
5013
+ const column = tableSchema.columns[field];
5014
+ if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
5015
+ if (!column.crdt) throw new Error(`openCrdtField: '${table}.${field}' is not annotated '@crdt' in the schema. Add '-- @crdt text' above the field's DEFINE FIELD and regenerate the client schema.`);
5016
+ }
5017
+ };
5018
+
5019
+ //#endregion
5020
+ //#region src/modules/feature-flag/index.ts
5021
+ const FEATURE_QUERY = "SELECT key, variant, payload FROM _00_user_feature";
5022
+ var FeatureFlagHandle = class {
5023
+ latest = {
5024
+ variant: void 0,
5025
+ payload: void 0
5026
+ };
5027
+ listeners = /* @__PURE__ */ new Set();
5028
+ unsubscribeFn = null;
5029
+ onCloseFn = null;
5030
+ closed = false;
5031
+ constructor(key, fallback) {
5032
+ this.key = key;
5033
+ this.fallback = fallback;
5034
+ }
5035
+ attach(unsubscribe) {
5036
+ this.unsubscribeFn?.();
5037
+ this.unsubscribeFn = unsubscribe;
5038
+ }
5039
+ detach() {
5040
+ this.unsubscribeFn?.();
5041
+ this.unsubscribeFn = null;
5042
+ }
5043
+ set(snapshot) {
5044
+ if (this.closed) return;
5045
+ this.latest = snapshot;
5046
+ for (const cb of this.listeners) cb(snapshot);
5047
+ }
5048
+ variant() {
5049
+ return this.latest.variant ?? this.fallback;
5050
+ }
5051
+ payload() {
5052
+ return this.latest.payload;
5053
+ }
5054
+ enabled() {
5055
+ const v = this.variant();
5056
+ return v !== void 0 && v !== "off";
5057
+ }
5058
+ subscribe(cb) {
5059
+ this.listeners.add(cb);
5060
+ cb({
5061
+ variant: this.variant(),
5062
+ payload: this.latest.payload
5063
+ });
5064
+ return () => {
5065
+ this.listeners.delete(cb);
5066
+ };
5067
+ }
5068
+ onClose(cb) {
5069
+ this.onCloseFn = cb;
5070
+ }
5071
+ close() {
5072
+ if (this.closed) return;
5073
+ this.closed = true;
5074
+ this.listeners.clear();
5075
+ this.detach();
5076
+ this.onCloseFn?.();
5077
+ }
5078
+ };
5079
+ var FeatureFlagModule = class {
5080
+ logger;
5081
+ handles = /* @__PURE__ */ new Set();
5082
+ authUnsubscribe = null;
5083
+ lastUserId = null;
5084
+ querySubscription = null;
5085
+ starting = false;
5086
+ ttl = "10m";
5087
+ snapshots = /* @__PURE__ */ new Map();
5088
+ loaded = false;
5089
+ constructor(deps) {
5090
+ this.deps = deps;
5091
+ this.logger = deps.logger.child({ service: "FeatureFlagModule" });
5092
+ }
5093
+ init() {
5094
+ if (this.authUnsubscribe) return;
5095
+ this.authUnsubscribe = this.deps.auth.subscribe((userId) => {
5096
+ if (userId === this.lastUserId) return;
5097
+ this.lastUserId = userId;
5098
+ this.refresh();
5099
+ });
5100
+ }
5101
+ feature(key, options = {}) {
5102
+ const handle = new FeatureFlagHandle(key, options.fallback);
5103
+ this.handles.add(handle);
5104
+ handle.onClose(() => this.handles.delete(handle));
5105
+ if (options.ttl) this.ttl = options.ttl;
5106
+ if (this.loaded) handle.set(this.snapshots.get(key) ?? {
5107
+ variant: void 0,
5108
+ payload: void 0
5109
+ });
5110
+ this.ensureStarted();
5111
+ return handle;
5112
+ }
5113
+ async closeAll() {
5114
+ this.authUnsubscribe?.();
5115
+ this.authUnsubscribe = null;
5116
+ this.teardownQuery();
5117
+ for (const handle of [...this.handles]) handle.close();
5118
+ }
5119
+ /** Auth changed: drop the old user's query/snapshots and re-observe. */
5120
+ async refresh() {
5121
+ this.teardownQuery();
5122
+ this.loaded = false;
5123
+ this.snapshots.clear();
5124
+ for (const handle of this.handles) handle.set({
5125
+ variant: void 0,
5126
+ payload: void 0
5127
+ });
5128
+ await this.ensureStarted();
5129
+ }
5130
+ teardownQuery() {
5131
+ this.querySubscription?.();
5132
+ this.querySubscription = null;
5133
+ }
5134
+ /** Start the single shared live query (idempotent; no-op with no handles). */
5135
+ async ensureStarted() {
5136
+ if (this.querySubscription || this.starting || this.handles.size === 0) return;
5137
+ this.starting = true;
5138
+ try {
5139
+ const hash = await this.deps.dataModule.query("_00_user_feature", FEATURE_QUERY, {}, this.ttl);
5140
+ this.deps.sync.enqueueDownEvent({
5141
+ type: "register",
5142
+ payload: { hash }
5143
+ });
5144
+ this.querySubscription = this.deps.dataModule.subscribe(hash, (records) => this.applyRecords(records), { immediate: true });
5145
+ } catch (err) {
5146
+ this.logger.warn({
5147
+ err,
5148
+ Category: "sp00ky-client::FeatureFlagModule::register"
5149
+ }, "Failed to register feature flag query");
5150
+ } finally {
5151
+ this.starting = false;
5152
+ }
5153
+ }
5154
+ /** Live query result → per-key snapshots → push to every active handle. */
5155
+ applyRecords(records) {
5156
+ this.snapshots.clear();
5157
+ for (const row of records ?? []) if (row && typeof row.key === "string") this.snapshots.set(row.key, {
5158
+ variant: row.variant,
5159
+ payload: row.payload
5160
+ });
5161
+ this.loaded = true;
5162
+ for (const handle of this.handles) handle.set(this.snapshots.get(handle.key) ?? {
5163
+ variant: void 0,
5164
+ payload: void 0
5165
+ });
5166
+ }
5167
+ };
5168
+
2841
5169
  //#endregion
2842
5170
  //#region src/services/persistence/localstorage.ts
2843
5171
  var LocalStoragePersistenceClient = class {
@@ -2870,7 +5198,7 @@ var SurrealDBPersistenceClient = class {
2870
5198
  }
2871
5199
  async set(key, val) {
2872
5200
  try {
2873
- const id = parseRecordIdString(`_spooky_kv:${key}`);
5201
+ const id = parseRecordIdString(`_00_kv:${key}`);
2874
5202
  await this.db.query(surql.seal(surql.upsert("id", "data")), {
2875
5203
  id,
2876
5204
  data: { val }
@@ -2878,41 +5206,111 @@ var SurrealDBPersistenceClient = class {
2878
5206
  } catch (error) {
2879
5207
  this.logger.error({
2880
5208
  error,
2881
- Category: "spooky-client::SurrealDBPersistenceClient::set"
5209
+ Category: "sp00ky-client::SurrealDBPersistenceClient::set"
2882
5210
  }, "Failed to set KV");
2883
5211
  throw error;
2884
5212
  }
2885
5213
  }
2886
5214
  async get(key) {
2887
5215
  try {
2888
- const id = parseRecordIdString(`_spooky_kv:${key}`);
5216
+ const id = parseRecordIdString(`_00_kv:${key}`);
2889
5217
  const [result] = await this.db.query(surql.seal(surql.selectById("id", ["val"])), { id });
2890
5218
  if (!result?.val) return null;
2891
5219
  return result.val;
2892
5220
  } catch (error) {
2893
5221
  this.logger.warn({
2894
5222
  error,
2895
- Category: "spooky-client::SurrealDBPersistenceClient::get"
5223
+ Category: "sp00ky-client::SurrealDBPersistenceClient::get"
2896
5224
  }, "Failed to get KV");
2897
5225
  return null;
2898
5226
  }
2899
5227
  }
2900
5228
  async remove(key) {
2901
5229
  try {
2902
- const id = parseRecordIdString(`_spooky_kv:${key}`);
5230
+ const id = parseRecordIdString(`_00_kv:${key}`);
2903
5231
  await this.db.query(surql.seal(surql.delete("id")), { id });
2904
5232
  } catch (err) {
2905
5233
  this.logger.info({
2906
5234
  err,
2907
- Category: "spooky-client::SurrealDBPersistenceClient::remove"
5235
+ Category: "sp00ky-client::SurrealDBPersistenceClient::remove"
2908
5236
  }, "Failed to delete KV");
2909
5237
  }
2910
5238
  }
2911
5239
  };
2912
5240
 
2913
5241
  //#endregion
2914
- //#region src/spooky.ts
2915
- var SpookyClient = class {
5242
+ //#region src/services/persistence/resilient.ts
5243
+ var ResilientPersistenceClient = class {
5244
+ logger;
5245
+ constructor(inner, logger) {
5246
+ this.inner = inner;
5247
+ this.logger = logger.child({ service: "ResilientPersistenceClient" });
5248
+ }
5249
+ set(key, value) {
5250
+ return this.inner.set(key, value);
5251
+ }
5252
+ async get(key) {
5253
+ try {
5254
+ return await this.inner.get(key);
5255
+ } catch (e) {
5256
+ this.logger.warn({
5257
+ key,
5258
+ error: e,
5259
+ Category: "sp00ky-client::ResilientPersistenceClient::get"
5260
+ }, "Persistence read failed, dropping key");
5261
+ await this.inner.remove(key).catch((removeErr) => {
5262
+ this.logger.debug({
5263
+ key,
5264
+ error: removeErr,
5265
+ Category: "sp00ky-client::ResilientPersistenceClient::get"
5266
+ }, "Failed to drop corrupt persistence key");
5267
+ });
5268
+ return null;
5269
+ }
5270
+ }
5271
+ remove(key) {
5272
+ return this.inner.remove(key);
5273
+ }
5274
+ };
5275
+
5276
+ //#endregion
5277
+ //#region src/sp00ky.ts
5278
+ var BucketHandle = class {
5279
+ constructor(bucketName, remote) {
5280
+ this.bucketName = bucketName;
5281
+ this.remote = remote;
5282
+ }
5283
+ async put(path, content) {
5284
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
5285
+ }
5286
+ async get(path) {
5287
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
5288
+ return result;
5289
+ }
5290
+ async delete(path) {
5291
+ await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
5292
+ }
5293
+ async exists(path) {
5294
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
5295
+ return result;
5296
+ }
5297
+ async head(path) {
5298
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".head();`);
5299
+ return result;
5300
+ }
5301
+ async copy(sourcePath, targetPath) {
5302
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".copy($target);`, { target: targetPath });
5303
+ }
5304
+ async rename(sourcePath, targetPath) {
5305
+ await this.remote.query(`RETURN f"${this.bucketName}:/${sourcePath}".rename($target);`, { target: targetPath });
5306
+ }
5307
+ async list(prefix) {
5308
+ const p = prefix ?? "";
5309
+ const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${p}".list();`);
5310
+ return result;
5311
+ }
5312
+ };
5313
+ var Sp00kyClient = class {
2916
5314
  local;
2917
5315
  remote;
2918
5316
  persistenceClient;
@@ -2921,6 +5319,8 @@ var SpookyClient = class {
2921
5319
  dataModule;
2922
5320
  sync;
2923
5321
  devTools;
5322
+ crdtManager;
5323
+ featureFlags;
2924
5324
  logger;
2925
5325
  auth;
2926
5326
  streamProcessor;
@@ -2933,33 +5333,64 @@ var SpookyClient = class {
2933
5333
  get pendingMutationCount() {
2934
5334
  return this.sync.pendingMutationCount;
2935
5335
  }
5336
+ /** Number of times the initial list_ref LIVE subscription retried on
5337
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
5338
+ * pre-emptive user-table creation got there first; >0 when LIVE
5339
+ * registration hit a "table not found" race. Exposed so the e2e
5340
+ * suite can guard the pre-emptive path against regression. */
5341
+ get liveRetryCount() {
5342
+ return this.sync.liveRetryCount;
5343
+ }
2936
5344
  subscribeToPendingMutations(cb) {
2937
5345
  return this.sync.subscribeToPendingMutations(cb);
2938
5346
  }
5347
+ /** Current sync-health snapshot. See {@link Sp00kyConfig.syncHealth}. */
5348
+ get syncHealth() {
5349
+ return this.sync.syncHealth;
5350
+ }
5351
+ /**
5352
+ * Observe sync health. Fires immediately with the current status and again
5353
+ * on every healthy↔degraded transition. Returns an unsubscribe.
5354
+ */
5355
+ subscribeToSyncHealth(cb) {
5356
+ return this.sync.subscribeToSyncHealth(cb);
5357
+ }
2939
5358
  constructor(config) {
2940
5359
  this.config = config;
2941
- const logger = createLogger(config.logLevel ?? "info", config.otelEndpoint);
2942
- this.logger = logger.child({ service: "SpookyClient" });
5360
+ const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
5361
+ this.logger = logger.child({ service: "Sp00kyClient" });
2943
5362
  this.logger.info({
2944
5363
  config: {
2945
5364
  ...config,
2946
5365
  schema: "[SchemaStructure]"
2947
5366
  },
2948
- Category: "spooky-client::SpookyClient::constructor"
2949
- }, "SpookyClient initialized");
5367
+ Category: "sp00ky-client::Sp00kyClient::constructor"
5368
+ }, "Sp00kyClient initialized");
2950
5369
  this.local = new LocalDatabaseService(this.config.database, logger);
2951
5370
  this.remote = new RemoteDatabaseService(this.config.database, logger);
2952
5371
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
2953
5372
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
2954
5373
  else this.persistenceClient = config.persistenceClient;
5374
+ this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
2955
5375
  this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
2956
5376
  this.migrator = new LocalMigrator(this.local, logger);
2957
5377
  this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
2958
5378
  this.dataModule.onStreamUpdate(update);
2959
5379
  }, logger);
5380
+ this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
2960
5381
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
2961
5382
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
2962
- this.sync = new SpookySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger);
5383
+ this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, {
5384
+ refSyncIntervalMs: this.config.refSyncIntervalMs,
5385
+ anonymousLiveQueries: this.config.enableAnonymousLiveQueries,
5386
+ degradeAfterConsecutiveFailures: this.config.syncHealth === false ? 0 : this.config.syncHealth?.degradeAfterConsecutiveFailures ?? 3
5387
+ });
5388
+ this.featureFlags = new FeatureFlagModule({
5389
+ dataModule: this.dataModule,
5390
+ sync: this.sync,
5391
+ auth: this.auth,
5392
+ logger
5393
+ });
2963
5394
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
2964
5395
  this.streamProcessor.addReceiver(this.devTools);
2965
5396
  this.setupCallbacks();
@@ -2968,6 +5399,27 @@ var SpookyClient = class {
2968
5399
  * Setup direct callbacks instead of event subscriptions
2969
5400
  */
2970
5401
  setupCallbacks() {
5402
+ this.dataModule.onQueryStatusChange = (queryHash, status) => {
5403
+ this.devTools.logEvent("QUERY_STATUS_CHANGED", {
5404
+ queryHash,
5405
+ status
5406
+ });
5407
+ };
5408
+ this.dataModule.onHeartbeat = (queryHash) => {
5409
+ this.sync.heartbeatQuery(queryHash).catch((err) => {
5410
+ this.logger.warn({
5411
+ err,
5412
+ queryHash,
5413
+ Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
5414
+ }, "TTL heartbeat failed");
5415
+ });
5416
+ };
5417
+ this.dataModule.onDeregister = (queryHash) => {
5418
+ this.sync.enqueueDownEvent({
5419
+ type: "cleanup",
5420
+ payload: { hash: queryHash }
5421
+ });
5422
+ };
2971
5423
  this.dataModule.onMutation((mutations) => {
2972
5424
  this.devTools.onMutation(mutations);
2973
5425
  if (mutations.length > 0) this.sync.enqueueMutation(mutations);
@@ -2975,6 +5427,22 @@ var SpookyClient = class {
2975
5427
  this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
2976
5428
  this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
2977
5429
  });
5430
+ this.sync.engineEvents.subscribe("SYNC_REMOTE_DATA_INGESTED", (event) => {
5431
+ try {
5432
+ const records = event.payload?.records ?? [];
5433
+ for (const row of records) {
5434
+ const id = row?.id;
5435
+ const table = id && typeof id === "object" && id.table !== void 0 ? String(id.table) : void 0;
5436
+ if (!table) continue;
5437
+ this.crdtManager.applyRow(table, row);
5438
+ }
5439
+ } catch (err) {
5440
+ this.logger.debug({
5441
+ err,
5442
+ Category: "sp00ky-client::engineEvents::ingested"
5443
+ }, "applyRow forwarding from sync ingest failed");
5444
+ }
5445
+ });
2978
5446
  this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
2979
5447
  this.devTools.logEvent("LOCAL_QUERY", event.payload);
2980
5448
  });
@@ -2983,44 +5451,90 @@ var SpookyClient = class {
2983
5451
  });
2984
5452
  }
2985
5453
  async init() {
2986
- this.logger.info({ Category: "spooky-client::SpookyClient::init" }, "SpookyClient initialization started");
5454
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
2987
5455
  try {
2988
- const clientId = this.config.clientId ?? await this.loadOrGenerateClientId();
2989
- this.persistClientId(clientId);
2990
- this.logger.debug({
2991
- clientId,
2992
- Category: "spooky-client::SpookyClient::init"
2993
- }, "Client ID loaded");
2994
5456
  await this.local.connect();
2995
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Local database connected");
5457
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
2996
5458
  await this.migrator.provision(this.config.schemaSurql);
2997
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Schema provisioned");
5459
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
2998
5460
  await this.remote.connect();
2999
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Remote database connected");
5461
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
3000
5462
  await this.streamProcessor.init();
3001
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "StreamProcessor initialized");
5463
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
5464
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
3002
5465
  await this.auth.init();
3003
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Auth initialized");
3004
- await this.dataModule.init();
3005
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "DataModule initialized");
3006
- await this.sync.init(clientId);
3007
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Sync initialized");
3008
- this.logger.info({ Category: "spooky-client::SpookyClient::init" }, "SpookyClient initialization completed successfully");
5466
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
5467
+ const sessionId = await this.fetchSessionId();
5468
+ await this.dataModule.init(sessionId);
5469
+ this.crdtManager.setSessionId(sessionId);
5470
+ this.logger.debug({
5471
+ sessionId,
5472
+ Category: "sp00ky-client::Sp00kyClient::init"
5473
+ }, "DataModule initialized");
5474
+ this.auth.subscribe(async (userId) => {
5475
+ this.dataModule.setCurrentUserId(userId);
5476
+ this.streamProcessor.setSessionAuth(this.auth.currentUser?.id ? encodeRecordId(this.auth.currentUser.id) : null, this.auth.access);
5477
+ const next = await this.fetchSessionId();
5478
+ this.dataModule.setSessionId(next);
5479
+ this.crdtManager.setSessionId(next);
5480
+ try {
5481
+ await this.sync.setCurrentUserId(userId);
5482
+ } catch (e) {
5483
+ this.logger.error({
5484
+ error: e,
5485
+ Category: "sp00ky-client::Sp00kyClient::authChange"
5486
+ }, "sync.setCurrentUserId failed");
5487
+ }
5488
+ });
5489
+ await this.sync.init();
5490
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
5491
+ this.featureFlags.init();
5492
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "FeatureFlagModule initialized");
5493
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
3009
5494
  } catch (e) {
3010
5495
  this.logger.error({
3011
5496
  error: e,
3012
- Category: "spooky-client::SpookyClient::init"
3013
- }, "SpookyClient initialization failed");
5497
+ Category: "sp00ky-client::Sp00kyClient::init"
5498
+ }, "Sp00kyClient initialization failed");
3014
5499
  throw e;
3015
5500
  }
3016
5501
  }
3017
5502
  async close() {
5503
+ await this.featureFlags.closeAll();
5504
+ this.crdtManager.closeAll();
3018
5505
  await this.local.close();
3019
5506
  await this.remote.close();
3020
5507
  }
5508
+ /**
5509
+ * Subscribe to a feature flag for the current user. Returns a
5510
+ * `FeatureFlagHandle` whose `variant()`, `payload()` and `enabled()`
5511
+ * accessors reflect the latest assignment from `_00_user_feature`,
5512
+ * and whose `subscribe(cb)` fires whenever that assignment changes.
5513
+ *
5514
+ * Permissions are enforced by SurrealDB: a client can only ever see
5515
+ * its own row, and cannot create or modify assignments.
5516
+ */
5517
+ feature(key, options) {
5518
+ return this.featureFlags.feature(key, options);
5519
+ }
3021
5520
  authenticate(token) {
3022
5521
  return this.remote.getClient().authenticate(token);
3023
5522
  }
5523
+ /**
5524
+ * Open a CRDT field for collaborative editing.
5525
+ * Returns a CrdtField with a LoroDoc that can be bound to any editor.
5526
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
5527
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
5528
+ */
5529
+ async openCrdtField(table, recordId, field, fallbackText) {
5530
+ return this.crdtManager.open(table, recordId, field, fallbackText);
5531
+ }
5532
+ /**
5533
+ * Close a CRDT field when editing is done.
5534
+ */
5535
+ closeCrdtField(table, recordId, field) {
5536
+ this.crdtManager.close(table, recordId, field);
5537
+ }
3024
5538
  deauthenticate() {
3025
5539
  return this.remote.getClient().invalidate();
3026
5540
  }
@@ -3030,7 +5544,18 @@ var SpookyClient = class {
3030
5544
  async initQuery(table, q, ttl) {
3031
5545
  const tableSchema = this.config.schema.tables.find((t) => t.name === table);
3032
5546
  if (!tableSchema) throw new Error(`Table ${table} not found`);
3033
- const hash = await this.dataModule.query(table, q.selectQuery.query, parseParams(tableSchema.columns, q.selectQuery.vars ?? {}), ttl);
5547
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
5548
+ const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
5549
+ if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
5550
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
5551
+ await this.dataModule.applyHydration(hash, rows ?? []);
5552
+ } catch (err) {
5553
+ this.logger.warn({
5554
+ err,
5555
+ hash,
5556
+ Category: "sp00ky-client::Sp00kyClient::instantHydrate"
5557
+ }, "Instant hydrate failed; proceeding with registration");
5558
+ }
3034
5559
  await this.sync.enqueueDownEvent({
3035
5560
  type: "register",
3036
5561
  payload: { hash }
@@ -3044,9 +5569,38 @@ var SpookyClient = class {
3044
5569
  async subscribe(queryHash, callback, options) {
3045
5570
  return this.dataModule.subscribe(queryHash, callback, options);
3046
5571
  }
5572
+ /**
5573
+ * Opt-in eager teardown for a query whose last subscriber has gone away
5574
+ * (e.g. a viewport-windowed list cancelling an off-screen window). No-op
5575
+ * while any subscriber remains. Tears down the remote `_00_query` view +
5576
+ * local WASM view instead of waiting for the TTL sweep. Default behavior
5577
+ * (no call here) keeps the view resident for cheap re-subscription.
5578
+ */
5579
+ deregisterQuery(queryHash) {
5580
+ this.dataModule.deregisterQuery(queryHash);
5581
+ }
5582
+ /**
5583
+ * Subscribe to a query's fetch-status changes (idle/fetching). With
5584
+ * `{ immediate: true }` the callback fires synchronously with the current
5585
+ * status. Powers the `useQuery` hook's `isFetching()` accessor.
5586
+ */
5587
+ subscribeQueryStatus(queryHash, callback, options) {
5588
+ return this.dataModule.subscribeStatus(queryHash, callback, options);
5589
+ }
5590
+ /**
5591
+ * Report the frontend processing time (ms) a client framework spent applying
5592
+ * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
5593
+ * surface the "frontend" phase of the per-query timing breakdown.
5594
+ */
5595
+ reportFrontendTiming(queryHash, ms) {
5596
+ this.dataModule.recordFrontendTiming(queryHash, ms);
5597
+ }
3047
5598
  run(backend, path, payload, options) {
3048
5599
  return this.dataModule.run(backend, path, payload, options);
3049
5600
  }
5601
+ bucket(name) {
5602
+ return new BucketHandle(name, this.remote);
5603
+ }
3050
5604
  create(id, data) {
3051
5605
  return this.dataModule.create(id, data);
3052
5606
  }
@@ -3059,24 +5613,25 @@ var SpookyClient = class {
3059
5613
  async useRemote(fn) {
3060
5614
  return fn(this.remote.getClient());
3061
5615
  }
3062
- persistClientId(id) {
5616
+ /**
5617
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
5618
+ * query-id hashing so two sessions for the same user get distinct
5619
+ * `_00_query` rows. Returns empty string if the query fails (we still
5620
+ * boot, just without session scoping for IDs).
5621
+ */
5622
+ async fetchSessionId() {
3063
5623
  try {
3064
- this.persistenceClient.set("spooky_client_id", id);
5624
+ const [sid] = await this.remote.query("RETURN <string>session::id()");
5625
+ return typeof sid === "string" ? sid : "";
3065
5626
  } catch (e) {
3066
5627
  this.logger.warn({
3067
5628
  error: e,
3068
- Category: "spooky-client::SpookyClient::persistClientId"
3069
- }, "Failed to persist client ID");
5629
+ Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
5630
+ }, "Failed to fetch session::id() — proceeding with empty salt");
5631
+ return "";
3070
5632
  }
3071
5633
  }
3072
- async loadOrGenerateClientId() {
3073
- const clientId = await this.persistenceClient.get("spooky_client_id");
3074
- if (clientId) return clientId;
3075
- const newId = generateId();
3076
- await this.persistClientId(newId);
3077
- return newId;
3078
- }
3079
5634
  };
3080
5635
 
3081
5636
  //#endregion
3082
- export { AuthEventTypes, AuthService, SpookyClient, createAuthEventSystem };
5637
+ export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };