@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.70

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 (64) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +299 -369
  3. package/dist/index.js +2275 -399
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +460 -0
  7. package/package.json +37 -7
  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 +6 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +3 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +17 -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 +662 -108
  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 +77 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/ref-tables.test.ts +56 -0
  30. package/src/modules/ref-tables.ts +57 -0
  31. package/src/modules/sync/engine.ts +97 -37
  32. package/src/modules/sync/events/index.ts +3 -2
  33. package/src/modules/sync/queue/queue-down.ts +5 -4
  34. package/src/modules/sync/queue/queue-up.ts +14 -13
  35. package/src/modules/sync/scheduler.ts +2 -2
  36. package/src/modules/sync/sync.ts +553 -58
  37. package/src/modules/sync/utils.test.ts +239 -2
  38. package/src/modules/sync/utils.ts +166 -17
  39. package/src/otel/index.ts +127 -0
  40. package/src/services/database/database.ts +11 -11
  41. package/src/services/database/events/index.ts +2 -1
  42. package/src/services/database/local-migrator.ts +21 -21
  43. package/src/services/database/local.test.ts +33 -0
  44. package/src/services/database/local.ts +192 -32
  45. package/src/services/database/remote.ts +13 -13
  46. package/src/services/logger/index.ts +6 -101
  47. package/src/services/persistence/localstorage.ts +2 -2
  48. package/src/services/persistence/resilient.ts +41 -0
  49. package/src/services/persistence/surrealdb.ts +9 -9
  50. package/src/services/stream-processor/index.ts +205 -36
  51. package/src/services/stream-processor/permissions.test.ts +47 -0
  52. package/src/services/stream-processor/permissions.ts +53 -0
  53. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  54. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  55. package/src/services/stream-processor/wasm-types.ts +18 -2
  56. package/src/sp00ky.auth-order.test.ts +65 -0
  57. package/src/sp00ky.ts +582 -0
  58. package/src/types.ts +132 -13
  59. package/src/utils/index.ts +35 -13
  60. package/src/utils/parser.ts +3 -2
  61. package/src/utils/surql.ts +24 -15
  62. package/src/utils/withRetry.test.ts +1 -1
  63. package/tsdown.config.ts +55 -1
  64. 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,9 +382,38 @@ 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;
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;
216
417
  constructor(cache, local, schema, logger, streamDebounceTime = 100) {
217
418
  this.cache = cache;
218
419
  this.local = local;
@@ -220,8 +421,35 @@ var DataModule = class {
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,265 @@ 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
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
751
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
752
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
753
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
754
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
755
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
756
+ */
757
+ async applyHydration(hash, rows) {
758
+ const queryState = this.activeQueries.get(hash);
759
+ if (!queryState) return;
760
+ queryState.hydrated = true;
761
+ if (rows.length === 0) return;
762
+ const tableName = queryState.config.tableName;
763
+ const batch = rows.map((record) => ({
764
+ table: tableName,
765
+ op: "CREATE",
766
+ record,
767
+ version: record._00_rv || 1
768
+ }));
769
+ await this.cache.saveBatch(batch);
770
+ queryState.config.remoteArray = rows.map((r) => [encodeRecordId(r.id), r._00_rv || 1]);
771
+ queryState.records = await this.materializeRecords(queryState);
772
+ const subscribers = this.subscriptions.get(hash);
773
+ if (subscribers) for (const cb of subscribers) cb(queryState.records);
774
+ }
775
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
776
+ hasSubscribers(hash) {
777
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
778
+ }
779
+ /**
780
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
781
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
782
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
783
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
784
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
785
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
786
+ *
787
+ * NOTE: most queries should NOT use this — the default keep-alive on
788
+ * unsubscribe avoids re-registration churn on navigation.
789
+ */
790
+ deregisterQuery(hash) {
791
+ if (this.hasSubscribers(hash)) return;
792
+ if (!this.activeQueries.has(hash)) return;
793
+ this.onDeregister?.(hash);
794
+ }
795
+ /**
796
+ * Final local teardown after the remote `_00_query` row was deleted: free the
797
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
798
+ * (`cleanupQuery`) guarantees no subscriber remains.
799
+ */
800
+ finalizeDeregister(hash) {
801
+ const qs = this.activeQueries.get(hash);
802
+ if (qs?.ttlTimer) {
803
+ clearTimeout(qs.ttlTimer);
804
+ qs.ttlTimer = null;
805
+ }
806
+ const debounce = this.debounceTimers.get(hash);
807
+ if (debounce) {
808
+ clearTimeout(debounce);
809
+ this.debounceTimers.delete(hash);
810
+ }
811
+ this.cache.unregisterQuery(hash);
812
+ this.activeQueries.delete(hash);
813
+ this.subscriptions.delete(hash);
814
+ }
815
+ /**
359
816
  * Get query state by id (for sync and devtools)
360
817
  */
361
818
  getQueryById(id) {
@@ -367,12 +824,15 @@ var DataModule = class {
367
824
  getActiveQueries() {
368
825
  return Array.from(this.activeQueries.values());
369
826
  }
827
+ getActiveQueryHashes() {
828
+ return Array.from(this.activeQueries.keys());
829
+ }
370
830
  async updateQueryLocalArray(id, localArray) {
371
831
  const queryState = this.activeQueries.get(id);
372
832
  if (!queryState) {
373
833
  this.logger.warn({
374
834
  id,
375
- Category: "spooky-client::DataModule::updateQueryLocalArray"
835
+ Category: "sp00ky-client::DataModule::updateQueryLocalArray"
376
836
  }, "Query to update local array not found");
377
837
  return;
378
838
  }
@@ -387,7 +847,7 @@ var DataModule = class {
387
847
  if (!queryState) {
388
848
  this.logger.warn({
389
849
  hash,
390
- Category: "spooky-client::DataModule::updateQueryRemoteArray"
850
+ Category: "sp00ky-client::DataModule::updateQueryRemoteArray"
391
851
  }, "Query to update remote array not found");
392
852
  return;
393
853
  }
@@ -404,8 +864,7 @@ var DataModule = class {
404
864
  async notifyQuerySynced(queryHash) {
405
865
  const queryState = this.activeQueries.get(queryHash);
406
866
  if (!queryState) return;
407
- const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
408
- const newRecords = records || [];
867
+ const newRecords = await this.materializeRecords(queryState);
409
868
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
410
869
  queryState.records = newRecords;
411
870
  if (changed || queryState.updateCount === 0) {
@@ -431,6 +890,7 @@ var DataModule = class {
431
890
  max_retries: options?.max_retries ?? 3,
432
891
  retry_strategy: options?.retry_strategy ?? "linear"
433
892
  };
893
+ if (options?.timeout != null) record.timeout = options.timeout;
434
894
  if (options?.assignedTo) record.assigned_to = options.assignedTo;
435
895
  const recordId = `${tableName}:${generateId()}`;
436
896
  await this.create(recordId, record);
@@ -444,7 +904,7 @@ var DataModule = class {
444
904
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
445
905
  const rid = parseRecordIdString(id);
446
906
  const params = parseParams(tableSchema.columns, data);
447
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
907
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
448
908
  const dataKeys = Object.keys(params).map((key) => ({
449
909
  key,
450
910
  variable: `data_${key}`
@@ -474,7 +934,7 @@ var DataModule = class {
474
934
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
475
935
  this.logger.debug({
476
936
  id,
477
- Category: "spooky-client::DataModule::create"
937
+ Category: "sp00ky-client::DataModule::create"
478
938
  }, "Record created");
479
939
  return target;
480
940
  }
@@ -487,10 +947,10 @@ var DataModule = class {
487
947
  if (!tableSchema) throw new Error(`Table ${tableName} not found`);
488
948
  const rid = parseRecordIdString(id);
489
949
  const params = parseParams(tableSchema.columns, data);
490
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
950
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
491
951
  const [beforeRecord] = await withRetry(this.logger, () => this.local.query("SELECT * FROM ONLY $id", { id: rid }));
492
952
  const query = surql.seal(surql.tx([
493
- surql.updateSet("id", [{ statement: "spooky_rv += 1" }]),
953
+ surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
494
954
  surql.let("updated", surql.updateMerge("id", "data")),
495
955
  surql.createMutation("update", "mid", "id", "data"),
496
956
  surql.returnObject([{
@@ -505,14 +965,14 @@ var DataModule = class {
505
965
  }));
506
966
  const updatedFields = { id: target.id };
507
967
  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;
968
+ if ("_00_rv" in target) updatedFields._00_rv = target._00_rv;
509
969
  this.replaceRecordInQueries(updatedFields);
510
970
  const parsedRecord = parseParams(tableSchema.columns, target);
511
971
  await this.cache.save({
512
972
  table,
513
973
  op: "UPDATE",
514
974
  record: parsedRecord,
515
- version: target.spooky_rv
975
+ version: target._00_rv
516
976
  }, true);
517
977
  const pushEventOptions = parseUpdateOptions(id, data, options);
518
978
  const mutationEvent = {
@@ -527,7 +987,7 @@ var DataModule = class {
527
987
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
528
988
  this.logger.debug({
529
989
  id,
530
- Category: "spooky-client::DataModule::update"
990
+ Category: "sp00ky-client::DataModule::update"
531
991
  }, "Record updated");
532
992
  return target;
533
993
  }
@@ -538,13 +998,32 @@ var DataModule = class {
538
998
  const tableName = extractTablePart(id);
539
999
  if (!this.schema.tables.find((t) => t.name === tableName)) throw new Error(`Table ${tableName} not found`);
540
1000
  const rid = parseRecordIdString(id);
541
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1001
+ const mutationId = parseRecordIdString(`_00_pending_mutations:${Date.now()}`);
1002
+ const [beforeRecords] = await this.local.query("SELECT * FROM ONLY $id", { id: rid });
1003
+ const beforeRecord = beforeRecords ?? {};
542
1004
  const query = surql.seal(surql.tx([surql.delete("id"), surql.createMutation("delete", "mid", "id")]));
543
1005
  await withRetry(this.logger, () => this.local.execute(query, {
544
1006
  id: rid,
545
1007
  mid: mutationId
546
1008
  }));
547
- await this.cache.delete(table, id, true);
1009
+ try {
1010
+ await this.cache.delete(table, id, true, beforeRecord);
1011
+ } catch (err) {
1012
+ this.logger.error({
1013
+ err,
1014
+ id,
1015
+ Category: "sp00ky-client::DataModule::delete"
1016
+ }, "SSP delete-ingest failed; relying on query re-materialize to reflect the delete");
1017
+ }
1018
+ for (const [queryHash, queryState] of this.activeQueries) if (queryState.config.tableName === tableName) try {
1019
+ await this.notifyQuerySynced(queryHash);
1020
+ } catch (err) {
1021
+ this.logger.error({
1022
+ err,
1023
+ queryHash,
1024
+ Category: "sp00ky-client::DataModule::delete"
1025
+ }, "notifyQuerySynced failed after delete");
1026
+ }
548
1027
  const mutationEvent = {
549
1028
  type: "delete",
550
1029
  mutation_id: mutationId,
@@ -553,7 +1032,7 @@ var DataModule = class {
553
1032
  for (const callback of this.mutationCallbacks) callback([mutationEvent]);
554
1033
  this.logger.debug({
555
1034
  id,
556
- Category: "spooky-client::DataModule::delete"
1035
+ Category: "sp00ky-client::DataModule::delete"
557
1036
  }, "Record deleted");
558
1037
  }
559
1038
  /**
@@ -568,14 +1047,14 @@ var DataModule = class {
568
1047
  this.logger.info({
569
1048
  id,
570
1049
  tableName,
571
- Category: "spooky-client::DataModule::rollbackCreate"
1050
+ Category: "sp00ky-client::DataModule::rollbackCreate"
572
1051
  }, "Rolled back optimistic create");
573
1052
  } catch (err) {
574
1053
  this.logger.error({
575
1054
  err,
576
1055
  id,
577
1056
  tableName,
578
- Category: "spooky-client::DataModule::rollbackCreate"
1057
+ Category: "sp00ky-client::DataModule::rollbackCreate"
579
1058
  }, "Failed to rollback create");
580
1059
  }
581
1060
  }
@@ -596,20 +1075,20 @@ var DataModule = class {
596
1075
  table: tableName,
597
1076
  op: "UPDATE",
598
1077
  record: parsedRecord,
599
- version: beforeRecord.spooky_rv || 1
1078
+ version: beforeRecord._00_rv || 1
600
1079
  }, true);
601
1080
  await this.replaceRecordInQueries(beforeRecord);
602
1081
  this.logger.info({
603
1082
  id,
604
1083
  tableName,
605
- Category: "spooky-client::DataModule::rollbackUpdate"
1084
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
606
1085
  }, "Rolled back optimistic update");
607
1086
  } catch (err) {
608
1087
  this.logger.error({
609
1088
  err,
610
1089
  id,
611
1090
  tableName,
612
- Category: "spooky-client::DataModule::rollbackUpdate"
1091
+ Category: "sp00ky-client::DataModule::rollbackUpdate"
613
1092
  }, "Failed to rollback update");
614
1093
  }
615
1094
  }
@@ -637,24 +1116,53 @@ var DataModule = class {
637
1116
  ttl,
638
1117
  tableName
639
1118
  });
640
- const { localArray } = this.cache.registerQuery({
1119
+ const t0 = performance.now();
1120
+ const { localArray, registrationTimings } = this.cache.registerQuery({
641
1121
  queryHash: hash,
642
1122
  surql: surqlString,
643
1123
  params,
644
1124
  ttl: new Duration(ttl),
645
1125
  lastActiveAt: /* @__PURE__ */ new Date()
646
1126
  });
647
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
1127
+ const registrationTime = performance.now() - t0;
1128
+ queryState.registrationTimings = {
1129
+ parseMs: registrationTimings?.parseMs ?? null,
1130
+ planMs: registrationTimings?.planMs ?? null,
1131
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
1132
+ wallMs: registrationTime
1133
+ };
1134
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
1135
+ "localArray",
1136
+ "registrationTime",
1137
+ "rowCount"
1138
+ ])), {
648
1139
  id: recordId,
649
- localArray
1140
+ localArray,
1141
+ registrationTime,
1142
+ rowCount: localArray.length
650
1143
  }));
1144
+ const windowMat = buildWindowMaterialization(surqlString);
1145
+ if (windowMat && localArray.length > 0) try {
1146
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
1147
+ const [seeded] = await this.local.query(windowMat.query, {
1148
+ ...params,
1149
+ __win: winIds
1150
+ });
1151
+ queryState.records = seeded || [];
1152
+ } catch (err) {
1153
+ this.logger.warn({
1154
+ err,
1155
+ hash,
1156
+ Category: "sp00ky-client::DataModule::createAndRegisterQuery"
1157
+ }, "Failed to seed windowed initial records from localArray");
1158
+ }
651
1159
  this.activeQueries.set(hash, queryState);
652
- this.startTTLHeartbeat(queryState);
1160
+ this.startTTLHeartbeat(queryState, hash);
653
1161
  this.logger.debug({
654
1162
  hash,
655
1163
  tableName,
656
1164
  recordCount: queryState.records.length,
657
- Category: "spooky-client::DataModule::query"
1165
+ Category: "sp00ky-client::DataModule::query"
658
1166
  }, "Query registered");
659
1167
  return hash;
660
1168
  }
@@ -671,8 +1179,12 @@ var DataModule = class {
671
1179
  localArray: [],
672
1180
  remoteArray: [],
673
1181
  lastActiveAt: /* @__PURE__ */ new Date(),
1182
+ createdAt: /* @__PURE__ */ new Date(),
674
1183
  ttl,
675
- tableName
1184
+ tableName,
1185
+ updateCount: 0,
1186
+ rowCount: 0,
1187
+ errorCount: 0
676
1188
  }
677
1189
  }));
678
1190
  configRecord = createdRecord;
@@ -683,46 +1195,67 @@ var DataModule = class {
683
1195
  params: parseParams(tableSchema.columns, configRecord.params)
684
1196
  };
685
1197
  let records = [];
686
- try {
1198
+ if (buildWindowMaterialization(surqlString) === null) try {
687
1199
  const [result] = await this.local.query(surqlString, params);
688
1200
  records = result || [];
689
1201
  } catch (err) {
690
1202
  this.logger.warn({
691
1203
  err,
692
- Category: "spooky-client::DataModule::createNewQuery"
1204
+ Category: "sp00ky-client::DataModule::createNewQuery"
693
1205
  }, "Failed to load initial cached records");
694
1206
  }
1207
+ const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
1208
+ const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
695
1209
  return {
696
1210
  config,
697
1211
  records,
698
1212
  ttlTimer: null,
699
1213
  ttlDurationMs: parseDuration(ttl),
700
- updateCount: 0
1214
+ updateCount: persistedUpdateCount,
1215
+ materializationSamples: [],
1216
+ lastIngestLatencyMs: null,
1217
+ errorCount: persistedErrorCount,
1218
+ status: "idle",
1219
+ phaseSamples: {},
1220
+ phaseLast: {},
1221
+ registrationTimings: {
1222
+ parseMs: null,
1223
+ planMs: null,
1224
+ snapshotMs: null,
1225
+ wallMs: null
1226
+ }
701
1227
  };
702
1228
  }
703
1229
  async calculateHash(data) {
704
- const content = JSON.stringify(data);
1230
+ const content = JSON.stringify({
1231
+ ...data,
1232
+ sessionId: this.sessionId
1233
+ });
705
1234
  const msgBuffer = new TextEncoder().encode(content);
706
1235
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
707
1236
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
708
1237
  }
709
- startTTLHeartbeat(queryState) {
1238
+ startTTLHeartbeat(queryState, hash) {
710
1239
  if (queryState.ttlTimer) return;
711
1240
  const heartbeatTime = Math.floor(queryState.ttlDurationMs * .9);
712
1241
  queryState.ttlTimer = setTimeout(() => {
1242
+ queryState.ttlTimer = null;
1243
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) {
1244
+ this.logger.debug({
1245
+ hash,
1246
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1247
+ }, "TTL heartbeat: no subscribers, stopping");
1248
+ return;
1249
+ }
1250
+ this.onHeartbeat?.(hash);
713
1251
  this.logger.debug({
1252
+ hash,
714
1253
  id: encodeRecordId(queryState.config.id),
715
- Category: "spooky-client::DataModule::startTTLHeartbeat"
716
- }, "TTL heartbeat");
717
- this.startTTLHeartbeat(queryState);
1254
+ Category: "sp00ky-client::DataModule::startTTLHeartbeat"
1255
+ }, "TTL heartbeat sent");
1256
+ this.startTTLHeartbeat(queryState, hash);
718
1257
  }, heartbeatTime);
719
1258
  }
720
- stopTTLHeartbeat(queryState) {
721
- if (queryState.ttlTimer) {
722
- clearTimeout(queryState.ttlTimer);
723
- queryState.ttlTimer = null;
724
- }
725
- }
726
1259
  async replaceRecordInQueries(record) {
727
1260
  for (const [queryHash, queryState] of this.activeQueries.entries()) {
728
1261
  const index = queryState.records.findIndex((r) => r.id === record.id);
@@ -744,7 +1277,7 @@ function parseUpdateOptions(id, data, options) {
744
1277
  let pushEventOptions = {};
745
1278
  if (options?.debounced) pushEventOptions = { debounced: {
746
1279
  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
1280
+ key: (options.debounced !== true ? options.debounced?.key ?? id : id) === "recordId_x_fields" ? `${id}::${Object.keys(data).toSorted().join("#")}` : id
748
1281
  } };
749
1282
  return pushEventOptions;
750
1283
  }
@@ -781,7 +1314,7 @@ var AbstractDatabaseService = class {
781
1314
  this.logger.debug({
782
1315
  query,
783
1316
  vars,
784
- Category: "spooky-client::Database::query"
1317
+ Category: "sp00ky-client::Database::query"
785
1318
  }, "Executing query");
786
1319
  const result = await this.client.query(query, vars);
787
1320
  const duration = performance.now() - startTime;
@@ -796,7 +1329,7 @@ var AbstractDatabaseService = class {
796
1329
  this.logger.trace({
797
1330
  query,
798
1331
  result,
799
- Category: "spooky-client::Database::query"
1332
+ Category: "sp00ky-client::Database::query"
800
1333
  }, "Query executed successfully");
801
1334
  } catch (err) {
802
1335
  const duration = performance.now() - startTime;
@@ -812,7 +1345,7 @@ var AbstractDatabaseService = class {
812
1345
  query,
813
1346
  vars,
814
1347
  err,
815
- Category: "spooky-client::Database::query"
1348
+ Category: "sp00ky-client::Database::query"
816
1349
  }, "Query execution failed");
817
1350
  reject(err);
818
1351
  }
@@ -824,7 +1357,7 @@ var AbstractDatabaseService = class {
824
1357
  return query.extract(raw);
825
1358
  }
826
1359
  async close() {
827
- this.logger.info({ Category: "spooky-client::Database::close" }, "Closing database connection");
1360
+ this.logger.info({ Category: "sp00ky-client::Database::close" }, "Closing database connection");
828
1361
  await this.client.close();
829
1362
  }
830
1363
  };
@@ -1004,7 +1537,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
1004
1537
  type,
1005
1538
  phase,
1006
1539
  service: "surrealdb:local",
1007
- Category: "spooky-client::LocalDatabaseService::diagnostics"
1540
+ Category: "sp00ky-client::LocalDatabaseService::diagnostics"
1008
1541
  }, `Local SurrealDB diagnostics captured ${type}:${phase}`);
1009
1542
  })
1010
1543
  }), logger, events);
@@ -1015,38 +1548,153 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
1015
1548
  }
1016
1549
  async connect() {
1017
1550
  const { namespace, database } = this.getConfig();
1551
+ const store = this.getConfig().store ?? "memory";
1552
+ const storeUrl = store === "memory" ? "mem://" : "indxdb://sp00ky";
1018
1553
  this.logger.info({
1019
1554
  namespace,
1020
1555
  database,
1021
- Category: "spooky-client::LocalDatabaseService::connect"
1556
+ storeUrl,
1557
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1022
1558
  }, "Connecting to local database");
1559
+ this.registerUnloadClose();
1023
1560
  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");
1561
+ await this.openStore(storeUrl, namespace, database);
1562
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database");
1563
+ return;
1041
1564
  } catch (err) {
1042
- this.logger.error({
1565
+ if (store === "memory" || !isLocalStoreOpenError(err)) {
1566
+ this.logger.error({
1567
+ err,
1568
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1569
+ }, "Failed to connect to local database");
1570
+ throw err;
1571
+ }
1572
+ this.logger.warn({
1043
1573
  err,
1044
- Category: "spooky-client::LocalDatabaseService::connect"
1045
- }, "Failed to connect to local database");
1046
- throw err;
1574
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1575
+ }, "Local IndexedDB store failed to open; retrying before clearing");
1576
+ }
1577
+ for (let attempt = 1; attempt <= 2; attempt++) {
1578
+ try {
1579
+ await this.client.close();
1580
+ } catch {}
1581
+ await delay(150 * attempt);
1582
+ try {
1583
+ await this.openStore(storeUrl, namespace, database);
1584
+ this.logger.info({
1585
+ attempt,
1586
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1587
+ }, "Connected to local database on retry (cache preserved)");
1588
+ return;
1589
+ } catch (retryErr) {
1590
+ this.logger.warn({
1591
+ err: retryErr,
1592
+ attempt,
1593
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1594
+ }, "Local store retry failed");
1595
+ }
1596
+ }
1597
+ try {
1598
+ await this.client.close();
1599
+ } catch {}
1600
+ await dropLocalIndexedDbStores(this.logger);
1601
+ try {
1602
+ await this.openStore(storeUrl, namespace, database);
1603
+ this.logger.info({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Reconnected to local database after clearing the corrupt store");
1604
+ } catch (retryErr) {
1605
+ this.logger.error({
1606
+ err: retryErr,
1607
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1608
+ }, "Local store still failing after clear; falling back to in-memory");
1609
+ try {
1610
+ await this.client.close();
1611
+ } catch {}
1612
+ await this.openStore("mem://", namespace, database);
1613
+ this.logger.warn({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "Connected to local database (in-memory fallback)");
1047
1614
  }
1048
1615
  }
1616
+ unloadCloseRegistered = false;
1617
+ /**
1618
+ * Close the local DB on page unload so the SurrealDB-WASM worker releases its
1619
+ * IndexedDB connection cleanly. Without this, the previous page's connection
1620
+ * lingers; the next load's `client.connect` opens the store but the first
1621
+ * write transaction in `client.use` hits an "IndexedDB error" — which then
1622
+ * (mis)triggered the corrupt-store recovery and WIPED the cache on every
1623
+ * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
1624
+ * unload signal (fires on bfcache + normal navigation); `close()` is async but
1625
+ * the WASM worker initiates the IndexedDB connection teardown synchronously.
1626
+ */
1627
+ registerUnloadClose() {
1628
+ if (this.unloadCloseRegistered || typeof window === "undefined") return;
1629
+ this.unloadCloseRegistered = true;
1630
+ const close = () => {
1631
+ try {
1632
+ this.client.close();
1633
+ } catch {}
1634
+ };
1635
+ window.addEventListener("pagehide", close);
1636
+ window.addEventListener("beforeunload", close);
1637
+ }
1638
+ async openStore(storeUrl, namespace, database) {
1639
+ this.logger.debug({
1640
+ storeUrl,
1641
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1642
+ }, "[LocalDatabaseService] Calling client.connect");
1643
+ await this.client.connect(storeUrl, {});
1644
+ this.logger.debug({
1645
+ namespace,
1646
+ database,
1647
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1648
+ }, "[LocalDatabaseService] client.connect returned. Calling client.use");
1649
+ await this.client.use({
1650
+ namespace,
1651
+ database
1652
+ });
1653
+ this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
1654
+ }
1049
1655
  };
1656
+ function delay(ms) {
1657
+ return new Promise((resolve) => setTimeout(resolve, ms));
1658
+ }
1659
+ /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
1660
+ * store can't be opened (corrupt / version-incompatible / blocked). Exported
1661
+ * for unit testing the error-message match. */
1662
+ function isLocalStoreOpenError(err) {
1663
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
1664
+ return msg.includes("indexeddb") || msg.includes("idb error") || msg.includes("key-value store");
1665
+ }
1666
+ /** Best-effort delete of this client's IndexedDB store(s). The persistent local
1667
+ * DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
1668
+ * IndexedDB databases whose names include `sp00ky`. Resolves even on
1669
+ * error/blocked so startup can proceed. No-op outside a browser. */
1670
+ async function dropLocalIndexedDbStores(logger) {
1671
+ if (typeof indexedDB === "undefined") return;
1672
+ const remove = (name) => new Promise((resolve) => {
1673
+ try {
1674
+ const req = indexedDB.deleteDatabase(name);
1675
+ req.onsuccess = () => resolve();
1676
+ req.onerror = () => resolve();
1677
+ req.onblocked = () => resolve();
1678
+ } catch {
1679
+ resolve();
1680
+ }
1681
+ });
1682
+ try {
1683
+ let names = [];
1684
+ if (typeof indexedDB.databases === "function") names = (await indexedDB.databases()).map((d) => d.name).filter((n) => !!n && n.toLowerCase().includes("sp00ky"));
1685
+ if (names.length === 0) names = ["sp00ky"];
1686
+ await Promise.all(names.map(remove));
1687
+ logger.info({
1688
+ names,
1689
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1690
+ }, "Cleared local IndexedDB store(s)");
1691
+ } catch (e) {
1692
+ logger.warn({
1693
+ err: e,
1694
+ Category: "sp00ky-client::LocalDatabaseService::connect"
1695
+ }, "Failed to enumerate/clear IndexedDB; proceeding anyway");
1696
+ }
1697
+ }
1050
1698
 
1051
1699
  //#endregion
1052
1700
  //#region src/services/database/remote.ts
@@ -1062,7 +1710,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1062
1710
  type,
1063
1711
  phase,
1064
1712
  service: "surrealdb:remote",
1065
- Category: "spooky-client::RemoteDatabaseService::diagnostics"
1713
+ Category: "sp00ky-client::RemoteDatabaseService::diagnostics"
1066
1714
  }, `Remote SurrealDB diagnostics captured ${type}:${phase}`);
