@spooky-sync/core 0.0.1-canary.9 → 0.0.1-canary.90

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