1067
1715
  }) }), logger, events);
1068
1716
  this.config = config;
@@ -1077,7 +1725,7 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1077
1725
  endpoint,
1078
1726
  namespace,
1079
1727
  database,
1080
- Category: "spooky-client::RemoteDatabaseService::connect"
1728
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
1081
1729
  }, "Connecting to remote database");
1082
1730
  try {
1083
1731
  await this.client.connect(endpoint);
@@ -1086,18 +1734,18 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1086
1734
  database
1087
1735
  });
1088
1736
  if (token) {
1089
- this.logger.debug({ Category: "spooky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
1737
+ this.logger.debug({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Authenticating with token");
1090
1738
  await this.client.authenticate(token);
1091
1739
  }
1092
- this.logger.info({ Category: "spooky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
1740
+ this.logger.info({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "Connected to remote database");
1093
1741
  } catch (err) {
1094
1742
  this.logger.error({
1095
1743
  err,
1096
- Category: "spooky-client::RemoteDatabaseService::connect"
1744
+ Category: "sp00ky-client::RemoteDatabaseService::connect"
1097
1745
  }, "Failed to connect to remote database");
1098
1746
  throw err;
1099
1747
  }
1100
- } else this.logger.warn({ Category: "spooky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
1748
+ } else this.logger.warn({ Category: "sp00ky-client::RemoteDatabaseService::connect" }, "No endpoint configured for remote database");
1101
1749
  }
1102
1750
  async signin(params) {
1103
1751
  return this.client.signin(params);
@@ -1115,70 +1763,14 @@ var RemoteDatabaseService = class extends AbstractDatabaseService {
1115
1763
 
1116
1764
  //#endregion
1117
1765
  //#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) {
1766
+ function createLogger(level = "info", transmit) {
1131
1767
  const browserConfig = {
1132
1768
  asObject: true,
1133
1769
  write: (o) => {
1134
1770
  console.log(JSON.stringify(o));
1135
1771
  }
1136
1772
  };
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
- }
1773
+ if (transmit) browserConfig.transmit = transmit;
1182
1774
  return pino({
1183
1775
  level,
1184
1776
  browser: browserConfig
@@ -1203,25 +1795,25 @@ var LocalMigrator = class {
1203
1795
  const hash = await sha1(schemaSurql);
1204
1796
  const { database } = this.localDb.getConfig();
1205
1797
  if (await this.isSchemaUpToDate(hash)) {
1206
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
1798
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, "[Provisioning] Schema is up to date, skipping migration");
1207
1799
  return;
1208
1800
  }
1209
1801
  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 ";
1802
+ 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
1803
  const statements = this.splitStatements(fullSchema);
1212
1804
  for (let i = 0; i < statements.length; i++) {
1213
1805
  const statement = statements[i];
1214
1806
  const cleanStatement = statement.replace(/--.*/g, "").trim();
1215
1807
  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)}...`);
1808
+ this.logger.warn({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`);
1217
1809
  continue;
1218
1810
  }
1219
1811
  try {
1220
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
1812
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`);
1221
1813
  await this.localDb.query(statement);
1222
- this.logger.info({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
1814
+ this.logger.info({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Done`);
1223
1815
  } catch (e) {
1224
- this.logger.error({ Category: "spooky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
1816
+ this.logger.error({ Category: "sp00ky-client::LocalMigrator::provision" }, `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`);
1225
1817
  throw e;
1226
1818
  }
1227
1819
  }
@@ -1229,22 +1821,22 @@ var LocalMigrator = class {
1229
1821
  }
1230
1822
  async isSchemaUpToDate(hash) {
1231
1823
  try {
1232
- const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`);
1824
+ const [lastSchemaRecord] = await this.localDb.query(`SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`);
1233
1825
  return lastSchemaRecord?.hash === hash;
1234
- } catch (error) {
1826
+ } catch (_error) {
1235
1827
  return false;
1236
1828
  }
1237
1829
  }
1238
1830
  async recreateDatabase(database) {
1239
1831
  try {
1240
- await this.localDb.query(`DEFINE DATABASE _spooky_temp;`);
1241
- } catch (e) {}
1832
+ await this.localDb.query(`DEFINE DATABASE _00_temp;`);
1833
+ } catch (_e) {}
1242
1834
  try {
1243
1835
  await this.localDb.query(`
1244
- USE DB _spooky_temp;
1836
+ USE DB _00_temp;
1245
1837
  REMOVE DATABASE ${database};
1246
1838
  `);
1247
- } catch (e) {}
1839
+ } catch (_e) {}
1248
1840
  await this.localDb.query(`
1249
1841
  DEFINE DATABASE ${database};
1250
1842
  USE DB ${database};
@@ -1302,7 +1894,7 @@ var LocalMigrator = class {
1302
1894
  return statements;
1303
1895
  }
1304
1896
  async createHashRecord(hash) {
1305
- await this.localDb.query(`UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
1897
+ await this.localDb.query(`UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`, { hash });
1306
1898
  }
1307
1899
  };
1308
1900
 
@@ -1394,7 +1986,7 @@ var UpQueue = class {
1394
1986
  this.logger.error({
1395
1987
  error,
1396
1988
  event,
1397
- Category: "spooky-client::UpQueue::next"
1989
+ Category: "sp00ky-client::UpQueue::next"
1398
1990
  }, "Network error processing mutation, re-queuing");
1399
1991
  this.queue.unshift(event);
1400
1992
  throw error;
@@ -1402,7 +1994,7 @@ var UpQueue = class {
1402
1994
  this.logger.error({
1403
1995
  error,
1404
1996
  event,
1405
- Category: "spooky-client::UpQueue::next"
1997
+ Category: "sp00ky-client::UpQueue::next"
1406
1998
  }, "Application error processing mutation, rolling back");
1407
1999
  try {
1408
2000
  await this.removeEventFromDatabase(event.mutation_id);
@@ -1410,7 +2002,7 @@ var UpQueue = class {
1410
2002
  this.logger.error({
1411
2003
  error: removeError,
1412
2004
  event,
1413
- Category: "spooky-client::UpQueue::next"
2005
+ Category: "sp00ky-client::UpQueue::next"
1414
2006
  }, "Failed to remove rolled-back mutation from database");
1415
2007
  }
1416
2008
  if (onRollback) try {
@@ -1419,7 +2011,7 @@ var UpQueue = class {
1419
2011
  this.logger.error({
1420
2012
  error: rollbackError,
1421
2013
  event,
1422
- Category: "spooky-client::UpQueue::next"
2014
+ Category: "sp00ky-client::UpQueue::next"
1423
2015
  }, "Rollback handler failed");
1424
2016
  }
1425
2017
  this._events.addEvent({
@@ -1434,7 +2026,7 @@ var UpQueue = class {
1434
2026
  this.logger.error({
1435
2027
  error,
1436
2028
  event,
1437
- Category: "spooky-client::UpQueue::next"
2029
+ Category: "sp00ky-client::UpQueue::next"
1438
2030
  }, "Failed to remove mutation from database after successful processing");
1439
2031
  }
1440
2032
  this._events.addEvent({
@@ -1448,7 +2040,7 @@ var UpQueue = class {
1448
2040
  }
1449
2041
  async loadFromDatabase() {
1450
2042
  try {
1451
- const [records] = await this.local.query(`SELECT * FROM _spooky_pending_mutations ORDER BY created_at ASC`);
2043
+ const [records] = await this.local.query(`SELECT * FROM _00_pending_mutations ORDER BY created_at ASC`);
1452
2044
  this.queue = records.map((r) => {
1453
2045
  switch (r.mutationType) {
1454
2046
  case "create": return {
@@ -1474,7 +2066,7 @@ var UpQueue = class {
1474
2066
  this.logger.warn({
1475
2067
  mutationType: r.mutationType,
1476
2068
  record: r,
1477
- Category: "spooky-client::UpQueue::loadFromDatabase"
2069
+ Category: "sp00ky-client::UpQueue::loadFromDatabase"
1478
2070
  }, "Unknown mutation type");
1479
2071
  return null;
1480
2072
  }
@@ -1482,7 +2074,7 @@ var UpQueue = class {
1482
2074
  } catch (error) {
1483
2075
  this.logger.error({
1484
2076
  error,
1485
- Category: "spooky-client::UpQueue::loadFromDatabase"
2077
+ Category: "sp00ky-client::UpQueue::loadFromDatabase"
1486
2078
  }, "Failed to load pending mutations from database");
1487
2079
  }
1488
2080
  }
@@ -1523,7 +2115,7 @@ var DownQueue = class {
1523
2115
  this.logger.error({
1524
2116
  error,
1525
2117
  event,
1526
- Category: "spooky-client::DownQueue::next"
2118
+ Category: "sp00ky-client::DownQueue::next"
1527
2119
  }, "Failed to process query");
1528
2120
  this.queue.unshift(event);
1529
2121
  throw error;
@@ -1538,8 +2130,8 @@ var ArraySyncer = class {
1538
2130
  remoteArray;
1539
2131
  needsSort = false;
1540
2132
  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]));
2133
+ this.remoteArray = remoteArray.toSorted((a, b) => a[0].localeCompare(b[0]));
2134
+ this.localArray = localArray.toSorted((a, b) => a[0].localeCompare(b[0]));
1543
2135
  }
1544
2136
  /**
1545
2137
  * Inserts an item into the local array
@@ -1575,7 +2167,6 @@ var ArraySyncer = class {
1575
2167
  this.localArray.sort((a, b) => a[0].localeCompare(b[0]));
1576
2168
  this.needsSort = false;
1577
2169
  }
1578
- console.log("xxxx555", this.localArray, this.remoteArray);
1579
2170
  return diffRecordVersionArray(this.localArray, this.remoteArray);
1580
2171
  }
1581
2172
  };
@@ -1606,14 +2197,12 @@ function diffRecordVersionArray(local, remote) {
1606
2197
  };
1607
2198
  }
1608
2199
  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
- }
2200
+ const old = versions?.find((record) => record[0] === encodeRecordId(recordId));
2201
+ if (old && old[1] >= version) return {
2202
+ added: [],
2203
+ updated: [],
2204
+ removed: []
2205
+ };
1617
2206
  if (op === "CREATE") return {
1618
2207
  added: [{
1619
2208
  id: recordId,
@@ -1636,6 +2225,83 @@ function createDiffFromDbOp(op, recordId, version, versions) {
1636
2225
  removed: [recordId]
1637
2226
  };
1638
2227
  }
2228
+ /**
2229
+ * Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
2230
+ * poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
2231
+ * deliveries; 500ms is aggressive enough to feel real-time on the
2232
+ * happy path while keeping the per-session query load bounded.
2233
+ */
2234
+ const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
2235
+ /**
2236
+ * Build the SurrealQL select that powers both the initial-fetch and
2237
+ * the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
2238
+ * predicate excludes subquery entries (rows with `parent_rel` set)
2239
+ * because the client's `RecordVersionArray` only tracks primary rows;
2240
+ * including subquery rows would surface them as spurious "added"
2241
+ * diffs every tick.
2242
+ */
2243
+ function buildListRefSelect(table) {
2244
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
2245
+ }
2246
+ /**
2247
+ * Resolve the effective list-ref poll interval. Negative or zero
2248
+ * values fall back to the default — accepting them would either
2249
+ * disable polling silently or busy-loop the event loop.
2250
+ */
2251
+ function resolveListRefPollInterval(opt) {
2252
+ if (typeof opt !== "number" || !Number.isFinite(opt) || opt <= 0) return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
2253
+ return opt;
2254
+ }
2255
+ /**
2256
+ * Ceiling for the adaptive list_ref poll backoff. An idle page (no LIVE
2257
+ * events, no poll-detected list_ref changes) coasts up to this cadence;
2258
+ * the existing healthy-LIVE safety net runs at the same 5s, so this keeps
2259
+ * the worst-case catch-up latency for a missed cross-session change at the
2260
+ * cadence the codebase already treats as acceptable.
2261
+ */
2262
+ const LIST_REF_POLL_MAX_INTERVAL_MS = 5e3;
2263
+ /**
2264
+ * Adaptive poll delay: stay at the responsive `baseIntervalMs` while
2265
+ * changes are arriving, and exponentially back off toward `maxIntervalMs`
2266
+ * while the `_00_list_ref` is quiet.
2267
+ *
2268
+ * `idleStreak` is the count of consecutive poll cycles that observed *no*
2269
+ * change. `Sp00kySync` resets it to 0 whenever a poll detects a real
2270
+ * remoteArray change OR a LIVE event lands, so any activity snaps the poll
2271
+ * straight back to `baseIntervalMs`. A streak of 0 (something just
2272
+ * happened) → base; otherwise `base * 2^streak` capped at `maxIntervalMs`.
2273
+ *
2274
+ * This replaces {@link nextPollDelayMs}: the old helper slowed the poll
2275
+ * only while LIVE was *delivering*, but the cross-session LIVE-permission
2276
+ * gap means LIVE frequently never fires here, so it left a fully idle page
2277
+ * polling every `base` ms forever (the "continuous queries while idle"
2278
+ * symptom). Backing off on observed idleness instead covers the
2279
+ * LIVE-healthy case for free (LIVE applies the change → the next poll sees
2280
+ * nothing new → the streak grows → it backs off).
2281
+ */
2282
+ function listRefPollDelayMs(args) {
2283
+ const { idleStreak, baseIntervalMs, maxIntervalMs = LIST_REF_POLL_MAX_INTERVAL_MS } = args;
2284
+ const cap = Math.max(baseIntervalMs, maxIntervalMs);
2285
+ if (idleStreak <= 0) return baseIntervalMs;
2286
+ const exponent = Math.min(idleStreak, 30);
2287
+ return Math.min(baseIntervalMs * 2 ** exponent, cap);
2288
+ }
2289
+ /**
2290
+ * Order-insensitive equality for two `RecordVersionArray`s (each a list of
2291
+ * `[recordIdString, version]`). The `_00_list_ref` SELECT has no `ORDER
2292
+ * BY`, so row order can differ between polls without anything having
2293
+ * actually changed — comparing as an id→version map avoids false
2294
+ * "changed" verdicts that would defeat the idle backoff. Record ids are
2295
+ * unique within a query's list_ref, so a map is a faithful representation.
2296
+ */
2297
+ function recordVersionArraysEqual(a, b) {
2298
+ if (a === b) return true;
2299
+ if (a.length !== b.length) return false;
2300
+ const byId = /* @__PURE__ */ new Map();
2301
+ for (const [id, version] of a) byId.set(id, version);
2302
+ for (const [id, version] of b) if (byId.get(id) !== version) return false;
2303
+ return true;
2304
+ }
1639
2305
 
1640
2306
  //#endregion
1641
2307
  //#region src/modules/sync/engine.ts
@@ -1643,7 +2309,7 @@ function createDiffFromDbOp(op, recordId, version, versions) {
1643
2309
  * SyncEngine handles the core sync operations: fetching remote records,
1644
2310
  * caching them locally, and ingesting into DBSP.
1645
2311
  *
1646
- * This is extracted from SpookySync to separate "how to sync" from "when to sync".
2312
+ * This is extracted from Sp00kySync to separate "how to sync" from "when to sync".
1647
2313
  */
1648
2314
  var SyncEngine = class {
1649
2315
  logger;
@@ -1652,7 +2318,7 @@ var SyncEngine = class {
1652
2318
  this.remote = remote;
1653
2319
  this.cache = cache;
1654
2320
  this.schema = schema;
1655
- this.logger = logger.child({ service: "SpookySync:SyncEngine" });
2321
+ this.logger = logger.child({ service: "Sp00kySync:SyncEngine" });
1656
2322
  }
1657
2323
  /**
1658
2324
  * Sync missing/updated/removed records between local and remote.
@@ -1665,33 +2331,42 @@ var SyncEngine = class {
1665
2331
  added,
1666
2332
  updated,
1667
2333
  removed,
1668
- Category: "spooky-client::SyncEngine::syncRecords"
2334
+ Category: "sp00ky-client::SyncEngine::syncRecords"
1669
2335
  }, "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);
2336
+ let stillRemoteIds = [];
2337
+ if (removed.length > 0) stillRemoteIds = await this.handleRemovedRecords(removed);
2338
+ const toFetch = [...added, ...updated];
2339
+ const idsToFetch = toFetch.map((x) => x.id);
2340
+ if (idsToFetch.length === 0) return {
2341
+ remoteFetchMs: 0,
2342
+ stillRemoteIds
2343
+ };
2344
+ const versionMap = /* @__PURE__ */ new Map();
2345
+ for (const item of toFetch) versionMap.set(encodeRecordId(item.id), item.version);
2346
+ const remoteFetchStart = performance.now();
2347
+ const [remoteResults] = await this.remote.query("SELECT * FROM $idsToFetch", { idsToFetch });
2348
+ const remoteFetchMs = performance.now() - remoteFetchStart;
1675
2349
  const cacheBatch = [];
1676
- for (const { spooky_rv, record } of remoteResults) {
2350
+ for (const record of remoteResults) {
1677
2351
  if (!record?.id) {
1678
2352
  this.logger.warn({
1679
2353
  record,
1680
2354
  idsToFetch,
1681
- Category: "spooky-client::SyncEngine::syncRecords"
1682
- }, "Remote record has no id. Skipping record");
2355
+ Category: "sp00ky-client::SyncEngine::syncRecords"
2356
+ }, "Remote record has no id (possibly deleted). Skipping record");
1683
2357
  continue;
1684
2358
  }
1685
2359
  const fullId = encodeRecordId(record.id);
1686
2360
  const table = record.id.table.toString();
1687
2361
  const isAdded = added.some((item) => encodeRecordId(item.id) === fullId);
2362
+ const version = versionMap.get(fullId) ?? 0;
1688
2363
  const localVersion = this.cache.lookup(fullId);
1689
- if (localVersion && spooky_rv <= localVersion) {
2364
+ if (localVersion && version <= localVersion) {
1690
2365
  this.logger.info({
1691
2366
  recordId: fullId,
1692
- version: spooky_rv,
2367
+ version,
1693
2368
  localVersion,
1694
- Category: "spooky-client::SyncEngine::syncRecords"
2369
+ Category: "sp00ky-client::SyncEngine::syncRecords"
1695
2370
  }, "Local version is higher than remote version. Skipping record");
1696
2371
  continue;
1697
2372
  }
@@ -1701,37 +2376,59 @@ var SyncEngine = class {
1701
2376
  table,
1702
2377
  op: isAdded ? "CREATE" : "UPDATE",
1703
2378
  record: cleanedRecord,
1704
- version: spooky_rv
2379
+ version
1705
2380
  });
1706
2381
  }
1707
2382
  if (cacheBatch.length > 0) await this.cache.saveBatch(cacheBatch);
1708
2383
  this.events.emit(SyncEventTypes.RemoteDataIngested, { records: remoteResults });
2384
+ return {
2385
+ remoteFetchMs,
2386
+ stillRemoteIds
2387
+ };
1709
2388
  }
1710
2389
  /**
1711
2390
  * Handle records that exist locally but not in remote array.
2391
+ *
2392
+ * "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
2393
+ * longer references a record that exists locally. That can mean the row
2394
+ * was genuinely deleted upstream — but it can also be a benign race
2395
+ * (e.g. a record we just created hasn't propagated into the SSP's
2396
+ * incantation list yet). Before deleting locally we verify against
2397
+ * upstream SurrealDB: if the row still exists there, skip the delete.
2398
+ *
2399
+ * On verification failure we skip deletion too. Losing a stale local
2400
+ * row to a later sync round is recoverable; deleting a fresh row that
2401
+ * upstream still has is not.
1712
2402
  */
1713
2403
  async handleRemovedRecords(removed) {
1714
2404
  this.logger.debug({
1715
2405
  removed: removed.map((r) => r.toString()),
1716
- Category: "spooky-client::SyncEngine::handleRemovedRecords"
2406
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1717
2407
  }, "Checking removed records");
1718
- let existingRemoteIds = /* @__PURE__ */ new Set();
2408
+ let existingRemoteIds;
1719
2409
  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");
2410
+ const [existing] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
2411
+ existingRemoteIds = new Set((existing ?? []).map((row) => encodeRecordId(row.id)));
2412
+ } catch (err) {
2413
+ this.logger.warn({
2414
+ err,
2415
+ removed: removed.map((r) => r.toString()),
2416
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
2417
+ }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
2418
+ return [];
1724
2419
  }
2420
+ const stillRemoteIds = [];
1725
2421
  for (const recordId of removed) {
1726
2422
  const recordIdStr = encodeRecordId(recordId);
1727
2423
  if (!existingRemoteIds.has(recordIdStr)) {
1728
2424
  this.logger.debug({
1729
2425
  recordId: recordIdStr,
1730
- Category: "spooky-client::SyncEngine::handleRemovedRecords"
2426
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1731
2427
  }, "Deleting confirmed removed record");
1732
2428
  await this.cache.delete(recordId.table.name, recordIdStr);
1733
- }
2429
+ } else stillRemoteIds.push(recordIdStr);
1734
2430
  }
2431
+ return stillRemoteIds;
1735
2432
  }
1736
2433
  };
1737
2434
 
@@ -1804,22 +2501,81 @@ var SyncScheduler = class {
1804
2501
  };
1805
2502
 
1806
2503
  //#endregion
1807
- //#region src/modules/sync/sync.ts
2504
+ //#region src/modules/ref-tables.ts
1808
2505
  /**
1809
- * The main synchronization engine for Spooky.
1810
- * Handles the bidirectional synchronization between the local database and the remote backend.
1811
- * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
1812
- * @template S The schema structure type.
2506
+ * Default ref-storage mode for this client build. Mirrors the SSP's
2507
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
2508
+ * box.
1813
2509
  */
1814
- var SpookySync = class {
1815
- clientId = "";
1816
- upQueue;
2510
+ const DEFAULT_REF_MODE = "dedicated";
2511
+ /**
2512
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
2513
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
2514
+ * the id is missing the `user:` prefix or contains characters that
2515
+ * aren't valid in a SurrealDB table identifier — the server-side
2516
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
2517
+ *
2518
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
2519
+ * objects (which only stringify cleanly via `.toString()`), since
2520
+ * `AuthService` passes the record-id object as-is to its subscribers.
2521
+ */
2522
+ function sanitizeUserId(userId) {
2523
+ if (userId === null || userId === void 0) return null;
2524
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
2525
+ if (!asString) return null;
2526
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
2527
+ if (raw.length === 0) return null;
2528
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
2529
+ return raw;
2530
+ }
2531
+ /**
2532
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
2533
+ * back to the global `_00_list_ref` when sanitization fails or in
2534
+ * single mode.
2535
+ */
2536
+ function listRefTableFor(mode, userId) {
2537
+ if (mode === "single") return "_00_list_ref";
2538
+ const uid = sanitizeUserId(userId);
2539
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
2540
+ }
2541
+
2542
+ //#endregion
2543
+ //#region src/modules/sync/sync.ts
2544
+ /**
2545
+ * The main synchronization engine for Sp00ky.
2546
+ * Handles the bidirectional synchronization between the local database and the remote backend.
2547
+ * Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
2548
+ * @template S The schema structure type.
2549
+ */
2550
+ var Sp00kySync = class {
2551
+ upQueue;
1817
2552
  downQueue;
1818
2553
  isInit = false;
1819
2554
  logger;
1820
2555
  syncEngine;
2556
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
2557
+ * from `this.events`, which carries Sp00kySync-level events like
2558
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
2559
+ get engineEvents() {
2560
+ return this.syncEngine.events;
2561
+ }
1821
2562
  scheduler;
2563
+ wasDisconnected = false;
1822
2564
  events = createSyncEventSystem();
2565
+ currentUserId = null;
2566
+ refMode = DEFAULT_REF_MODE;
2567
+ currentLiveQueryUuid = null;
2568
+ liveQueryUnsubscribe = null;
2569
+ listRefPollTimer = null;
2570
+ listRefPollRunning = false;
2571
+ refSyncIntervalMs;
2572
+ listRefIdleStreak = 0;
2573
+ stillRemoteStreaks = /* @__PURE__ */ new Map();
2574
+ lastLiveEventAt = null;
2575
+ _liveRetryCount = 0;
2576
+ get liveRetryCount() {
2577
+ return this._liveRetryCount;
2578
+ }
1823
2579
  get isSyncing() {
1824
2580
  return this.scheduler.isSyncing;
1825
2581
  }
@@ -1834,60 +2590,260 @@ var SpookySync = class {
1834
2590
  this.upQueue.events.unsubscribe(id2);
1835
2591
  };
1836
2592
  }
1837
- constructor(local, remote, cache, dataModule, schema, logger) {
2593
+ constructor(local, remote, cache, dataModule, schema, logger, options) {
1838
2594
  this.local = local;
1839
2595
  this.remote = remote;
1840
2596
  this.cache = cache;
1841
2597
  this.dataModule = dataModule;
1842
2598
  this.schema = schema;
1843
- this.logger = logger.child({ service: "SpookySync" });
2599
+ this.logger = logger.child({ service: "Sp00kySync" });
1844
2600
  this.upQueue = new UpQueue(this.local, this.logger);
1845
2601
  this.downQueue = new DownQueue(this.local, this.logger);
1846
2602
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
1847
2603
  this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
2604
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
1848
2605
  }
1849
2606
  /**
1850
2607
  * Initializes the synchronization system.
1851
2608
  * Starts the scheduler and initiates the initial sync cycles.
1852
- * @param clientId The unique identifier for this client instance.
1853
2609
  * @throws Error if already initialized.
1854
2610
  */
1855
- async init(clientId) {
1856
- if (this.isInit) throw new Error("SpookySync is already initialized");
1857
- this.clientId = clientId;
2611
+ async init() {
2612
+ if (this.isInit) throw new Error("Sp00kySync is already initialized");
1858
2613
  this.isInit = true;
1859
2614
  await this.scheduler.init();
1860
- this.scheduler.syncUp();
2615
+ this.subscribeToReconnect();
1861
2616
  this.scheduler.syncUp();
1862
2617
  this.scheduler.syncDown();
1863
- this.startRefLiveQueries();
2618
+ }
2619
+ /**
2620
+ * Push the authenticated user's record id from the parent client's
2621
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
2622
+ * any) and re-registers it under the new user's dedicated table so
2623
+ * SurrealDB binds the permission rule under the post-flip auth
2624
+ * context. Pass `null` on sign-out.
2625
+ *
2626
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
2627
+ * the SSP when the first query registration arrives, which may be
2628
+ * concurrent with this call. We retry the LIVE registration with a
2629
+ * short backoff so a "table not found" race resolves without
2630
+ * surfacing as a permanent auth-loading hang.
2631
+ */
2632
+ async setCurrentUserId(userId) {
2633
+ if (this.currentUserId === userId) return;
2634
+ this.currentUserId = userId;
2635
+ if (!userId) {
2636
+ await this.killRefLiveQuery();
2637
+ this.stopListRefPoll();
2638
+ return;
2639
+ }
2640
+ this.startListRefPoll();
2641
+ const attemptDelays = [
2642
+ 0,
2643
+ 250,
2644
+ 500,
2645
+ 1e3,
2646
+ 2e3
2647
+ ];
2648
+ for (let i = 0; i < attemptDelays.length; i++) {
2649
+ if (attemptDelays[i] > 0) {
2650
+ this._liveRetryCount++;
2651
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
2652
+ }
2653
+ try {
2654
+ await this.restartRefLiveQuery();
2655
+ return;
2656
+ } catch (err) {
2657
+ this.logger.debug({
2658
+ err,
2659
+ attempt: i + 1,
2660
+ Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
2661
+ }, "Ref LIVE start failed; relying on periodic poll fallback");
2662
+ }
2663
+ }
2664
+ }
2665
+ startListRefPoll() {
2666
+ if (this.listRefPollRunning) return;
2667
+ this.listRefPollRunning = true;
2668
+ this.logger.debug({
2669
+ intervalMs: this.refSyncIntervalMs,
2670
+ Category: "sp00ky-client::Sp00kySync::startListRefPoll"
2671
+ }, "list_ref poll loop started");
2672
+ const schedule = (delayMs) => {
2673
+ this.listRefPollTimer = setTimeout(async () => {
2674
+ if (!this.listRefPollRunning) return;
2675
+ let changed = false;
2676
+ try {
2677
+ changed = await this.pollListRefForActiveQueries();
2678
+ } finally {
2679
+ if (!this.listRefPollRunning) return;
2680
+ this.listRefIdleStreak = changed ? 0 : this.listRefIdleStreak + 1;
2681
+ schedule(listRefPollDelayMs({
2682
+ idleStreak: this.listRefIdleStreak,
2683
+ baseIntervalMs: this.refSyncIntervalMs
2684
+ }));
2685
+ }
2686
+ }, delayMs);
2687
+ };
2688
+ schedule(this.refSyncIntervalMs);
2689
+ }
2690
+ stopListRefPoll() {
2691
+ this.listRefPollRunning = false;
2692
+ if (this.listRefPollTimer !== null) {
2693
+ clearTimeout(this.listRefPollTimer);
2694
+ this.listRefPollTimer = null;
2695
+ }
2696
+ }
2697
+ /**
2698
+ * One poll cycle: refetch `_00_list_ref` for every active query. Returns
2699
+ * whether ANY query's remoteArray actually changed — the scheduler uses this
2700
+ * to drive the adaptive idle backoff.
2701
+ */
2702
+ async pollListRefForActiveQueries() {
2703
+ const hashes = this.dataModule.getActiveQueryHashes();
2704
+ if (hashes.length === 0) return false;
2705
+ let anyChanged = false;
2706
+ for (const hash of hashes) try {
2707
+ if (await this.refetchListRefForQuery(hash)) anyChanged = true;
2708
+ } catch (err) {
2709
+ this.logger.debug({
2710
+ err: err?.message ?? err,
2711
+ hash,
2712
+ Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2713
+ }, "Per-query list_ref poll failed");
2714
+ }
2715
+ return anyChanged;
2716
+ }
2717
+ /**
2718
+ * Pull the upstream list_ref entries for `queryHash`, diff them
2719
+ * against the local `remoteArray` cache, sync any added/updated rows
2720
+ * through the SyncEngine, then persist the new remoteArray. This is
2721
+ * the same shape `createRemoteQuery` does for its initial fetch and
2722
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
2723
+ * it on a timer as a fallback for missed LIVE notifications.
2724
+ */
2725
+ async refetchListRefForQuery(queryHash) {
2726
+ const queryState = this.dataModule.getQueryByHash(queryHash);
2727
+ if (!queryState) return false;
2728
+ const listRefTbl = this.listRefTable();
2729
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2730
+ if (!Array.isArray(items)) return false;
2731
+ const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
2732
+ const prevRemote = queryState.config.remoteArray ?? [];
2733
+ const freshIds = new Set(fresh.map(([id]) => id));
2734
+ const removedIds = prevRemote.filter(([id]) => !freshIds.has(id)).map(([id]) => id);
2735
+ const changed = !recordVersionArraysEqual(fresh, queryState.config.remoteArray);
2736
+ if (changed) await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
2737
+ try {
2738
+ await this.syncQuery(queryHash);
2739
+ } catch (err) {
2740
+ this.logger.info({
2741
+ err: err?.message ?? err,
2742
+ queryHash,
2743
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2744
+ }, "syncQuery failed during poll");
2745
+ }
2746
+ if (removedIds.length > 0) try {
2747
+ await this.dataModule.notifyQuerySynced(queryHash);
2748
+ } catch (err) {
2749
+ this.logger.info({
2750
+ err: err?.message ?? err,
2751
+ queryHash,
2752
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2753
+ }, "notifyQuerySynced failed during poll-removal re-render");
2754
+ }
2755
+ return changed;
2756
+ }
2757
+ /**
2758
+ * Resolve the current `_00_list_ref` table name for the active auth
2759
+ * context. Public so the `createRemoteQuery` initial-fetch path can
2760
+ * read from the right per-user table.
2761
+ *
2762
+ * Reads the user id from `DataModule` rather than the local mirror,
2763
+ * because `DataModule.setCurrentUserId` runs synchronously from the
2764
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
2765
+ * is async — the userQuery's initial fetch can fire between those
2766
+ * two points and we need the correct table name immediately.
2767
+ */
2768
+ listRefTable() {
2769
+ return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
2770
+ }
2771
+ async killRefLiveQuery() {
2772
+ if (this.liveQueryUnsubscribe) {
2773
+ try {
2774
+ this.liveQueryUnsubscribe();
2775
+ } catch {}
2776
+ this.liveQueryUnsubscribe = null;
2777
+ }
2778
+ if (this.currentLiveQueryUuid !== null) {
2779
+ try {
2780
+ await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
2781
+ } catch (err) {
2782
+ this.logger.debug({
2783
+ err,
2784
+ Category: "sp00ky-client::Sp00kySync::killRefLiveQuery"
2785
+ }, "Prior LIVE KILL failed; continuing");
2786
+ }
2787
+ this.currentLiveQueryUuid = null;
2788
+ }
2789
+ }
2790
+ async restartRefLiveQuery() {
2791
+ await this.killRefLiveQuery();
2792
+ await this.startRefLiveQueries();
2793
+ }
2794
+ subscribeToReconnect() {
2795
+ const client = this.remote.getClient();
2796
+ client.subscribe("disconnected", () => {
2797
+ this.wasDisconnected = true;
2798
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
2799
+ });
2800
+ client.subscribe("connected", () => {
2801
+ if (!this.wasDisconnected) return;
2802
+ this.wasDisconnected = false;
2803
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
2804
+ for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
2805
+ type: "register",
2806
+ payload: { hash }
2807
+ });
2808
+ if (this.currentUserId) this.restartRefLiveQuery().catch((err) => {
2809
+ this.logger.debug({
2810
+ err,
2811
+ Category: "sp00ky-client::Sp00kySync::onReconnect"
2812
+ }, "LIVE restart after reconnect failed; relying on poll fallback");
2813
+ });
2814
+ });
1864
2815
  }
1865
2816
  async startRefLiveQueries() {
2817
+ const tableName = this.listRefTable();
1866
2818
  this.logger.debug({
1867
- clientId: this.clientId,
1868
- Category: "spooky-client::SpookySync::startRefLiveQueries"
2819
+ tableName,
2820
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1869
2821
  }, "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) => {
2822
+ const [queryUuid] = await this.remote.query(`LIVE SELECT * FROM ${tableName}`);
2823
+ this.currentLiveQueryUuid = queryUuid;
2824
+ this.liveQueryUnsubscribe = (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
1872
2825
  this.logger.debug({
1873
2826
  message,
1874
- Category: "spooky-client::SpookySync::startRefLiveQueries"
2827
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1875
2828
  }, "Live update received");
1876
2829
  if (message.action === "KILLED") return;
2830
+ if (message.value.parent != null) return;
1877
2831
  this.handleRemoteListRefChange(message.action, message.value.in, message.value.out, message.value.version).catch((err) => {
1878
2832
  this.logger.error({
1879
2833
  err,
1880
- Category: "spooky-client::SpookySync::startRefLiveQueries"
2834
+ Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1881
2835
  }, "Error handling remote list ref change");
1882
2836
  });
1883
2837
  });
1884
2838
  }
1885
2839
  async handleRemoteListRefChange(action, queryId, recordId, version) {
2840
+ this.lastLiveEventAt = Date.now();
2841
+ this.listRefIdleStreak = 0;
1886
2842
  const existing = this.dataModule.getQueryById(queryId);
1887
2843
  if (!existing) {
1888
2844
  this.logger.warn({
1889
2845
  queryId: queryId.toString(),
1890
- Category: "spooky-client::SpookySync::handleRemoteListRefChange"
2846
+ Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
1891
2847
  }, "Received remote update for unknown local query");
1892
2848
  return;
1893
2849
  }
@@ -1898,10 +2854,11 @@ var SpookySync = class {
1898
2854
  recordId,
1899
2855
  version,
1900
2856
  localArray,
1901
- Category: "spooky-client::SpookySync::handleRemoteListRefChange"
2857
+ Category: "sp00ky-client::Sp00kySync::handleRemoteListRefChange"
1902
2858
  }, "Live update is being processed");
1903
2859
  const diff = createDiffFromDbOp(action, recordId, version, localArray);
1904
- await this.syncEngine.syncRecords(diff);
2860
+ const hash = extractIdPart(existing.config.id);
2861
+ await this.runSyncForQuery(hash, diff);
1905
2862
  }
1906
2863
  /**
1907
2864
  * Enqueues a 'down' event (from remote to local) for processing.
@@ -1913,11 +2870,10 @@ var SpookySync = class {
1913
2870
  async processUpEvent(event) {
1914
2871
  this.logger.debug({
1915
2872
  event,
1916
- Category: "spooky-client::SpookySync::processUpEvent"
2873
+ Category: "sp00ky-client::Sp00kySync::processUpEvent"
1917
2874
  }, "Processing up event");
1918
- console.log("xx1", event);
1919
2875
  switch (event.type) {
1920
- case "create":
2876
+ case "create": {
1921
2877
  const dataKeys = Object.keys(event.data).map((key) => ({
1922
2878
  key,
1923
2879
  variable: `data_${key}`
@@ -1929,6 +2885,7 @@ var SpookySync = class {
1929
2885
  ...prefixedParams
1930
2886
  });
1931
2887
  break;
2888
+ }
1932
2889
  case "update":
1933
2890
  await this.remote.query(`UPDATE $id MERGE $data`, {
1934
2891
  id: event.record_id,
@@ -1941,7 +2898,7 @@ var SpookySync = class {
1941
2898
  default:
1942
2899
  this.logger.error({
1943
2900
  event,
1944
- Category: "spooky-client::SpookySync::processUpEvent"
2901
+ Category: "sp00ky-client::Sp00kySync::processUpEvent"
1945
2902
  }, "processUpEvent unknown event type");
1946
2903
  return;
1947
2904
  }
@@ -1954,7 +2911,7 @@ var SpookySync = class {
1954
2911
  recordId,
1955
2912
  tableName,
1956
2913
  error: error.message,
1957
- Category: "spooky-client::SpookySync::handleRollback"
2914
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1958
2915
  }, "Rolling back failed mutation");
1959
2916
  switch (event.type) {
1960
2917
  case "create":
@@ -1964,13 +2921,13 @@ var SpookySync = class {
1964
2921
  if (event.beforeRecord) await this.dataModule.rollbackUpdate(event.record_id, tableName, event.beforeRecord);
1965
2922
  else this.logger.warn({
1966
2923
  recordId,
1967
- Category: "spooky-client::SpookySync::handleRollback"
2924
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1968
2925
  }, "Cannot rollback update: no beforeRecord available. Down-sync will reconcile.");
1969
2926
  break;
1970
2927
  case "delete":
1971
2928
  this.logger.warn({
1972
2929
  recordId,
1973
- Category: "spooky-client::SpookySync::handleRollback"
2930
+ Category: "sp00ky-client::Sp00kySync::handleRollback"
1974
2931
  }, "Delete rollback not implemented. Down-sync will reconcile.");
1975
2932
  break;
1976
2933
  }
@@ -1983,7 +2940,7 @@ var SpookySync = class {
1983
2940
  async processDownEvent(event) {
1984
2941
  this.logger.debug({
1985
2942
  event,
1986
- Category: "spooky-client::SpookySync::processDownEvent"
2943
+ Category: "sp00ky-client::Sp00kySync::processDownEvent"
1987
2944
  }, "Processing down event");
1988
2945
  switch (event.type) {
1989
2946
  case "register": return this.registerQuery(event.payload.hash);
@@ -2002,13 +2959,76 @@ var SpookySync = class {
2002
2959
  if (!queryState) {
2003
2960
  this.logger.warn({
2004
2961
  hash,
2005
- Category: "spooky-client::SpookySync::syncQuery"
2962
+ Category: "sp00ky-client::Sp00kySync::syncQuery"
2006
2963
  }, "Query not found");
2007
2964
  return;
2008
2965
  }
2009
2966
  const diff = new ArraySyncer(queryState.config.localArray, queryState.config.remoteArray).nextSet();
2010
2967
  if (!diff) return;
2011
- return this.syncEngine.syncRecords(diff);
2968
+ return this.runSyncForQuery(hash, diff);
2969
+ }
2970
+ /**
2971
+ * Run a sync for a single query while reflecting its fetch status. Marks the
2972
+ * query `fetching` for the duration when the diff actually pulls records
2973
+ * (added/updated), then resets to `idle` in a `finally` so a failed sync
2974
+ * never leaves a query stuck `fetching`. Part A's notification coalescing
2975
+ * means the single resulting UI update lands after this completes.
2976
+ */
2977
+ async runSyncForQuery(hash, diff) {
2978
+ if (diff.added.length > 0 || diff.updated.length > 0) {
2979
+ const pendingDeletes = await this.getPendingDeleteIds();
2980
+ if (pendingDeletes.size > 0) diff = {
2981
+ added: diff.added.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
2982
+ updated: diff.updated.filter((r) => !pendingDeletes.has(encodeRecordId(r.id))),
2983
+ removed: diff.removed
2984
+ };
2985
+ }
2986
+ const fetching = diff.added.length + diff.updated.length > 0;
2987
+ if (fetching) this.dataModule.setQueryStatus(hash, "fetching");
2988
+ try {
2989
+ const { remoteFetchMs, stillRemoteIds } = await this.syncEngine.syncRecords(diff);
2990
+ if (fetching) this.dataModule.recordRemoteFetch(hash, remoteFetchMs);
2991
+ if (stillRemoteIds.length > 0) {
2992
+ const CONVERGE_AFTER = 3;
2993
+ const toConverge = [];
2994
+ for (const id of stillRemoteIds) {
2995
+ const key = `${hash}:${id}`;
2996
+ const n = (this.stillRemoteStreaks.get(key) ?? 0) + 1;
2997
+ if (n >= CONVERGE_AFTER) {
2998
+ this.stillRemoteStreaks.delete(key);
2999
+ toConverge.push(id);
3000
+ } else this.stillRemoteStreaks.set(key, n);
3001
+ }
3002
+ if (toConverge.length > 0) {
3003
+ const local = this.dataModule.getQueryByHash(hash)?.config.localArray;
3004
+ if (local && local.length > 0) {
3005
+ const drop = new Set(toConverge);
3006
+ const next = local.filter(([id]) => !drop.has(id));
3007
+ if (next.length !== local.length) await this.dataModule.updateQueryLocalArray(hash, next);
3008
+ }
3009
+ }
3010
+ }
3011
+ } finally {
3012
+ if (fetching) this.dataModule.setQueryStatus(hash, "idle");
3013
+ }
3014
+ }
3015
+ /**
3016
+ * Record ids with a pending local DELETE in the outbox (`_00_pending_mutations`).
3017
+ * Sync must not re-fetch/re-insert these — the remote delete is async, so the
3018
+ * server's `_00_list_ref` still lists them until it's processed, and the diff
3019
+ * would otherwise resurrect a just-deleted record.
3020
+ */
3021
+ async getPendingDeleteIds() {
3022
+ try {
3023
+ const [rows] = await this.local.query("SELECT recordId FROM _00_pending_mutations WHERE mutationType = 'delete'");
3024
+ return new Set((rows ?? []).map((r) => encodeRecordId(r.recordId)));
3025
+ } catch (err) {
3026
+ this.logger.warn({
3027
+ err,
3028
+ Category: "sp00ky-client::Sp00kySync::getPendingDeleteIds"
3029
+ }, "Failed to read pending deletes; sync may briefly resurrect a just-deleted record");
3030
+ return /* @__PURE__ */ new Set();
3031
+ }
2012
3032
  }
2013
3033
  /**
2014
3034
  * Enqueues a list of mutations (up events) to be sent to the remote.
@@ -2021,7 +3041,7 @@ var SpookySync = class {
2021
3041
  try {
2022
3042
  this.logger.debug({
2023
3043
  queryHash,
2024
- Category: "spooky-client::SpookySync::registerQuery"
3044
+ Category: "sp00ky-client::Sp00kySync::registerQuery"
2025
3045
  }, "Register Query state");
2026
3046
  await this.createRemoteQuery(queryHash);
2027
3047
  await this.syncQuery(queryHash);
@@ -2029,7 +3049,7 @@ var SpookySync = class {
2029
3049
  } catch (e) {
2030
3050
  this.logger.error({
2031
3051
  err: e,
2032
- Category: "spooky-client::SpookySync::registerQuery"
3052
+ Category: "sp00ky-client::Sp00kySync::registerQuery"
2033
3053
  }, "registerQuery error");
2034
3054
  throw e;
2035
3055
  }
@@ -2039,28 +3059,28 @@ var SpookySync = class {
2039
3059
  if (!queryState) {
2040
3060
  this.logger.warn({
2041
3061
  queryHash,
2042
- Category: "spooky-client::SpookySync::createRemoteQuery"
3062
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
2043
3063
  }, "Query to register not found");
2044
3064
  throw new Error("Query to register not found");
2045
3065
  }
2046
3066
  await this.remote.query("fn::query::register($config)", { config: {
2047
- clientId: this.clientId,
2048
3067
  id: queryState.config.id,
2049
3068
  surql: queryState.config.surql,
2050
3069
  params: queryState.config.params,
2051
3070
  ttl: queryState.config.ttl
2052
3071
  } });
2053
- const [items] = await this.remote.query(surql.selectByFieldsAnd("_spooky_list_ref", ["in"], ["out", "version"]), { in: queryState.config.id });
3072
+ const listRefTbl = this.listRefTable();
3073
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2054
3074
  this.logger.trace({
2055
3075
  queryId: encodeRecordId(queryState.config.id),
2056
3076
  items,
2057
- Category: "spooky-client::SpookySync::createRemoteQuery"
3077
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
2058
3078
  }, "Got query record version array from remote");
2059
3079
  const array = items.map((item) => [encodeRecordId(item.out), item.version]);
2060
3080
  this.logger.debug({
2061
3081
  queryId: encodeRecordId(queryState.config.id),
2062
3082
  array,
2063
- Category: "spooky-client::SpookySync::createRemoteQuery"
3083
+ Category: "sp00ky-client::Sp00kySync::createRemoteQuery"
2064
3084
  }, "createdRemoteQuery");
2065
3085
  if (array) await this.dataModule.updateQueryRemoteArray(queryHash, array);
2066
3086
  }
@@ -2069,7 +3089,7 @@ var SpookySync = class {
2069
3089
  if (!queryState) {
2070
3090
  this.logger.warn({
2071
3091
  queryHash,
2072
- Category: "spooky-client::SpookySync::heartbeatQuery"
3092
+ Category: "sp00ky-client::Sp00kySync::heartbeatQuery"
2073
3093
  }, "Query to register not found");
2074
3094
  throw new Error("Query to register not found");
2075
3095
  }
@@ -2077,14 +3097,17 @@ var SpookySync = class {
2077
3097
  }
2078
3098
  async cleanupQuery(queryHash) {
2079
3099
  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
- }
3100
+ if (!queryState) return;
3101
+ if (this.dataModule.hasSubscribers(queryHash)) return;
2087
3102
  await this.remote.query(`DELETE $id`, { id: queryState.config.id });
3103
+ if (this.dataModule.hasSubscribers(queryHash)) {
3104
+ this.enqueueDownEvent({
3105
+ type: "register",
3106
+ payload: { hash: queryHash }
3107
+ });
3108
+ return;
3109
+ }
3110
+ this.dataModule.finalizeDeregister(queryHash);
2088
3111
  }
2089
3112
  };
2090
3113
 
@@ -2095,12 +3118,64 @@ function createAuthEventSystem() {
2095
3118
  return createEventSystem([AuthEventTypes.AuthStateChanged]);
2096
3119
  }
2097
3120
 
3121
+ //#endregion
3122
+ //#region src/modules/devtools/versions.ts
3123
+ const UNAVAILABLE = "unavailable";
3124
+ function emptyBackendVersions() {
3125
+ return {
3126
+ ssp: UNAVAILABLE,
3127
+ scheduler: UNAVAILABLE,
3128
+ surrealdb: UNAVAILABLE
3129
+ };
3130
+ }
3131
+ function emptyBackendInfo() {
3132
+ return {
3133
+ versions: emptyBackendVersions(),
3134
+ entities: []
3135
+ };
3136
+ }
3137
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
3138
+ function normalizeServerVersion(v) {
3139
+ return String(v).replace(/^surrealdb-/i, "").trim();
3140
+ }
3141
+ /**
3142
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
3143
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
3144
+ * how the result is unwrapped it may arrive as the array itself, a single
3145
+ * object, or `null`. Tolerant of all three.
3146
+ */
3147
+ function toEntityArray(raw) {
3148
+ if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
3149
+ if (raw && typeof raw === "object") return [raw];
3150
+ return [];
3151
+ }
3152
+ /**
3153
+ * Derive component versions + the full entity list from a `/info` entity array.
3154
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
3155
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
3156
+ */
3157
+ function parseBackendInfo(raw) {
3158
+ const entities = toEntityArray(raw);
3159
+ const versions = emptyBackendVersions();
3160
+ for (const entity of entities) {
3161
+ const version = entity.version ? String(entity.version) : void 0;
3162
+ if (entity.entity === "ssp" && version) versions.ssp = version;
3163
+ else if (entity.entity === "scheduler" && version) versions.scheduler = version;
3164
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
3165
+ }
3166
+ return {
3167
+ versions,
3168
+ entities
3169
+ };
3170
+ }
3171
+
2098
3172
  //#endregion
2099
3173
  //#region src/modules/devtools/index.ts
2100
3174
  var DevToolsService = class {
2101
3175
  eventsHistory = [];
2102
3176
  eventIdCounter = 0;
2103
- version = "1.0.0";
3177
+ version = "0.0.1-canary.70";
3178
+ backendInfo = emptyBackendInfo();
2104
3179
  constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
2105
3180
  this.databaseService = databaseService;
2106
3181
  this.remoteDatabaseService = remoteDatabaseService;
@@ -2112,7 +3187,26 @@ var DevToolsService = class {
2112
3187
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
2113
3188
  this.notifyDevTools();
2114
3189
  });
2115
- this.logger.debug({ Category: "spooky-client::DevToolsService::init" }, "Service initialized");
3190
+ this.refreshBackendVersions();
3191
+ this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
3192
+ }
3193
+ /**
3194
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
3195
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
3196
+ * Never throws: on failure the info stays empty/'unavailable'.
3197
+ */
3198
+ async refreshBackendVersions() {
3199
+ try {
3200
+ const result = await this.remoteDatabaseService.query("RETURN fn::spooky::info()");
3201
+ this.backendInfo = parseBackendInfo(Array.isArray(result) ? result[0] : result);
3202
+ } catch (err) {
3203
+ this.logger.debug({
3204
+ err,
3205
+ Category: "sp00ky-client::DevToolsService::versions"
3206
+ }, "fn::spooky::info() unavailable; backend versions stay unavailable");
3207
+ this.backendInfo = emptyBackendInfo();
3208
+ }
3209
+ this.notifyDevTools();
2116
3210
  }
2117
3211
  getActiveQueries() {
2118
3212
  const result = /* @__PURE__ */ new Map();
@@ -2122,6 +3216,8 @@ var DevToolsService = class {
2122
3216
  result.set(queryHash, {
2123
3217
  queryHash,
2124
3218
  status: "active",
3219
+ fetchStatus: q.status,
3220
+ isFetching: q.status === "fetching",
2125
3221
  createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
2126
3222
  lastUpdate: Date.now(),
2127
3223
  updateCount: q.updateCount,
@@ -2130,7 +3226,8 @@ var DevToolsService = class {
2130
3226
  dataSize: q.records?.length || 0,
2131
3227
  data: q.records,
2132
3228
  localArray: q.config.localArray,
2133
- remoteArray: q.config.remoteArray
3229
+ remoteArray: q.config.remoteArray,
3230
+ timings: this.dataManager.phaseTimings(q)
2134
3231
  });
2135
3232
  });
2136
3233
  return result;
@@ -2138,7 +3235,7 @@ var DevToolsService = class {
2138
3235
  onQueryInitialized(payload) {
2139
3236
  this.logger.debug({
2140
3237
  payload,
2141
- Category: "spooky-client::DevToolsService::onQueryInitialized"
3238
+ Category: "sp00ky-client::DevToolsService::onQueryInitialized"
2142
3239
  }, "QueryInitialized");
2143
3240
  const queryHash = this.hashString(payload.queryId.toString());
2144
3241
  this.addEvent("QUERY_REQUEST_INIT", {
@@ -2151,7 +3248,7 @@ var DevToolsService = class {
2151
3248
  onQueryUpdated(payload) {
2152
3249
  this.logger.debug({
2153
3250
  id: payload.queryId?.toString(),
2154
- Category: "spooky-client::DevToolsService::onQueryUpdated"
3251
+ Category: "sp00ky-client::DevToolsService::onQueryUpdated"
2155
3252
  }, "QueryUpdated");
2156
3253
  const queryHash = this.hashString(payload.queryId.toString());
2157
3254
  this.addEvent("QUERY_UPDATED", {
@@ -2163,7 +3260,7 @@ var DevToolsService = class {
2163
3260
  onStreamUpdate(update) {
2164
3261
  this.logger.debug({
2165
3262
  update,
2166
- Category: "spooky-client::DevToolsService::onStreamUpdate"
3263
+ Category: "sp00ky-client::DevToolsService::onStreamUpdate"
2167
3264
  }, "StreamUpdate");
2168
3265
  this.addEvent("STREAM_UPDATE", { updates: [update] });
2169
3266
  this.notifyDevTools();
@@ -2210,6 +3307,15 @@ var DevToolsService = class {
2210
3307
  userId: this.authService.currentUser?.id
2211
3308
  },
2212
3309
  version: this.version,
3310
+ versions: {
3311
+ frontend: {
3312
+ core: "0.0.1-canary.70",
3313
+ wasm: "0.0.1-canary.70",
3314
+ surrealdb: "3.0.3"
3315
+ },
3316
+ backend: this.backendInfo.versions,
3317
+ entities: this.backendInfo.entities
3318
+ },
2213
3319
  database: {
2214
3320
  tables: this.schema.tables.map((t) => t.name),
2215
3321
  tableData: {}
@@ -2218,8 +3324,8 @@ var DevToolsService = class {
2218
3324
  }
2219
3325
  notifyDevTools() {
2220
3326
  if (typeof window !== "undefined") window.postMessage({
2221
- type: "SPOOKY_STATE_CHANGED",
2222
- source: "spooky-devtools-page",
3327
+ type: "SP00KY_STATE_CHANGED",
3328
+ source: "sp00ky-devtools-page",
2223
3329
  state: this.getState()
2224
3330
  }, "*");
2225
3331
  }
@@ -2245,13 +3351,14 @@ var DevToolsService = class {
2245
3351
  }
2246
3352
  exposeToWindow() {
2247
3353
  if (typeof window !== "undefined") {
2248
- window.__SPOOKY__ = {
3354
+ window.__00__ = {
2249
3355
  version: this.version,
2250
3356
  getState: () => this.getState(),
2251
3357
  clearHistory: () => {
2252
3358
  this.eventsHistory = [];
2253
3359
  this.notifyDevTools();
2254
3360
  },
3361
+ refreshVersions: () => this.refreshBackendVersions(),
2255
3362
  getTableData: async (tableName) => {
2256
3363
  try {
2257
3364
  const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
@@ -2266,7 +3373,7 @@ var DevToolsService = class {
2266
3373
  } catch (e) {
2267
3374
  this.logger.error({
2268
3375
  err: e,
2269
- Category: "spooky-client::DevToolsService::exposeToWindow"
3376
+ Category: "sp00ky-client::DevToolsService::exposeToWindow"
2270
3377
  }, "Failed to get table data");
2271
3378
  return [];
2272
3379
  }
@@ -2298,7 +3405,7 @@ var DevToolsService = class {
2298
3405
  this.logger.debug({
2299
3406
  query,
2300
3407
  target,
2301
- Category: "spooky-client::DevToolsService::runQuery"
3408
+ Category: "sp00ky-client::DevToolsService::runQuery"
2302
3409
  }, "Running query (START)");
2303
3410
  const service = target === "remote" ? this.remoteDatabaseService : this.databaseService;
2304
3411
  const startTime = Date.now();
@@ -2309,7 +3416,7 @@ var DevToolsService = class {
2309
3416
  time: queryTime,
2310
3417
  resultType: typeof result,
2311
3418
  isArray: Array.isArray(result),
2312
- Category: "spooky-client::DevToolsService::runQuery"
3419
+ Category: "sp00ky-client::DevToolsService::runQuery"
2313
3420
  }, "Database returned result");
2314
3421
  const serializeStart = Date.now();
2315
3422
  const serialized = this.serializeForDevTools(result);
@@ -2317,7 +3424,7 @@ var DevToolsService = class {
2317
3424
  this.logger.debug({
2318
3425
  serializeTime,
2319
3426
  serializedLength: JSON.stringify(serialized).length,
2320
- Category: "spooky-client::DevToolsService::runQuery"
3427
+ Category: "sp00ky-client::DevToolsService::runQuery"
2321
3428
  }, "Serialization complete");
2322
3429
  return {
2323
3430
  success: true,
@@ -2329,7 +3436,7 @@ var DevToolsService = class {
2329
3436
  err: e,
2330
3437
  query,
2331
3438
  target,
2332
- Category: "spooky-client::DevToolsService::runQuery"
3439
+ Category: "sp00ky-client::DevToolsService::runQuery"
2333
3440
  }, "Query execution failed");
2334
3441
  return {
2335
3442
  success: false,
@@ -2339,13 +3446,14 @@ var DevToolsService = class {
2339
3446
  }
2340
3447
  };
2341
3448
  window.postMessage({
2342
- type: "SPOOKY_DETECTED",
2343
- source: "spooky-devtools-page",
3449
+ type: "SP00KY_DETECTED",
3450
+ source: "sp00ky-devtools-page",
2344
3451
  data: {
2345
3452
  version: this.version,
2346
3453
  detected: true
2347
3454
  }
2348
3455
  }, "*");
3456
+ window.dispatchEvent(new CustomEvent("sp00ky:init"));
2349
3457
  }
2350
3458
  }
2351
3459
  };
@@ -2396,9 +3504,9 @@ var AuthService = class {
2396
3504
  async check(accessToken) {
2397
3505
  this.isLoading = true;
2398
3506
  try {
2399
- const token = accessToken || await this.persistenceClient.get("spooky_auth_token");
3507
+ const token = accessToken || await this.persistenceClient.get("sp00ky_auth_token");
2400
3508
  if (!token) {
2401
- this.logger.debug({ Category: "spooky-client::AuthService::check" }, "No token found in storage or arguments");
3509
+ this.logger.debug({ Category: "sp00ky-client::AuthService::check" }, "No token found in storage or arguments");
2402
3510
  this.isLoading = false;
2403
3511
  this.isAuthenticated = false;
2404
3512
  this.notifyListeners();
@@ -2411,22 +3519,22 @@ var AuthService = class {
2411
3519
  if (user && user.id) {
2412
3520
  this.logger.info({
2413
3521
  user,
2414
- Category: "spooky-client::AuthService::check"
3522
+ Category: "sp00ky-client::AuthService::check"
2415
3523
  }, "Auth check complete (via $auth.id)");
2416
3524
  await this.setSession(token, user);
2417
3525
  } else {
2418
- this.logger.warn({ Category: "spooky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
3526
+ this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "$auth.id empty, attempting manual user fetch");
2419
3527
  const manualResult = await this.remote.query("SELECT * FROM user WHERE id = $auth.id LIMIT 1");
2420
3528
  const manualItems = Array.isArray(manualResult) && Array.isArray(manualResult[0]) ? manualResult[0] : manualResult;
2421
3529
  const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
2422
3530
  if (manualUser && manualUser.id) {
2423
3531
  this.logger.info({
2424
3532
  user: manualUser,
2425
- Category: "spooky-client::AuthService::check"
3533
+ Category: "sp00ky-client::AuthService::check"
2426
3534
  }, "Auth check complete (via manual fetch)");
2427
3535
  await this.setSession(token, manualUser);
2428
3536
  } else {
2429
- this.logger.warn({ Category: "spooky-client::AuthService::check" }, "Token valid but user not found via fallback");
3537
+ this.logger.warn({ Category: "sp00ky-client::AuthService::check" }, "Token valid but user not found via fallback");
2430
3538
  await this.signOut();
2431
3539
  }
2432
3540
  }
@@ -2434,7 +3542,7 @@ var AuthService = class {
2434
3542
  this.logger.error({
2435
3543
  error,
2436
3544
  stack: error.stack,
2437
- Category: "spooky-client::AuthService::check"
3545
+ Category: "sp00ky-client::AuthService::check"
2438
3546
  }, "Auth check failed");
2439
3547
  await this.signOut();
2440
3548
  } finally {
@@ -2448,17 +3556,17 @@ var AuthService = class {
2448
3556
  this.token = null;
2449
3557
  this.currentUser = null;
2450
3558
  this.isAuthenticated = false;
2451
- await this.persistenceClient.remove("spooky_auth_token");
3559
+ await this.persistenceClient.remove("sp00ky_auth_token");
2452
3560
  try {
2453
3561
  await this.remote.getClient().invalidate();
2454
- } catch (e) {}
3562
+ } catch (_e) {}
2455
3563
  this.notifyListeners();
2456
3564
  }
2457
3565
  async setSession(token, user) {
2458
3566
  this.token = token;
2459
3567
  this.currentUser = user;
2460
3568
  this.isAuthenticated = true;
2461
- await this.persistenceClient.set("spooky_auth_token", token);
3569
+ await this.persistenceClient.set("sp00ky_auth_token", token);
2462
3570
  this.notifyListeners();
2463
3571
  }
2464
3572
  async signUp(accessName, params) {
@@ -2470,13 +3578,13 @@ var AuthService = class {
2470
3578
  this.logger.info({
2471
3579
  accessName,
2472
3580
  runtimeParams,
2473
- Category: "spooky-client::AuthService::signUp"
3581
+ Category: "sp00ky-client::AuthService::signUp"
2474
3582
  }, "Attempting signup");
2475
3583
  const { access } = await this.remote.getClient().signup({
2476
3584
  access: accessName,
2477
3585
  variables: runtimeParams
2478
3586
  });
2479
- this.logger.info({ Category: "spooky-client::AuthService::signUp" }, "Signup successful, token received");
3587
+ this.logger.info({ Category: "sp00ky-client::AuthService::signUp" }, "Signup successful, token received");
2480
3588
  await this.check(access);
2481
3589
  }
2482
3590
  async signIn(accessName, params) {
@@ -2487,7 +3595,7 @@ var AuthService = class {
2487
3595
  if (missingParams.length > 0) throw new Error(`Missing required signin params for '${accessName}': ${missingParams.join(", ")}`);
2488
3596
  this.logger.info({
2489
3597
  accessName,
2490
- Category: "spooky-client::AuthService::signIn"
3598
+ Category: "sp00ky-client::AuthService::signIn"
2491
3599
  }, "Attempting signin");
2492
3600
  const { access } = await this.remote.getClient().signin({
2493
3601
  access: accessName,
@@ -2504,6 +3612,8 @@ var StreamProcessorService = class {
2504
3612
  processor;
2505
3613
  isInitialized = false;
2506
3614
  receivers = [];
3615
+ batching = false;
3616
+ batchBuffer = /* @__PURE__ */ new Map();
2507
3617
  constructor(events, db, persistenceClient, logger) {
2508
3618
  this.events = events;
2509
3619
  this.db = db;
@@ -2518,25 +3628,91 @@ var StreamProcessorService = class {
2518
3628
  this.receivers.push(receiver);
2519
3629
  }
2520
3630
  notifyUpdates(updates) {
3631
+ if (this.batching) {
3632
+ for (const update of updates) {
3633
+ const prev = this.batchBuffer.get(update.queryHash);
3634
+ const sum = (a, b) => (a ?? 0) + (b ?? 0);
3635
+ this.batchBuffer.set(update.queryHash, {
3636
+ ...update,
3637
+ op: "CREATE",
3638
+ materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
3639
+ storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
3640
+ circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
3641
+ transformMs: sum(prev?.transformMs, update.transformMs)
3642
+ });
3643
+ }
3644
+ return;
3645
+ }
3646
+ this.dispatchUpdates(updates);
3647
+ }
3648
+ dispatchUpdates(updates) {
2521
3649
  for (const update of updates) for (const receiver of this.receivers) receiver.onStreamUpdate(update);
2522
3650
  }
2523
3651
  /**
3652
+ * Ingest a batch of record changes as a single bulk operation, firing only
3653
+ * one coalesced `StreamUpdate` per affected query once every record has been
3654
+ * ingested (instead of one update per record). Use this whenever multiple
3655
+ * records land at once — e.g. sync fetching N missing rows — so a list query
3656
+ * re-runs and the UI re-renders once for the whole batch rather than
3657
+ * row-by-row.
3658
+ *
3659
+ * Internally opens a coalescing window, ingests each record, then flushes;
3660
+ * processor state is persisted once for the whole batch. No-op for an empty
3661
+ * batch.
3662
+ */
3663
+ ingestMany(records) {
3664
+ if (records.length === 0) return;
3665
+ this.beginCoalescing();
3666
+ try {
3667
+ for (const record of records) this.ingest(record.table, record.op, record.id, record.record);
3668
+ } finally {
3669
+ this.flushCoalescing();
3670
+ }
3671
+ }
3672
+ /**
3673
+ * Open a coalescing window. While open, the per-record stream updates
3674
+ * emitted by `ingest` are buffered (one entry per queryHash) instead of
3675
+ * dispatched. Always paired with `flushCoalescing()` in a try/finally by
3676
+ * `ingestMany` so the window always closes — otherwise the processor stays
3677
+ * stuck buffering forever.
3678
+ *
3679
+ * No-op if a window is already open (nested batches aren't expected here).
3680
+ */
3681
+ beginCoalescing() {
3682
+ if (this.batching) return;
3683
+ this.batching = true;
3684
+ this.batchBuffer.clear();
3685
+ }
3686
+ /**
3687
+ * Close the coalescing window and flush: dispatch one coalesced
3688
+ * `StreamUpdate` per buffered queryHash, then persist processor state once
3689
+ * for the whole batch (instead of once per ingest).
3690
+ */
3691
+ flushCoalescing() {
3692
+ if (!this.batching) return;
3693
+ this.batching = false;
3694
+ const buffered = Array.from(this.batchBuffer.values());
3695
+ this.batchBuffer.clear();
3696
+ if (buffered.length > 0) this.dispatchUpdates(buffered);
3697
+ this.saveState();
3698
+ }
3699
+ /**
2524
3700
  * Initialize the WASM module and processor.
2525
3701
  * This must be called before using other methods.
2526
3702
  */
2527
3703
  async init() {
2528
3704
  if (this.isInitialized) return;
2529
- this.logger.info({ Category: "spooky-client::StreamProcessorService::init" }, "Initializing WASM...");
3705
+ this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initializing WASM...");
2530
3706
  try {
2531
3707
  await init();
2532
- this.processor = new SpookyProcessor();
3708
+ this.processor = new Sp00kyProcessor();
2533
3709
  await this.loadState();
2534
3710
  this.isInitialized = true;
2535
- this.logger.info({ Category: "spooky-client::StreamProcessorService::init" }, "Initialized successfully");
3711
+ this.logger.info({ Category: "sp00ky-client::StreamProcessorService::init" }, "Initialized successfully");
2536
3712
  } catch (e) {
2537
3713
  this.logger.error({
2538
3714
  error: e,
2539
- Category: "spooky-client::StreamProcessorService::init"
3715
+ Category: "sp00ky-client::StreamProcessorService::init"
2540
3716
  }, "Failed to initialize");
2541
3717
  throw e;
2542
3718
  }
@@ -2544,37 +3720,54 @@ var StreamProcessorService = class {
2544
3720
  async loadState() {
2545
3721
  if (!this.processor) return;
2546
3722
  try {
2547
- const result = await this.persistenceClient.get("_spooky_stream_processor_state");
3723
+ const result = await this.persistenceClient.get("_00_stream_processor_state");
2548
3724
  if (Array.isArray(result) && result.length > 0 && Array.isArray(result[0]) && result[0].length > 0 && result[0][0]?.state) {
2549
3725
  const state = result[0][0].state;
2550
3726
  this.logger.info({
2551
3727
  stateLength: state.length,
2552
- Category: "spooky-client::StreamProcessorService::loadState"
3728
+ Category: "sp00ky-client::StreamProcessorService::loadState"
2553
3729
  }, "Loading state from DB");
2554
3730
  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");
3731
+ else this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "load_state method not found on processor");
3732
+ } else this.logger.info({ Category: "sp00ky-client::StreamProcessorService::loadState" }, "No saved state found");
2557
3733
  } catch (e) {
2558
3734
  this.logger.error({
2559
3735
  error: e,
2560
- Category: "spooky-client::StreamProcessorService::loadState"
3736
+ Category: "sp00ky-client::StreamProcessorService::loadState"
2561
3737
  }, "Failed to load state");
2562
3738
  }
2563
3739
  }
3740
+ /**
3741
+ * Seed per-table `select` permission predicates ({ [table]: whereText }).
3742
+ * Must run after the processor exists and before any `register_view`, else
3743
+ * non-`_00_` tables are default-denied and registration fails.
3744
+ */
3745
+ setPermissions(permissions) {
3746
+ if (!this.processor) return;
3747
+ if (typeof this.processor.set_permissions !== "function") {
3748
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::setPermissions" }, "set_permissions not found on processor (stale WASM build?)");
3749
+ return;
3750
+ }
3751
+ this.processor.set_permissions(permissions);
3752
+ this.logger.info({
3753
+ tables: Object.keys(permissions).length,
3754
+ Category: "sp00ky-client::StreamProcessorService::setPermissions"
3755
+ }, "Seeded table permissions");
3756
+ }
2564
3757
  async saveState() {
2565
3758
  if (!this.processor) return;
2566
3759
  try {
2567
3760
  if (typeof this.processor.save_state === "function") {
2568
3761
  const state = this.processor.save_state();
2569
3762
  if (state) {
2570
- await this.persistenceClient.set("_spooky_stream_processor_state", state);
2571
- this.logger.trace({ Category: "spooky-client::StreamProcessorService::saveState" }, "State saved");
3763
+ await this.persistenceClient.set("_00_stream_processor_state", state);
3764
+ this.logger.trace({ Category: "sp00ky-client::StreamProcessorService::saveState" }, "State saved");
2572
3765
  }
2573
3766
  }
2574
3767
  } catch (e) {
2575
3768
  this.logger.error({
2576
3769
  error: e,
2577
- Category: "spooky-client::StreamProcessorService::saveState"
3770
+ Category: "sp00ky-client::StreamProcessorService::saveState"
2578
3771
  }, "Failed to save state");
2579
3772
  }
2580
3773
  }
@@ -2588,36 +3781,43 @@ var StreamProcessorService = class {
2588
3781
  table,
2589
3782
  op,
2590
3783
  id,
2591
- Category: "spooky-client::StreamProcessorService::ingest"
3784
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2592
3785
  }, "Ingesting into ssp");
2593
3786
  if (!this.processor) {
2594
- this.logger.warn({ Category: "spooky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
3787
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::ingest" }, "Not initialized, skipping ingest");
2595
3788
  return [];
2596
3789
  }
2597
3790
  try {
2598
3791
  const normalizedRecord = this.normalizeValue(record);
3792
+ const t0 = performance.now();
2599
3793
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
3794
+ const materializationTimeMs = performance.now() - t0;
2600
3795
  this.logger.debug({
2601
3796
  table,
2602
3797
  op,
2603
3798
  id,
2604
3799
  rawUpdates: rawUpdates.length,
2605
- Category: "spooky-client::StreamProcessorService::ingest"
3800
+ materializationTimeMs,
3801
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2606
3802
  }, "Ingesting into ssp done");
2607
3803
  if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
2608
3804
  const updates = rawUpdates.map((u) => ({
2609
3805
  queryHash: u.query_id,
2610
3806
  localArray: u.result_data,
2611
- op
3807
+ op,
3808
+ materializationTimeMs,
3809
+ storeApplyMs: u.timing_store_apply_ms,
3810
+ circuitStepMs: u.timing_circuit_step_ms,
3811
+ transformMs: u.timing_transform_ms
2612
3812
  }));
2613
3813
  this.notifyUpdates(updates);
2614
3814
  }
2615
- this.saveState();
3815
+ if (!this.batching) this.saveState();
2616
3816
  return rawUpdates;
2617
3817
  } catch (e) {
2618
3818
  this.logger.error({
2619
3819
  error: e,
2620
- Category: "spooky-client::StreamProcessorService::ingest"
3820
+ Category: "sp00ky-client::StreamProcessorService::ingest"
2621
3821
  }, "Ingesting into ssp failed");
2622
3822
  }
2623
3823
  return [];
@@ -2628,14 +3828,14 @@ var StreamProcessorService = class {
2628
3828
  */
2629
3829
  registerQueryPlan(queryPlan) {
2630
3830
  if (!this.processor) {
2631
- this.logger.warn({ Category: "spooky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
3831
+ this.logger.warn({ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan" }, "Not initialized, skipping registration");
2632
3832
  return;
2633
3833
  }
2634
3834
  this.logger.debug({
2635
3835
  queryHash: queryPlan.queryHash,
2636
3836
  surql: queryPlan.surql,
2637
3837
  params: queryPlan.params,
2638
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
3838
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2639
3839
  }, "Registering query plan");
2640
3840
  try {
2641
3841
  const normalizedParams = this.normalizeValue(queryPlan.params);
@@ -2649,25 +3849,30 @@ var StreamProcessorService = class {
2649
3849
  });
2650
3850
  this.logger.debug({
2651
3851
  initialUpdate,
2652
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
3852
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2653
3853
  }, "register_view result");
2654
3854
  if (!initialUpdate) throw new Error("Failed to register query plan");
2655
3855
  const update = {
2656
3856
  queryHash: initialUpdate.query_id,
2657
- localArray: initialUpdate.result_data
3857
+ localArray: initialUpdate.result_data,
3858
+ registration: {
3859
+ parseMs: initialUpdate.timing_parse_ms ?? 0,
3860
+ planMs: initialUpdate.timing_plan_ms ?? 0,
3861
+ snapshotMs: initialUpdate.timing_snapshot_ms ?? 0
3862
+ }
2658
3863
  };
2659
3864
  this.saveState();
2660
3865
  this.logger.debug({
2661
3866
  queryHash: queryPlan.queryHash,
2662
3867
  surql: queryPlan.surql,
2663
3868
  params: queryPlan.params,
2664
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
3869
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2665
3870
  }, "Registered query plan");
2666
3871
  return update;
2667
3872
  } catch (e) {
2668
3873
  this.logger.error({
2669
3874
  error: e,
2670
- Category: "spooky-client::StreamProcessorService::registerQueryPlan"
3875
+ Category: "sp00ky-client::StreamProcessorService::registerQueryPlan"
2671
3876
  }, "Error registering query plan");
2672
3877
  throw e;
2673
3878
  }
@@ -2683,13 +3888,14 @@ var StreamProcessorService = class {
2683
3888
  } catch (e) {
2684
3889
  this.logger.error({
2685
3890
  error: e,
2686
- Category: "spooky-client::StreamProcessorService::unregisterQueryPlan"
3891
+ Category: "sp00ky-client::StreamProcessorService::unregisterQueryPlan"
2687
3892
  }, "Error unregistering query plan");
2688
3893
  }
2689
3894
  }
2690
3895
  normalizeValue(value) {
2691
3896
  if (value === null || value === void 0) return value;
2692
3897
  if (typeof value === "object") {
3898
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
2693
3899
  const hasTable = "table" in value && typeof value.table?.toString === "function";
2694
3900
  const hasId = "id" in value;
2695
3901
  const hasToString = typeof value.toString === "function";
@@ -2698,7 +3904,7 @@ var StreamProcessorService = class {
2698
3904
  const result = value.toString();
2699
3905
  this.logger.trace({
2700
3906
  result,
2701
- Category: "spooky-client::StreamProcessorService::normalizeValue"
3907
+ Category: "sp00ky-client::StreamProcessorService::normalizeValue"
2702
3908
  }, "RecordId detected");
2703
3909
  return result;
2704
3910
  }
@@ -2714,6 +3920,51 @@ var StreamProcessorService = class {
2714
3920
  }
2715
3921
  };
2716
3922
 
3923
+ //#endregion
3924
+ //#region src/services/stream-processor/permissions.ts
3925
+ /**
3926
+ * Extract per-table `select` permission predicates from a raw `.surql` schema.
3927
+ *
3928
+ * The in-browser SSP default-denies any non-`_00_` table that has no permission
3929
+ * predicate registered (see `permission_inject::build_predicate`). The client
3930
+ * already holds the full schema text (`config.schemaSurql`), so we parse each
3931
+ * `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
3932
+ * processor via `set_permissions` at boot — mirroring the native path that
3933
+ * seeds the same map from `INFO FOR DB`.
3934
+ *
3935
+ * Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
3936
+ * expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
3937
+ * `select` permission).
3938
+ */
3939
+ function extractSelectPermissions(schemaSurql) {
3940
+ const out = {};
3941
+ if (!schemaSurql) return out;
3942
+ const cleaned = schemaSurql.replace(/--[^\n]*/g, "");
3943
+ const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
3944
+ let m;
3945
+ while ((m = tableStmt.exec(cleaned)) !== null) {
3946
+ const table = m[1];
3947
+ const body = m[2];
3948
+ out[table] = selectPredicateFromBody(body);
3949
+ }
3950
+ return out;
3951
+ }
3952
+ function selectPredicateFromBody(body) {
3953
+ const permIdx = body.search(/\bPERMISSIONS\b/i);
3954
+ if (permIdx === -1) return "false";
3955
+ const perms = body.slice(permIdx + 11).trim();
3956
+ if (/^FULL\b/i.test(perms)) return "true";
3957
+ if (/^NONE\b/i.test(perms)) return "false";
3958
+ const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
3959
+ for (const group of groups) {
3960
+ const where = group.search(/\bWHERE\b/i);
3961
+ if (where === -1) continue;
3962
+ const actions = group.slice(0, where).toLowerCase();
3963
+ if (/\bselect\b/.test(actions)) return group.slice(where + 5).trim();
3964
+ }
3965
+ return "false";
3966
+ }
3967
+
2717
3968
  //#endregion
2718
3969
  //#region src/modules/cache/index.ts
2719
3970
  /**
@@ -2741,7 +3992,7 @@ var CacheModule = class {
2741
3992
  this.logger.debug({
2742
3993
  queryHash: update.queryHash,
2743
3994
  arrayLength: update.localArray?.length,
2744
- Category: "spooky-client::CacheModule::onStreamUpdate"
3995
+ Category: "sp00ky-client::CacheModule::onStreamUpdate"
2745
3996
  }, "Stream update received");
2746
3997
  this.streamUpdateCallback(update);
2747
3998
  }
@@ -2764,7 +4015,7 @@ var CacheModule = class {
2764
4015
  if (records.length === 0) return;
2765
4016
  this.logger.debug({
2766
4017
  count: records.length,
2767
- Category: "spooky-client::CacheModule::saveBatch"
4018
+ Category: "sp00ky-client::CacheModule::saveBatch"
2768
4019
  }, "Saving record batch");
2769
4020
  try {
2770
4021
  const populatedRecords = records.map((record) => {
@@ -2773,13 +4024,13 @@ var CacheModule = class {
2773
4024
  ...record,
2774
4025
  record: {
2775
4026
  ...record.record,
2776
- spooky_rv: record.version
4027
+ _00_rv: record.version
2777
4028
  }
2778
4029
  };
2779
4030
  });
2780
4031
  if (!skipDbInsert) {
2781
4032
  const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
2782
- return surql.upsert(`id${i}`, `content${i}`);
4033
+ return surql.upsertMerge(`id${i}`, `content${i}`);
2783
4034
  })));
2784
4035
  const params = populatedRecords.reduce((acc, record, i) => {
2785
4036
  const { id, ...content } = record.record;
@@ -2791,20 +4042,26 @@ var CacheModule = class {
2791
4042
  }, {});
2792
4043
  await this.local.execute(query, params);
2793
4044
  }
2794
- for (const record of populatedRecords) {
4045
+ const bulk = populatedRecords.map((record) => {
2795
4046
  const recordId = encodeRecordId(record.record.id);
2796
4047
  this.versionLookups[recordId] = record.version;
2797
- this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
2798
- }
4048
+ return {
4049
+ table: record.table,
4050
+ op: record.op,
4051
+ id: recordId,
4052
+ record: record.record
4053
+ };
4054
+ });
4055
+ this.streamProcessor.ingestMany(bulk);
2799
4056
  this.logger.debug({
2800
4057
  count: records.length,
2801
- Category: "spooky-client::CacheModule::saveBatch"
4058
+ Category: "sp00ky-client::CacheModule::saveBatch"
2802
4059
  }, "Batch saved successfully");
2803
4060
  } catch (err) {
2804
4061
  this.logger.error({
2805
4062
  err,
2806
4063
  count: records.length,
2807
- Category: "spooky-client::CacheModule::saveBatch"
4064
+ Category: "sp00ky-client::CacheModule::saveBatch"
2808
4065
  }, "Failed to save batch");
2809
4066
  throw err;
2810
4067
  }
@@ -2812,27 +4069,27 @@ var CacheModule = class {
2812
4069
  /**
2813
4070
  * Delete a record from local DB and ingest deletion into DBSP
2814
4071
  */
2815
- async delete(table, id, skipDbDelete = false) {
4072
+ async delete(table, id, skipDbDelete = false, recordData = {}) {
2816
4073
  this.logger.debug({
2817
4074
  table,
2818
4075
  id,
2819
- Category: "spooky-client::CacheModule::delete"
4076
+ Category: "sp00ky-client::CacheModule::delete"
2820
4077
  }, "Deleting record");
2821
4078
  try {
2822
4079
  if (!skipDbDelete) await this.local.query("DELETE $id", { id: parseRecordIdString(id) });
2823
4080
  delete this.versionLookups[id];
2824
- await this.streamProcessor.ingest(table, "DELETE", id, {});
4081
+ this.streamProcessor.ingest(table, "DELETE", id, recordData);
2825
4082
  this.logger.debug({
2826
4083
  table,
2827
4084
  id,
2828
- Category: "spooky-client::CacheModule::delete"
4085
+ Category: "sp00ky-client::CacheModule::delete"
2829
4086
  }, "Record deleted successfully");
2830
4087
  } catch (err) {
2831
4088
  this.logger.error({
2832
4089
  err,
2833
4090
  table,
2834
4091
  id,
2835
- Category: "spooky-client::CacheModule::delete"
4092
+ Category: "sp00ky-client::CacheModule::delete"
2836
4093
  }, "Failed to delete record");
2837
4094
  throw err;
2838
4095
  }
@@ -2845,7 +4102,7 @@ var CacheModule = class {
2845
4102
  this.logger.debug({
2846
4103
  queryHash: config.queryHash,
2847
4104
  surql: config.surql,
2848
- Category: "spooky-client::CacheModule::registerQuery"
4105
+ Category: "sp00ky-client::CacheModule::registerQuery"
2849
4106
  }, "Registering query");
2850
4107
  try {
2851
4108
  const update = this.streamProcessor.registerQueryPlan({
@@ -2862,14 +4119,17 @@ var CacheModule = class {
2862
4119
  this.logger.debug({
2863
4120
  queryHash: config.queryHash,
2864
4121
  arrayLength: update.localArray?.length,
2865
- Category: "spooky-client::CacheModule::registerQuery"
4122
+ Category: "sp00ky-client::CacheModule::registerQuery"
2866
4123
  }, "Query registered successfully");
2867
- return { localArray: update.localArray };
4124
+ return {
4125
+ localArray: update.localArray,
4126
+ registrationTimings: update.registration
4127
+ };
2868
4128
  } catch (err) {
2869
4129
  this.logger.error({
2870
4130
  err,
2871
4131
  queryHash: config.queryHash,
2872
- Category: "spooky-client::CacheModule::registerQuery"
4132
+ Category: "sp00ky-client::CacheModule::registerQuery"
2873
4133
  }, "Failed to register query");
2874
4134
  throw err;
2875
4135
  }
@@ -2880,24 +4140,489 @@ var CacheModule = class {
2880
4140
  unregisterQuery(queryHash) {
2881
4141
  this.logger.debug({
2882
4142
  queryHash,
2883
- Category: "spooky-client::CacheModule::unregisterQuery"
4143
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2884
4144
  }, "Unregistering query");
2885
4145
  try {
2886
4146
  this.streamProcessor.unregisterQueryPlan(queryHash);
2887
4147
  this.logger.debug({
2888
4148
  queryHash,
2889
- Category: "spooky-client::CacheModule::unregisterQuery"
4149
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2890
4150
  }, "Query unregistered successfully");
2891
4151
  } catch (err) {
2892
4152
  this.logger.error({
2893
4153
  err,
2894
4154
  queryHash,
2895
- Category: "spooky-client::CacheModule::unregisterQuery"
4155
+ Category: "sp00ky-client::CacheModule::unregisterQuery"
2896
4156
  }, "Failed to unregister query");
2897
4157
  }
2898
4158
  }
2899
4159
  };
2900
4160
 
4161
+ //#endregion
4162
+ //#region src/modules/crdt/crdt-field.ts
4163
+ const CURSOR_COLORS = [
4164
+ "#3b82f6",
4165
+ "#ef4444",
4166
+ "#22c55e",
4167
+ "#f59e0b",
4168
+ "#8b5cf6",
4169
+ "#ec4899",
4170
+ "#14b8a6",
4171
+ "#f97316"
4172
+ ];
4173
+ function cursorColorFromName(name) {
4174
+ let hash = 0;
4175
+ for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
4176
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
4177
+ }
4178
+ var CrdtField = class {
4179
+ doc;
4180
+ pushTimer = null;
4181
+ local = null;
4182
+ remote = null;
4183
+ recordId = null;
4184
+ sessionId = "";
4185
+ unsubscribe = null;
4186
+ lastPushTime = 0;
4187
+ lastCursorPushTime = 0;
4188
+ loadedFromCrdt = false;
4189
+ pushRetryCount = 0;
4190
+ logger;
4191
+ cursorsEnabled;
4192
+ /** Remote-push debounce. Local writes happen immediately on every Loro
4193
+ * update; the remote UPSERT is coalesced over this window. Configured
4194
+ * via `Sp00kyConfig.crdtDebounceMs`, default 500. */
4195
+ remoteDebounceMs = 500;
4196
+ _onCursorUpdate = null;
4197
+ pendingCursorUpdate = null;
4198
+ /** Callback set by the editor to receive remote cursor updates.
4199
+ * Any cursor data that arrived before this callback was set will be replayed. */
4200
+ set onCursorUpdate(cb) {
4201
+ this._onCursorUpdate = cb;
4202
+ if (cb && this.pendingCursorUpdate) {
4203
+ try {
4204
+ cb(this.pendingCursorUpdate);
4205
+ } catch (e) {
4206
+ this.logger?.warn({
4207
+ error: e,
4208
+ Category: "sp00ky-client::CrdtField::onCursorUpdate"
4209
+ }, "Failed to replay pending cursor update");
4210
+ }
4211
+ this.pendingCursorUpdate = null;
4212
+ }
4213
+ }
4214
+ get onCursorUpdate() {
4215
+ return this._onCursorUpdate;
4216
+ }
4217
+ constructor(fieldName, cursorsEnabled, initialState, logger) {
4218
+ this.fieldName = fieldName;
4219
+ 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_]*`);
4220
+ this.logger = logger ?? null;
4221
+ this.cursorsEnabled = cursorsEnabled;
4222
+ this.doc = new LoroDoc();
4223
+ if (initialState && initialState.length > 0) try {
4224
+ this.doc.import(initialState);
4225
+ this.loadedFromCrdt = true;
4226
+ } catch (e) {
4227
+ this.logger?.warn({
4228
+ error: e,
4229
+ fieldName,
4230
+ Category: "sp00ky-client::CrdtField::constructor"
4231
+ }, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
4232
+ }
4233
+ }
4234
+ getDoc() {
4235
+ return this.doc;
4236
+ }
4237
+ /** Whether the LoroDoc was loaded from saved CRDT state */
4238
+ hasContent() {
4239
+ return this.loadedFromCrdt;
4240
+ }
4241
+ startSync(local, remote, recordId, sessionId, debounceMs) {
4242
+ this.local = local;
4243
+ this.remote = remote;
4244
+ this.recordId = recordId;
4245
+ this.sessionId = sessionId;
4246
+ this.remoteDebounceMs = debounceMs;
4247
+ this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
4248
+ this.persistLocal();
4249
+ this.scheduleRemotePush();
4250
+ });
4251
+ }
4252
+ stopSync() {
4253
+ if (this.unsubscribe) {
4254
+ this.unsubscribe();
4255
+ this.unsubscribe = null;
4256
+ }
4257
+ if (this.pushTimer) {
4258
+ clearTimeout(this.pushTimer);
4259
+ this.pushTimer = null;
4260
+ }
4261
+ if (this.remote && this.recordId) this.pushToRemote();
4262
+ }
4263
+ importRemote(state) {
4264
+ if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
4265
+ try {
4266
+ this.doc.import(state);
4267
+ this.persistLocal();
4268
+ } catch (e) {
4269
+ this.logger?.warn({
4270
+ error: e,
4271
+ Category: "sp00ky-client::CrdtField::importRemote"
4272
+ }, "Failed to import remote CRDT state");
4273
+ }
4274
+ }
4275
+ exportSnapshot() {
4276
+ return this.doc.export({ mode: "snapshot" });
4277
+ }
4278
+ /** Push this session's cursor blob into the parent row at
4279
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
4280
+ * field — the editor still calls this method optimistically, but
4281
+ * without `@cursor` on the schema there's nowhere to store the blob.
4282
+ * The UPDATE itself fires the parent table's LIVE feed, so other
4283
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
4284
+ async pushCursorState(encoded) {
4285
+ if (!this.remote || !this.recordId) return;
4286
+ if (!this.cursorsEnabled) return;
4287
+ this.lastCursorPushTime = Date.now();
4288
+ try {
4289
+ const state = encodeBase64(encoded);
4290
+ await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
4291
+ id: parseRecordIdString(this.recordId),
4292
+ sid: this.sessionId,
4293
+ state
4294
+ });
4295
+ } catch (e) {
4296
+ this.logger?.warn({
4297
+ error: e,
4298
+ Category: "sp00ky-client::CrdtField::pushCursorState"
4299
+ }, "Failed to push cursor state");
4300
+ }
4301
+ }
4302
+ /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
4303
+ importRemoteCursor(base64State) {
4304
+ if (Date.now() - this.lastCursorPushTime < 300) return;
4305
+ try {
4306
+ const data = decodeBase64(base64State);
4307
+ if (this._onCursorUpdate) this._onCursorUpdate(data);
4308
+ else this.pendingCursorUpdate = data;
4309
+ } catch (e) {
4310
+ this.logger?.warn({
4311
+ error: e,
4312
+ Category: "sp00ky-client::CrdtField::importRemoteCursor"
4313
+ }, "Failed to apply remote cursor data");
4314
+ }
4315
+ }
4316
+ scheduleRemotePush() {
4317
+ if (this.pushTimer) clearTimeout(this.pushTimer);
4318
+ this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
4319
+ }
4320
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
4321
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
4322
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
4323
+ * `<field>.state` next to per-session cursor blobs. */
4324
+ statePath() {
4325
+ return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
4326
+ }
4327
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
4328
+ * every local update and every remote import so reloads (online or
4329
+ * offline) see the freshest content immediately. Failures are
4330
+ * swallowed — a stale local write must never block user input. */
4331
+ async persistLocal() {
4332
+ if (!this.local || !this.recordId) return;
4333
+ try {
4334
+ await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
4335
+ id: parseRecordIdString(this.recordId),
4336
+ state: this.exportSnapshot()
4337
+ });
4338
+ } catch (e) {
4339
+ this.logger?.debug({
4340
+ error: e,
4341
+ Category: "sp00ky-client::CrdtField::persistLocal"
4342
+ }, "Local CRDT persist failed (best-effort)");
4343
+ }
4344
+ }
4345
+ async pushToRemote() {
4346
+ if (!this.remote || !this.recordId) return;
4347
+ this.lastPushTime = Date.now();
4348
+ try {
4349
+ await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
4350
+ id: parseRecordIdString(this.recordId),
4351
+ state: this.exportSnapshot()
4352
+ });
4353
+ this.pushRetryCount = 0;
4354
+ } catch (e) {
4355
+ this.logger?.warn({
4356
+ error: e,
4357
+ Category: "sp00ky-client::CrdtField::pushToRemote"
4358
+ }, "Failed to push CRDT state to remote");
4359
+ if (this.pushRetryCount < 2) {
4360
+ this.pushRetryCount++;
4361
+ this.scheduleRemotePush();
4362
+ }
4363
+ }
4364
+ }
4365
+ };
4366
+ function decodeBase64(b64) {
4367
+ if (typeof atob === "function") {
4368
+ const binary = atob(b64);
4369
+ const bytes = new Uint8Array(binary.length);
4370
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
4371
+ return bytes;
4372
+ }
4373
+ return new Uint8Array(Buffer.from(b64, "base64"));
4374
+ }
4375
+ function encodeBase64(bytes) {
4376
+ if (typeof btoa === "function") {
4377
+ let binary = "";
4378
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
4379
+ return btoa(binary);
4380
+ }
4381
+ return Buffer.from(bytes).toString("base64");
4382
+ }
4383
+
4384
+ //#endregion
4385
+ //#region src/modules/crdt/index.ts
4386
+ /**
4387
+ * CrdtManager manages active CrdtField instances and their sync channels.
4388
+ *
4389
+ * Collaborative state lives in two dedicated tables (defined in
4390
+ * `apps/cli/src/meta_tables_remote.surql`):
4391
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
4392
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
4393
+ * (record, session, field)
4394
+ *
4395
+ * Splitting them off the parent row is what makes offline edits mergeable:
4396
+ * each (record, field) gets its own row, so concurrent offline writes don't
4397
+ * collide on the parent's last-write-wins semantics.
4398
+ *
4399
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
4400
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
4401
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
4402
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
4403
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
4404
+ * via subquery. Permission inheritance happens server-side via
4405
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
4406
+ */
4407
+ var CrdtManager = class {
4408
+ fields = /* @__PURE__ */ new Map();
4409
+ liveByTable = /* @__PURE__ */ new Map();
4410
+ pendingLive = /* @__PURE__ */ new Map();
4411
+ logger;
4412
+ sessionId = "";
4413
+ constructor(schema, local, remote, logger, debounceMs = 500) {
4414
+ this.schema = schema;
4415
+ this.local = local;
4416
+ this.remote = remote;
4417
+ this.debounceMs = debounceMs;
4418
+ this.logger = logger.child({ service: "CrdtManager" });
4419
+ }
4420
+ /** Set the session id that scopes this client's cursor entries. Must be
4421
+ * called before `open()` for cursors to be pushed under a stable key.
4422
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
4423
+ * for the data-module salt). */
4424
+ setSessionId(sessionId) {
4425
+ this.sessionId = sessionId;
4426
+ }
4427
+ /**
4428
+ * Open a CRDT field for collaborative editing.
4429
+ *
4430
+ * @param table - Table name
4431
+ * @param recordId - Full record ID (e.g., "thread:abc")
4432
+ * @param field - Field name (e.g., "title", "content")
4433
+ * @param fallbackText - Current plain text from the record, used to seed the
4434
+ * LoroDoc if no CRDT state exists yet (migration path)
4435
+ */
4436
+ async open(table, recordId, field, fallbackText) {
4437
+ this.assertCrdtField(table, field);
4438
+ const cursorsEnabled = this.fieldHasCursor(table, field);
4439
+ const key = this.makeKey(table, recordId, field);
4440
+ let crdtField = this.fields.get(key);
4441
+ if (crdtField) return crdtField;
4442
+ let initialCrdtState;
4443
+ try {
4444
+ const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
4445
+ const snapshot = this.extractSnapshot(result, cursorsEnabled);
4446
+ if (snapshot) initialCrdtState = snapshot;
4447
+ } catch (e) {
4448
+ this.logger.info({
4449
+ error: String(e),
4450
+ recordId,
4451
+ field,
4452
+ Category: "sp00ky-client::CrdtManager::open"
4453
+ }, "No existing CRDT state found in local cache (continuing with empty doc)");
4454
+ }
4455
+ crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
4456
+ crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
4457
+ this.fields.set(key, crdtField);
4458
+ this.logger.info({
4459
+ key,
4460
+ hasInitialState: !!initialCrdtState,
4461
+ hasFallback: !!fallbackText,
4462
+ Category: "sp00ky-client::CrdtManager::open"
4463
+ }, "CrdtField opened");
4464
+ this.ensureTableSubscription(table);
4465
+ if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
4466
+ return crdtField;
4467
+ }
4468
+ close(table, recordId, field) {
4469
+ const key = this.makeKey(table, recordId, field);
4470
+ const crdtField = this.fields.get(key);
4471
+ if (crdtField) {
4472
+ crdtField.stopSync();
4473
+ this.fields.delete(key);
4474
+ }
4475
+ const tablePrefix = `${table}:`;
4476
+ if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
4477
+ this.logger.debug({
4478
+ key,
4479
+ Category: "sp00ky-client::CrdtManager::close"
4480
+ }, "CrdtField closed");
4481
+ }
4482
+ closeAll() {
4483
+ for (const [_, field] of this.fields) field.stopSync();
4484
+ this.fields.clear();
4485
+ for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
4486
+ }
4487
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
4488
+ * every open CrdtField on `table`. */
4489
+ async ensureTableSubscription(table) {
4490
+ if (this.liveByTable.has(table)) return;
4491
+ const pending = this.pendingLive.get(table);
4492
+ if (pending) return pending;
4493
+ const start = (async () => {
4494
+ try {
4495
+ const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
4496
+ (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
4497
+ if (message.action === "KILLED") return;
4498
+ if (message.action !== "CREATE" && message.action !== "UPDATE") return;
4499
+ this.dispatchRow(table, message.value);
4500
+ });
4501
+ this.liveByTable.set(table, uuid);
4502
+ this.logger.info({
4503
+ table,
4504
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
4505
+ }, "LIVE SELECT started");
4506
+ } catch (e) {
4507
+ this.logger.warn({
4508
+ error: e,
4509
+ table,
4510
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
4511
+ }, "Failed to start LIVE SELECT");
4512
+ }
4513
+ })();
4514
+ this.pendingLive.set(table, start);
4515
+ try {
4516
+ await start;
4517
+ } finally {
4518
+ this.pendingLive.delete(table);
4519
+ }
4520
+ }
4521
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
4522
+ * list_ref-driven sync engine, when the cross-user LIVE on the
4523
+ * parent table is filtered out by the SurrealDB cross-session
4524
+ * permission gap). Same semantics as the internal `dispatchRow`. */
4525
+ applyRow(table, row) {
4526
+ this.dispatchRow(table, row);
4527
+ }
4528
+ /** Dispatch a parent-row LIVE event to every open CrdtField on that
4529
+ * record. Each open field reads its slice of the row directly — the
4530
+ * CRDT snapshot is a column on the parent now, so there is no
4531
+ * follow-up subquery. */
4532
+ dispatchRow(table, row) {
4533
+ const id = row.id != null ? String(row.id) : "";
4534
+ if (!id) return;
4535
+ const rowKeyPrefix = `${table}:${id}:`;
4536
+ for (const [key, crdtField] of this.fields) {
4537
+ if (!key.startsWith(rowKeyPrefix)) continue;
4538
+ const fieldName = key.slice(rowKeyPrefix.length);
4539
+ const cursorsEnabled = this.fieldHasCursor(table, fieldName);
4540
+ const slice = row[fieldName];
4541
+ const snapshot = this.extractSnapshot(slice, cursorsEnabled);
4542
+ if (snapshot) crdtField.importRemote(snapshot);
4543
+ if (cursorsEnabled && slice && typeof slice === "object") {
4544
+ const cursors = slice.cursors;
4545
+ if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
4546
+ if (sid === this.sessionId) continue;
4547
+ if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
4548
+ }
4549
+ }
4550
+ }
4551
+ }
4552
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
4553
+ * locally yet (fresh device, memory-backed local DB after reload, …).
4554
+ * Used by `open()` when the local read came up empty. Subsequent
4555
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
4556
+ async fetchAndDispatchRow(table, id) {
4557
+ try {
4558
+ const recordId = parseRecordIdString(id);
4559
+ const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
4560
+ if (!row || typeof row !== "object") return;
4561
+ this.dispatchRow(table, row);
4562
+ } catch (e) {
4563
+ this.logger.warn({
4564
+ error: e,
4565
+ table,
4566
+ id,
4567
+ Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
4568
+ }, "Failed to fetch parent row for CRDT hydration");
4569
+ }
4570
+ }
4571
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
4572
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
4573
+ fieldHasCursor(table, field) {
4574
+ return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
4575
+ }
4576
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
4577
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
4578
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
4579
+ extractSnapshot(value, cursorsEnabled) {
4580
+ const asBytes = (v) => {
4581
+ if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
4582
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
4583
+ if (ArrayBuffer.isView(v)) {
4584
+ const view = v;
4585
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
4586
+ }
4587
+ if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
4588
+ };
4589
+ if (cursorsEnabled) {
4590
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
4591
+ return;
4592
+ }
4593
+ return asBytes(value);
4594
+ }
4595
+ killTableSubscription(table) {
4596
+ const uuid = this.liveByTable.get(table);
4597
+ if (uuid) {
4598
+ this.remote.query("KILL $uuid", { uuid }).catch((err) => {
4599
+ this.logger.debug({
4600
+ err,
4601
+ table,
4602
+ Category: "sp00ky-client::CrdtManager::killTableSubscription"
4603
+ }, "KILL of table LIVE failed (already closed?)");
4604
+ });
4605
+ this.liveByTable.delete(table);
4606
+ }
4607
+ }
4608
+ makeKey(table, recordId, field) {
4609
+ return `${table}:${recordId}:${field}`;
4610
+ }
4611
+ /**
4612
+ * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
4613
+ * typos, removed annotations, and stale schema codegen at the call site instead
4614
+ * of silently producing a non-CRDT writer.
4615
+ */
4616
+ assertCrdtField(table, field) {
4617
+ 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_]*`);
4618
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
4619
+ if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
4620
+ const column = tableSchema.columns[field];
4621
+ if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
4622
+ 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.`);
4623
+ }
4624
+ };
4625
+
2901
4626
  //#endregion
2902
4627
  //#region src/services/persistence/localstorage.ts
2903
4628
  var LocalStoragePersistenceClient = class {
@@ -2930,7 +4655,7 @@ var SurrealDBPersistenceClient = class {
2930
4655
  }
2931
4656
  async set(key, val) {
2932
4657
  try {
2933
- const id = parseRecordIdString(`_spooky_kv:${key}`);
4658
+ const id = parseRecordIdString(`_00_kv:${key}`);
2934
4659
  await this.db.query(surql.seal(surql.upsert("id", "data")), {
2935
4660
  id,
2936
4661
  data: { val }
@@ -2938,40 +4663,75 @@ var SurrealDBPersistenceClient = class {
2938
4663
  } catch (error) {
2939
4664
  this.logger.error({
2940
4665
  error,
2941
- Category: "spooky-client::SurrealDBPersistenceClient::set"
4666
+ Category: "sp00ky-client::SurrealDBPersistenceClient::set"
2942
4667
  }, "Failed to set KV");
2943
4668
  throw error;
2944
4669
  }
2945
4670
  }
2946
4671
  async get(key) {
2947
4672
  try {
2948
- const id = parseRecordIdString(`_spooky_kv:${key}`);
4673
+ const id = parseRecordIdString(`_00_kv:${key}`);
2949
4674
  const [result] = await this.db.query(surql.seal(surql.selectById("id", ["val"])), { id });
2950
4675
  if (!result?.val) return null;
2951
4676
  return result.val;
2952
4677
  } catch (error) {
2953
4678
  this.logger.warn({
2954
4679
  error,
2955
- Category: "spooky-client::SurrealDBPersistenceClient::get"
4680
+ Category: "sp00ky-client::SurrealDBPersistenceClient::get"
2956
4681
  }, "Failed to get KV");
2957
4682
  return null;
2958
4683
  }
2959
4684
  }
2960
4685
  async remove(key) {
2961
4686
  try {
2962
- const id = parseRecordIdString(`_spooky_kv:${key}`);
4687
+ const id = parseRecordIdString(`_00_kv:${key}`);
2963
4688
  await this.db.query(surql.seal(surql.delete("id")), { id });
2964
4689
  } catch (err) {
2965
4690
  this.logger.info({
2966
4691
  err,
2967
- Category: "spooky-client::SurrealDBPersistenceClient::remove"
4692
+ Category: "sp00ky-client::SurrealDBPersistenceClient::remove"
2968
4693
  }, "Failed to delete KV");
2969
4694
  }
2970
4695
  }
2971
4696
  };
2972
4697
 
2973
4698
  //#endregion
2974
- //#region src/spooky.ts
4699
+ //#region src/services/persistence/resilient.ts
4700
+ var ResilientPersistenceClient = class {
4701
+ logger;
4702
+ constructor(inner, logger) {
4703
+ this.inner = inner;
4704
+ this.logger = logger.child({ service: "ResilientPersistenceClient" });
4705
+ }
4706
+ set(key, value) {
4707
+ return this.inner.set(key, value);
4708
+ }
4709
+ async get(key) {
4710
+ try {
4711
+ return await this.inner.get(key);
4712
+ } catch (e) {
4713
+ this.logger.warn({
4714
+ key,
4715
+ error: e,
4716
+ Category: "sp00ky-client::ResilientPersistenceClient::get"
4717
+ }, "Persistence read failed, dropping key");
4718
+ await this.inner.remove(key).catch((removeErr) => {
4719
+ this.logger.debug({
4720
+ key,
4721
+ error: removeErr,
4722
+ Category: "sp00ky-client::ResilientPersistenceClient::get"
4723
+ }, "Failed to drop corrupt persistence key");
4724
+ });
4725
+ return null;
4726
+ }
4727
+ }
4728
+ remove(key) {
4729
+ return this.inner.remove(key);
4730
+ }
4731
+ };
4732
+
4733
+ //#endregion
4734
+ //#region src/sp00ky.ts
2975
4735
  var BucketHandle = class {
2976
4736
  constructor(bucketName, remote) {
2977
4737
  this.bucketName = bucketName;
@@ -3007,7 +4767,7 @@ var BucketHandle = class {
3007
4767
  return result;
3008
4768
  }
3009
4769
  };
3010
- var SpookyClient = class {
4770
+ var Sp00kyClient = class {
3011
4771
  local;
3012
4772
  remote;
3013
4773
  persistenceClient;
@@ -3016,6 +4776,7 @@ var SpookyClient = class {
3016
4776
  dataModule;
3017
4777
  sync;
3018
4778
  devTools;
4779
+ crdtManager;
3019
4780
  logger;
3020
4781
  auth;
3021
4782
  streamProcessor;
@@ -3028,33 +4789,43 @@ var SpookyClient = class {
3028
4789
  get pendingMutationCount() {
3029
4790
  return this.sync.pendingMutationCount;
3030
4791
  }
4792
+ /** Number of times the initial list_ref LIVE subscription retried on
4793
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
4794
+ * pre-emptive user-table creation got there first; >0 when LIVE
4795
+ * registration hit a "table not found" race. Exposed so the e2e
4796
+ * suite can guard the pre-emptive path against regression. */
4797
+ get liveRetryCount() {
4798
+ return this.sync.liveRetryCount;
4799
+ }
3031
4800
  subscribeToPendingMutations(cb) {
3032
4801
  return this.sync.subscribeToPendingMutations(cb);
3033
4802
  }
3034
4803
  constructor(config) {
3035
4804
  this.config = config;
3036
- const logger = createLogger(config.logLevel ?? "info", config.otelEndpoint);
3037
- this.logger = logger.child({ service: "SpookyClient" });
4805
+ const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
4806
+ this.logger = logger.child({ service: "Sp00kyClient" });
3038
4807
  this.logger.info({
3039
4808
  config: {
3040
4809
  ...config,
3041
4810
  schema: "[SchemaStructure]"
3042
4811
  },
3043
- Category: "spooky-client::SpookyClient::constructor"
3044
- }, "SpookyClient initialized");
4812
+ Category: "sp00ky-client::Sp00kyClient::constructor"
4813
+ }, "Sp00kyClient initialized");
3045
4814
  this.local = new LocalDatabaseService(this.config.database, logger);
3046
4815
  this.remote = new RemoteDatabaseService(this.config.database, logger);
3047
4816
  if (config.persistenceClient === "surrealdb") this.persistenceClient = new SurrealDBPersistenceClient(this.local, logger);
3048
4817
  else if (config.persistenceClient === "localstorage" || !config.persistenceClient) this.persistenceClient = new LocalStoragePersistenceClient(logger);
3049
4818
  else this.persistenceClient = config.persistenceClient;
4819
+ this.persistenceClient = new ResilientPersistenceClient(this.persistenceClient, logger);
3050
4820
  this.streamProcessor = new StreamProcessorService(new EventSystem(["stream_update"]), this.local, this.persistenceClient, logger);
3051
4821
  this.migrator = new LocalMigrator(this.local, logger);
3052
4822
  this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
3053
4823
  this.dataModule.onStreamUpdate(update);
3054
4824
  }, logger);
4825
+ this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
3055
4826
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
3056
4827
  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);
4828
+ this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
3058
4829
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
3059
4830
  this.streamProcessor.addReceiver(this.devTools);
3060
4831
  this.setupCallbacks();
@@ -3063,6 +4834,27 @@ var SpookyClient = class {
3063
4834
  * Setup direct callbacks instead of event subscriptions
3064
4835
  */
3065
4836
  setupCallbacks() {
4837
+ this.dataModule.onQueryStatusChange = (queryHash, status) => {
4838
+ this.devTools.logEvent("QUERY_STATUS_CHANGED", {
4839
+ queryHash,
4840
+ status
4841
+ });
4842
+ };
4843
+ this.dataModule.onHeartbeat = (queryHash) => {
4844
+ this.sync.heartbeatQuery(queryHash).catch((err) => {
4845
+ this.logger.warn({
4846
+ err,
4847
+ queryHash,
4848
+ Category: "sp00ky-client::Sp00kyClient::onHeartbeat"
4849
+ }, "TTL heartbeat failed");
4850
+ });
4851
+ };
4852
+ this.dataModule.onDeregister = (queryHash) => {
4853
+ this.sync.enqueueDownEvent({
4854
+ type: "cleanup",
4855
+ payload: { hash: queryHash }
4856
+ });
4857
+ };
3066
4858
  this.dataModule.onMutation((mutations) => {
3067
4859
  this.devTools.onMutation(mutations);
3068
4860
  if (mutations.length > 0) this.sync.enqueueMutation(mutations);
@@ -3070,6 +4862,22 @@ var SpookyClient = class {
3070
4862
  this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
3071
4863
  this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
3072
4864
  });
4865
+ this.sync.engineEvents.subscribe("SYNC_REMOTE_DATA_INGESTED", (event) => {
4866
+ try {
4867
+ const records = event.payload?.records ?? [];
4868
+ for (const row of records) {
4869
+ const id = row?.id;
4870
+ const table = id && typeof id === "object" && id.table !== void 0 ? String(id.table) : void 0;
4871
+ if (!table) continue;
4872
+ this.crdtManager.applyRow(table, row);
4873
+ }
4874
+ } catch (err) {
4875
+ this.logger.debug({
4876
+ err,
4877
+ Category: "sp00ky-client::engineEvents::ingested"
4878
+ }, "applyRow forwarding from sync ingest failed");
4879
+ }
4880
+ });
3073
4881
  this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
3074
4882
  this.devTools.logEvent("LOCAL_QUERY", event.payload);
3075
4883
  });
@@ -3078,44 +4886,74 @@ var SpookyClient = class {
3078
4886
  });
3079
4887
  }
3080
4888
  async init() {
3081
- this.logger.info({ Category: "spooky-client::SpookyClient::init" }, "SpookyClient initialization started");
4889
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
3082
4890
  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
4891
  await this.local.connect();
3090
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Local database connected");
4892
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
3091
4893
  await this.migrator.provision(this.config.schemaSurql);
3092
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Schema provisioned");
4894
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Schema provisioned");
3093
4895
  await this.remote.connect();
3094
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "Remote database connected");
4896
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Remote database connected");
3095
4897
  await this.streamProcessor.init();
3096
- this.logger.debug({ Category: "spooky-client::SpookyClient::init" }, "StreamProcessor initialized");
4898
+ this.streamProcessor.setPermissions(extractSelectPermissions(this.config.schemaSurql));
4899
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
3097
4900
  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");
4901
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
4902
+ const sessionId = await this.fetchSessionId();
4903
+ await this.dataModule.init(sessionId);
4904
+ this.crdtManager.setSessionId(sessionId);
4905
+ this.logger.debug({
4906
+ sessionId,
4907
+ Category: "sp00ky-client::Sp00kyClient::init"
4908
+ }, "DataModule initialized");
4909
+ this.auth.subscribe(async (userId) => {
4910
+ this.dataModule.setCurrentUserId(userId);
4911
+ const next = await this.fetchSessionId();
4912
+ this.dataModule.setSessionId(next);
4913
+ this.crdtManager.setSessionId(next);
4914
+ try {
4915
+ await this.sync.setCurrentUserId(userId);
4916
+ } catch (e) {
4917
+ this.logger.error({
4918
+ error: e,
4919
+ Category: "sp00ky-client::Sp00kyClient::authChange"
4920
+ }, "sync.setCurrentUserId failed");
4921
+ }
4922
+ });
4923
+ await this.sync.init();
4924
+ this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
4925
+ this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
3104
4926
  } catch (e) {
3105
4927
  this.logger.error({
3106
4928
  error: e,
3107
- Category: "spooky-client::SpookyClient::init"
3108
- }, "SpookyClient initialization failed");
4929
+ Category: "sp00ky-client::Sp00kyClient::init"
4930
+ }, "Sp00kyClient initialization failed");
3109
4931
  throw e;
3110
4932
  }
3111
4933
  }
3112
4934
  async close() {
4935
+ this.crdtManager.closeAll();
3113
4936
  await this.local.close();
3114
4937
  await this.remote.close();
3115
4938
  }
3116
4939
  authenticate(token) {
3117
4940
  return this.remote.getClient().authenticate(token);
3118
4941
  }
4942
+ /**
4943
+ * Open a CRDT field for collaborative editing.
4944
+ * Returns a CrdtField with a LoroDoc that can be bound to any editor.
4945
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
4946
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
4947
+ */
4948
+ async openCrdtField(table, recordId, field, fallbackText) {
4949
+ return this.crdtManager.open(table, recordId, field, fallbackText);
4950
+ }
4951
+ /**
4952
+ * Close a CRDT field when editing is done.
4953
+ */
4954
+ closeCrdtField(table, recordId, field) {
4955
+ this.crdtManager.close(table, recordId, field);
4956
+ }
3119
4957
  deauthenticate() {
3120
4958
  return this.remote.getClient().invalidate();
3121
4959
  }
@@ -3125,7 +4963,18 @@ var SpookyClient = class {
3125
4963
  async initQuery(table, q, ttl) {
3126
4964
  const tableSchema = this.config.schema.tables.find((t) => t.name === table);
3127
4965
  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);
4966
+ const params = parseParams(tableSchema.columns, q.selectQuery.vars ?? {});
4967
+ const hash = await this.dataModule.query(table, q.selectQuery.query, params, ttl);
4968
+ if (this.config.instantHydrate !== false && this.dataModule.isCold(hash)) try {
4969
+ const [rows] = await this.remote.query(q.selectQuery.query, params);
4970
+ await this.dataModule.applyHydration(hash, rows ?? []);
4971
+ } catch (err) {
4972
+ this.logger.warn({
4973
+ err,
4974
+ hash,
4975
+ Category: "sp00ky-client::Sp00kyClient::instantHydrate"
4976
+ }, "Instant hydrate failed; proceeding with registration");
4977
+ }
3129
4978
  await this.sync.enqueueDownEvent({
3130
4979
  type: "register",
3131
4980
  payload: { hash }
@@ -3139,6 +4988,32 @@ var SpookyClient = class {
3139
4988
  async subscribe(queryHash, callback, options) {
3140
4989
  return this.dataModule.subscribe(queryHash, callback, options);
3141
4990
  }
4991
+ /**
4992
+ * Opt-in eager teardown for a query whose last subscriber has gone away
4993
+ * (e.g. a viewport-windowed list cancelling an off-screen window). No-op
4994
+ * while any subscriber remains. Tears down the remote `_00_query` view +
4995
+ * local WASM view instead of waiting for the TTL sweep. Default behavior
4996
+ * (no call here) keeps the view resident for cheap re-subscription.
4997
+ */
4998
+ deregisterQuery(queryHash) {
4999
+ this.dataModule.deregisterQuery(queryHash);
5000
+ }
5001
+ /**
5002
+ * Subscribe to a query's fetch-status changes (idle/fetching). With
5003
+ * `{ immediate: true }` the callback fires synchronously with the current
5004
+ * status. Powers the `useQuery` hook's `isFetching()` accessor.
5005
+ */
5006
+ subscribeQueryStatus(queryHash, callback, options) {
5007
+ return this.dataModule.subscribeStatus(queryHash, callback, options);
5008
+ }
5009
+ /**
5010
+ * Report the frontend processing time (ms) a client framework spent applying
5011
+ * an update for a query (e.g. `useQuery`'s `reconcile()`), so DevTools/MCP can
5012
+ * surface the "frontend" phase of the per-query timing breakdown.
5013
+ */
5014
+ reportFrontendTiming(queryHash, ms) {
5015
+ this.dataModule.recordFrontendTiming(queryHash, ms);
5016
+ }
3142
5017
  run(backend, path, payload, options) {
3143
5018
  return this.dataModule.run(backend, path, payload, options);
3144
5019
  }
@@ -3157,24 +5032,25 @@ var SpookyClient = class {
3157
5032
  async useRemote(fn) {
3158
5033
  return fn(this.remote.getClient());
3159
5034
  }
3160
- persistClientId(id) {
5035
+ /**
5036
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
5037
+ * query-id hashing so two sessions for the same user get distinct
5038
+ * `_00_query` rows. Returns empty string if the query fails (we still
5039
+ * boot, just without session scoping for IDs).
5040
+ */
5041
+ async fetchSessionId() {
3161
5042
  try {
3162
- this.persistenceClient.set("spooky_client_id", id);
5043
+ const [sid] = await this.remote.query("RETURN <string>session::id()");
5044
+ return typeof sid === "string" ? sid : "";
3163
5045
  } catch (e) {
3164
5046
  this.logger.warn({
3165
5047
  error: e,
3166
- Category: "spooky-client::SpookyClient::persistClientId"
3167
- }, "Failed to persist client ID");
5048
+ Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
5049
+ }, "Failed to fetch session::id() — proceeding with empty salt");
5050
+ return "";
3168
5051
  }
3169
5052
  }
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
5053
  };
3178
5054
 
3179
5055
  //#endregion
3180
- export { AuthEventTypes, AuthService, BucketHandle, SpookyClient, createAuthEventSystem, fileToUint8Array };
5056
+ export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };