@spooky-sync/core 0.0.1-canary.66 → 0.0.1-canary.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,11 @@ import pino from "pino";
5
5
  import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
6
6
  import { LoroDoc } from "loro-crdt";
7
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
8
13
  //#region src/utils/surql.ts
9
14
  const surql = {
10
15
  seal(query, options) {
@@ -40,6 +45,9 @@ const surql = {
40
45
  upsert(idVar, dataVar) {
41
46
  return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
42
47
  },
48
+ upsertMerge(idVar, dataVar) {
49
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
50
+ },
43
51
  updateMerge(idVar, dataVar) {
44
52
  return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
45
53
  },
@@ -216,6 +224,8 @@ var DataModule = class {
216
224
  mutationCallbacks = /* @__PURE__ */ new Set();
217
225
  debounceTimers = /* @__PURE__ */ new Map();
218
226
  logger;
227
+ sessionId = "";
228
+ currentUserId = null;
219
229
  constructor(cache, local, schema, logger, streamDebounceTime = 100) {
220
230
  this.cache = cache;
221
231
  this.local = local;
@@ -223,8 +233,35 @@ var DataModule = class {
223
233
  this.streamDebounceTime = streamDebounceTime;
224
234
  this.logger = logger.child({ service: "DataModule" });
225
235
  }
226
- async init() {
227
- this.logger.info({ Category: "sp00ky-client::DataModule::init" }, "DataModule initialized");
236
+ async init(sessionId) {
237
+ this.sessionId = sessionId;
238
+ this.logger.info({
239
+ sessionId,
240
+ Category: "sp00ky-client::DataModule::init"
241
+ }, "DataModule initialized");
242
+ }
243
+ /**
244
+ * Update the session salt used in query-id hashing. Call this when the
245
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
246
+ * registered queries will get fresh, session-scoped IDs.
247
+ */
248
+ setSessionId(sessionId) {
249
+ this.sessionId = sessionId;
250
+ }
251
+ /**
252
+ * Update the authenticated user record id. Pass `null` on sign-out.
253
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
254
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
255
+ * SSP writes to.
256
+ */
257
+ setCurrentUserId(userId) {
258
+ this.currentUserId = userId;
259
+ }
260
+ /** Read-only view of the authenticated user id used for per-user
261
+ * `_00_list_ref` routing. Other modules consult this so they pick the
262
+ * same table name DataModule does. */
263
+ getCurrentUserId() {
264
+ return this.currentUserId;
228
265
  }
229
266
  /**
230
267
  * Register a query and return its hash for subscriptions
@@ -309,7 +346,7 @@ var DataModule = class {
309
346
  } else await this.processStreamUpdate(update);
310
347
  }
311
348
  async processStreamUpdate(update) {
312
- const { queryHash, localArray } = update;
349
+ const { queryHash, localArray, materializationTimeMs } = update;
313
350
  const queryState = this.activeQueries.get(queryHash);
314
351
  if (!queryState) {
315
352
  this.logger.warn({
@@ -318,25 +355,46 @@ var DataModule = class {
318
355
  }, "Received update for unknown query. Skipping...");
319
356
  return;
320
357
  }
358
+ if (typeof materializationTimeMs === "number") {
359
+ queryState.materializationSamples.push(materializationTimeMs);
360
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) queryState.materializationSamples.shift();
361
+ queryState.lastIngestLatencyMs = materializationTimeMs;
362
+ }
363
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
321
364
  try {
322
365
  const [records] = await this.local.query(queryState.config.surql, queryState.config.params);
323
366
  const newRecords = records || [];
324
367
  queryState.config.localArray = localArray;
325
- await this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
326
- id: queryState.config.id,
327
- localArray
328
- });
329
368
  const prevJson = JSON.stringify(queryState.records);
330
369
  const newJson = JSON.stringify(newRecords);
331
370
  queryState.records = newRecords;
332
- if (prevJson === newJson) {
371
+ const recordsChanged = prevJson !== newJson;
372
+ if (recordsChanged) queryState.updateCount++;
373
+ await this.local.query(surql.seal(surql.updateSet("id", [
374
+ "localArray",
375
+ "rowCount",
376
+ "updateCount",
377
+ "lastIngestLatency",
378
+ "materializationP55",
379
+ "materializationP90",
380
+ "materializationP99"
381
+ ])), {
382
+ id: queryState.config.id,
383
+ localArray,
384
+ rowCount: localArray.length,
385
+ updateCount: queryState.updateCount,
386
+ lastIngestLatency: queryState.lastIngestLatencyMs,
387
+ materializationP55: percentiles.p55,
388
+ materializationP90: percentiles.p90,
389
+ materializationP99: percentiles.p99
390
+ });
391
+ if (!recordsChanged) {
333
392
  this.logger.debug({
334
393
  queryHash,
335
394
  Category: "sp00ky-client::DataModule::onStreamUpdate"
336
395
  }, "Query records unchanged, skipping notification");
337
396
  return;
338
397
  }
339
- queryState.updateCount++;
340
398
  const subscribers = this.subscriptions.get(queryHash);
341
399
  if (subscribers) for (const callback of subscribers) callback(queryState.records);
342
400
  this.logger.debug({
@@ -345,14 +403,48 @@ var DataModule = class {
345
403
  Category: "sp00ky-client::DataModule::onStreamUpdate"
346
404
  }, "Query updated from stream");
347
405
  } catch (err) {
406
+ queryState.errorCount++;
348
407
  this.logger.error({
349
408
  err,
350
409
  queryHash,
351
410
  Category: "sp00ky-client::DataModule::onStreamUpdate"
352
411
  }, "Failed to fetch records for stream update");
412
+ try {
413
+ await this.local.query(surql.seal(surql.updateSet("id", ["errorCount"])), {
414
+ id: queryState.config.id,
415
+ errorCount: queryState.errorCount
416
+ });
417
+ } catch (persistErr) {
418
+ this.logger.warn({
419
+ err: persistErr,
420
+ queryHash,
421
+ Category: "sp00ky-client::DataModule::onStreamUpdate"
422
+ }, "Failed to persist incremented errorCount");
423
+ }
353
424
  }
354
425
  }
355
426
  /**
427
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
428
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
429
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
430
+ */
431
+ computeMaterializationPercentiles(samples) {
432
+ if (samples.length === 0) return {
433
+ p55: null,
434
+ p90: null,
435
+ p99: null
436
+ };
437
+ const sorted = [...samples].sort((a, b) => a - b);
438
+ const pick = (q) => {
439
+ return sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
440
+ };
441
+ return {
442
+ p55: pick(.55),
443
+ p90: pick(.9),
444
+ p99: pick(.99)
445
+ };
446
+ }
447
+ /**
356
448
  * Get query state (for sync and devtools)
357
449
  */
358
450
  getQueryByHash(hash) {
@@ -370,6 +462,9 @@ var DataModule = class {
370
462
  getActiveQueries() {
371
463
  return Array.from(this.activeQueries.values());
372
464
  }
465
+ getActiveQueryHashes() {
466
+ return Array.from(this.activeQueries.keys());
467
+ }
373
468
  async updateQueryLocalArray(id, localArray) {
374
469
  const queryState = this.activeQueries.get(id);
375
470
  if (!queryState) {
@@ -644,6 +739,7 @@ var DataModule = class {
644
739
  ttl,
645
740
  tableName
646
741
  });
742
+ const t0 = performance.now();
647
743
  const { localArray } = this.cache.registerQuery({
648
744
  queryHash: hash,
649
745
  surql: surqlString,
@@ -651,9 +747,16 @@ var DataModule = class {
651
747
  ttl: new Duration(ttl),
652
748
  lastActiveAt: /* @__PURE__ */ new Date()
653
749
  });
654
- await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", ["localArray"])), {
750
+ const registrationTime = performance.now() - t0;
751
+ await withRetry(this.logger, () => this.local.query(surql.seal(surql.updateSet("id", [
752
+ "localArray",
753
+ "registrationTime",
754
+ "rowCount"
755
+ ])), {
655
756
  id: recordId,
656
- localArray
757
+ localArray,
758
+ registrationTime,
759
+ rowCount: localArray.length
657
760
  }));
658
761
  this.activeQueries.set(hash, queryState);
659
762
  this.startTTLHeartbeat(queryState);
@@ -678,8 +781,12 @@ var DataModule = class {
678
781
  localArray: [],
679
782
  remoteArray: [],
680
783
  lastActiveAt: /* @__PURE__ */ new Date(),
784
+ createdAt: /* @__PURE__ */ new Date(),
681
785
  ttl,
682
- tableName
786
+ tableName,
787
+ updateCount: 0,
788
+ rowCount: 0,
789
+ errorCount: 0
683
790
  }
684
791
  }));
685
792
  configRecord = createdRecord;
@@ -699,16 +806,24 @@ var DataModule = class {
699
806
  Category: "sp00ky-client::DataModule::createNewQuery"
700
807
  }, "Failed to load initial cached records");
701
808
  }
809
+ const persistedUpdateCount = typeof configRecord?.updateCount === "number" ? configRecord.updateCount : 0;
810
+ const persistedErrorCount = typeof configRecord?.errorCount === "number" ? configRecord.errorCount : 0;
702
811
  return {
703
812
  config,
704
813
  records,
705
814
  ttlTimer: null,
706
815
  ttlDurationMs: parseDuration(ttl),
707
- updateCount: 0
816
+ updateCount: persistedUpdateCount,
817
+ materializationSamples: [],
818
+ lastIngestLatencyMs: null,
819
+ errorCount: persistedErrorCount
708
820
  };
709
821
  }
710
822
  async calculateHash(data) {
711
- const content = JSON.stringify(data);
823
+ const content = JSON.stringify({
824
+ ...data,
825
+ sessionId: this.sessionId
826
+ });
712
827
  const msgBuffer = new TextEncoder().encode(content);
713
828
  const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
714
829
  return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -1584,6 +1699,65 @@ function createDiffFromDbOp(op, recordId, version, versions) {
1584
1699
  removed: [recordId]
1585
1700
  };
1586
1701
  }
1702
+ /**
1703
+ * Default cadence for the `_00_list_ref_user_<id>` poll fallback. The
1704
+ * poll is the safety net for SurrealDB v3's occasionally-dropped LIVE
1705
+ * deliveries; 500ms is aggressive enough to feel real-time on the
1706
+ * happy path while keeping the per-session query load bounded.
1707
+ */
1708
+ const DEFAULT_LIST_REF_POLL_INTERVAL_MS = 500;
1709
+ /**
1710
+ * Build the SurrealQL select that powers both the initial-fetch and
1711
+ * the periodic poll of `_00_list_ref[_user_<id>]`. The `parent IS NONE`
1712
+ * predicate excludes subquery entries (rows with `parent_rel` set)
1713
+ * because the client's `RecordVersionArray` only tracks primary rows;
1714
+ * including subquery rows would surface them as spurious "added"
1715
+ * diffs every tick.
1716
+ */
1717
+ function buildListRefSelect(table) {
1718
+ return `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
1719
+ }
1720
+ /**
1721
+ * Resolve the effective list-ref poll interval. Negative or zero
1722
+ * values fall back to the default — accepting them would either
1723
+ * disable polling silently or busy-loop the event loop.
1724
+ */
1725
+ function resolveListRefPollInterval(opt) {
1726
+ if (typeof opt !== "number" || !Number.isFinite(opt) || opt <= 0) return DEFAULT_LIST_REF_POLL_INTERVAL_MS;
1727
+ return opt;
1728
+ }
1729
+ /**
1730
+ * When the LIVE feed has delivered an event within this window, treat
1731
+ * the LIVE subscription as healthy and back the poll off to
1732
+ * {@link LIVE_HEALTHY_POLL_INTERVAL_MS}. As soon as LIVE quiets for
1733
+ * longer than this, the poll snaps back to the aggressive default.
1734
+ */
1735
+ const LIVE_HEALTHY_COOLDOWN_MS = 5e3;
1736
+ /**
1737
+ * Poll interval while LIVE is delivering events. The aggressive
1738
+ * default 500ms costs ~120 queries / minute / session — wasted work
1739
+ * when LIVE is already covering us. 5s keeps the safety net in place
1740
+ * at 1/10th the load.
1741
+ */
1742
+ const LIVE_HEALTHY_POLL_INTERVAL_MS = 5e3;
1743
+ /**
1744
+ * Pick the next poll delay based on whether LIVE has been healthy
1745
+ * recently. If a LIVE event fired within `cooldownMs`, use the slow
1746
+ * (`healthyIntervalMs`) cadence; otherwise the fast (`baseIntervalMs`)
1747
+ * cadence. Pure so it's unit-testable; `Sp00kySync.startListRefPoll`
1748
+ * calls it from a self-rescheduling timer.
1749
+ *
1750
+ * The healthy interval is clamped to at least `baseIntervalMs` so
1751
+ * configuring an aggressive base (e.g. 100ms) never gets implicitly
1752
+ * widened by this helper.
1753
+ */
1754
+ function nextPollDelayMs(args) {
1755
+ const { now, lastLiveEventAt, baseIntervalMs, cooldownMs = LIVE_HEALTHY_COOLDOWN_MS, healthyIntervalMs = LIVE_HEALTHY_POLL_INTERVAL_MS } = args;
1756
+ if (lastLiveEventAt === null) return baseIntervalMs;
1757
+ const sinceLive = now - lastLiveEventAt;
1758
+ if (sinceLive < 0 || sinceLive >= cooldownMs) return baseIntervalMs;
1759
+ return Math.max(healthyIntervalMs, baseIntervalMs);
1760
+ }
1587
1761
 
1588
1762
  //#endregion
1589
1763
  //#region src/modules/sync/engine.ts
@@ -1660,18 +1834,46 @@ var SyncEngine = class {
1660
1834
  }
1661
1835
  /**
1662
1836
  * Handle records that exist locally but not in remote array.
1837
+ *
1838
+ * "Removed" here is a derived signal: the SSP's `_00_list_ref` array no
1839
+ * longer references a record that exists locally. That can mean the row
1840
+ * was genuinely deleted upstream — but it can also be a benign race
1841
+ * (e.g. a record we just created hasn't propagated into the SSP's
1842
+ * incantation list yet). Before deleting locally we verify against
1843
+ * upstream SurrealDB: if the row still exists there, skip the delete.
1844
+ *
1845
+ * On verification failure we skip deletion too. Losing a stale local
1846
+ * row to a later sync round is recoverable; deleting a fresh row that
1847
+ * upstream still has is not.
1663
1848
  */
1664
1849
  async handleRemovedRecords(removed) {
1665
1850
  this.logger.debug({
1666
1851
  removed: removed.map((r) => r.toString()),
1667
1852
  Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1668
1853
  }, "Checking removed records");
1669
- let existingRemoteIds = /* @__PURE__ */ new Set();
1854
+ const byTable = /* @__PURE__ */ new Map();
1855
+ for (const r of removed) {
1856
+ const list = byTable.get(r.table.name) ?? [];
1857
+ list.push(r);
1858
+ byTable.set(r.table.name, list);
1859
+ }
1860
+ let existingRemoteIds;
1670
1861
  try {
1671
- const [existingRemote] = await this.remote.query("SELECT id FROM $ids", { ids: removed });
1672
- existingRemoteIds = new Set(existingRemote.map((r) => encodeRecordId(r.id)));
1673
- } catch {
1674
- this.logger.debug({ Category: "sp00ky-client::SyncEngine::handleRemovedRecords" }, "Remote existence check failed, proceeding with deletion");
1862
+ existingRemoteIds = /* @__PURE__ */ new Set();
1863
+ for (const [table, ids] of byTable) {
1864
+ const [existing] = await this.remote.query("SELECT id FROM type::table($table) WHERE id IN $ids", {
1865
+ table,
1866
+ ids
1867
+ });
1868
+ for (const row of existing) existingRemoteIds.add(encodeRecordId(row.id));
1869
+ }
1870
+ } catch (err) {
1871
+ this.logger.warn({
1872
+ err,
1873
+ removed: removed.map((r) => r.toString()),
1874
+ Category: "sp00ky-client::SyncEngine::handleRemovedRecords"
1875
+ }, "Remote existence check failed, skipping deletion to avoid clobbering fresh data");
1876
+ return;
1675
1877
  }
1676
1878
  for (const recordId of removed) {
1677
1879
  const recordIdStr = encodeRecordId(recordId);
@@ -1754,6 +1956,45 @@ var SyncScheduler = class {
1754
1956
  }
1755
1957
  };
1756
1958
 
1959
+ //#endregion
1960
+ //#region src/modules/ref-tables.ts
1961
+ /**
1962
+ * Default ref-storage mode for this client build. Mirrors the SSP's
1963
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
1964
+ * box.
1965
+ */
1966
+ const DEFAULT_REF_MODE = "dedicated";
1967
+ /**
1968
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
1969
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
1970
+ * the id is missing the `user:` prefix or contains characters that
1971
+ * aren't valid in a SurrealDB table identifier — the server-side
1972
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
1973
+ *
1974
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
1975
+ * objects (which only stringify cleanly via `.toString()`), since
1976
+ * `AuthService` passes the record-id object as-is to its subscribers.
1977
+ */
1978
+ function sanitizeUserId(userId) {
1979
+ if (userId === null || userId === void 0) return null;
1980
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
1981
+ if (!asString) return null;
1982
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
1983
+ if (raw.length === 0) return null;
1984
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
1985
+ return raw;
1986
+ }
1987
+ /**
1988
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
1989
+ * back to the global `_00_list_ref` when sanitization fails or in
1990
+ * single mode.
1991
+ */
1992
+ function listRefTableFor(mode, userId) {
1993
+ if (mode === "single") return "_00_list_ref";
1994
+ const uid = sanitizeUserId(userId);
1995
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
1996
+ }
1997
+
1757
1998
  //#endregion
1758
1999
  //#region src/modules/sync/sync.ts
1759
2000
  /**
@@ -1763,14 +2004,32 @@ var SyncScheduler = class {
1763
2004
  * @template S The schema structure type.
1764
2005
  */
1765
2006
  var Sp00kySync = class {
1766
- clientId = "";
1767
2007
  upQueue;
1768
2008
  downQueue;
1769
2009
  isInit = false;
1770
2010
  logger;
1771
2011
  syncEngine;
2012
+ /** Engine-level events (e.g. `SYNC_REMOTE_DATA_INGESTED`). Distinct
2013
+ * from `this.events`, which carries Sp00kySync-level events like
2014
+ * `SYNC_QUERY_UPDATED` and `SYNC_MUTATION_ROLLED_BACK`. */
2015
+ get engineEvents() {
2016
+ return this.syncEngine.events;
2017
+ }
1772
2018
  scheduler;
2019
+ wasDisconnected = false;
1773
2020
  events = createSyncEventSystem();
2021
+ currentUserId = null;
2022
+ refMode = DEFAULT_REF_MODE;
2023
+ currentLiveQueryUuid = null;
2024
+ liveQueryUnsubscribe = null;
2025
+ listRefPollTimer = null;
2026
+ listRefPollRunning = false;
2027
+ refSyncIntervalMs;
2028
+ lastLiveEventAt = null;
2029
+ _liveRetryCount = 0;
2030
+ get liveRetryCount() {
2031
+ return this._liveRetryCount;
2032
+ }
1774
2033
  get isSyncing() {
1775
2034
  return this.scheduler.isSyncing;
1776
2035
  }
@@ -1785,7 +2044,7 @@ var Sp00kySync = class {
1785
2044
  this.upQueue.events.unsubscribe(id2);
1786
2045
  };
1787
2046
  }
1788
- constructor(local, remote, cache, dataModule, schema, logger) {
2047
+ constructor(local, remote, cache, dataModule, schema, logger, options) {
1789
2048
  this.local = local;
1790
2049
  this.remote = remote;
1791
2050
  this.cache = cache;
@@ -1796,29 +2055,199 @@ var Sp00kySync = class {
1796
2055
  this.downQueue = new DownQueue(this.local, this.logger);
1797
2056
  this.syncEngine = new SyncEngine(this.remote, this.cache, this.schema, this.logger);
1798
2057
  this.scheduler = new SyncScheduler(this.upQueue, this.downQueue, this.processUpEvent.bind(this), this.processDownEvent.bind(this), this.logger, this.handleRollback.bind(this));
2058
+ this.refSyncIntervalMs = resolveListRefPollInterval(options?.refSyncIntervalMs);
1799
2059
  }
1800
2060
  /**
1801
2061
  * Initializes the synchronization system.
1802
2062
  * Starts the scheduler and initiates the initial sync cycles.
1803
- * @param clientId The unique identifier for this client instance.
1804
2063
  * @throws Error if already initialized.
1805
2064
  */
1806
- async init(clientId) {
2065
+ async init() {
1807
2066
  if (this.isInit) throw new Error("Sp00kySync is already initialized");
1808
- this.clientId = clientId;
1809
2067
  this.isInit = true;
1810
2068
  await this.scheduler.init();
2069
+ this.subscribeToReconnect();
1811
2070
  this.scheduler.syncUp();
1812
2071
  this.scheduler.syncDown();
1813
- this.startRefLiveQueries();
2072
+ }
2073
+ /**
2074
+ * Push the authenticated user's record id from the parent client's
2075
+ * auth subscription. Tears down the existing `_00_list_ref` LIVE (if
2076
+ * any) and re-registers it under the new user's dedicated table so
2077
+ * SurrealDB binds the permission rule under the post-flip auth
2078
+ * context. Pass `null` on sign-out.
2079
+ *
2080
+ * The dedicated `_00_list_ref_user_<id>` table is created lazily by
2081
+ * the SSP when the first query registration arrives, which may be
2082
+ * concurrent with this call. We retry the LIVE registration with a
2083
+ * short backoff so a "table not found" race resolves without
2084
+ * surfacing as a permanent auth-loading hang.
2085
+ */
2086
+ async setCurrentUserId(userId) {
2087
+ if (this.currentUserId === userId) return;
2088
+ this.currentUserId = userId;
2089
+ if (!userId) {
2090
+ await this.killRefLiveQuery();
2091
+ this.stopListRefPoll();
2092
+ return;
2093
+ }
2094
+ this.startListRefPoll();
2095
+ const attemptDelays = [
2096
+ 0,
2097
+ 250,
2098
+ 500,
2099
+ 1e3,
2100
+ 2e3
2101
+ ];
2102
+ for (let i = 0; i < attemptDelays.length; i++) {
2103
+ if (attemptDelays[i] > 0) {
2104
+ this._liveRetryCount++;
2105
+ await new Promise((r) => setTimeout(r, attemptDelays[i]));
2106
+ }
2107
+ try {
2108
+ await this.restartRefLiveQuery();
2109
+ return;
2110
+ } catch (err) {
2111
+ this.logger.debug({
2112
+ err,
2113
+ attempt: i + 1,
2114
+ Category: "sp00ky-client::Sp00kySync::setCurrentUserId"
2115
+ }, "Ref LIVE start failed; relying on periodic poll fallback");
2116
+ }
2117
+ }
2118
+ }
2119
+ startListRefPoll() {
2120
+ if (this.listRefPollRunning) return;
2121
+ this.listRefPollRunning = true;
2122
+ this.logger.debug({
2123
+ intervalMs: this.refSyncIntervalMs,
2124
+ Category: "sp00ky-client::Sp00kySync::startListRefPoll"
2125
+ }, "list_ref poll loop started");
2126
+ const schedule = (delayMs) => {
2127
+ this.listRefPollTimer = setTimeout(async () => {
2128
+ if (!this.listRefPollRunning) return;
2129
+ try {
2130
+ await this.pollListRefForActiveQueries();
2131
+ } finally {
2132
+ if (!this.listRefPollRunning) return;
2133
+ schedule(nextPollDelayMs({
2134
+ now: Date.now(),
2135
+ lastLiveEventAt: this.lastLiveEventAt,
2136
+ baseIntervalMs: this.refSyncIntervalMs
2137
+ }));
2138
+ }
2139
+ }, delayMs);
2140
+ };
2141
+ schedule(this.refSyncIntervalMs);
2142
+ }
2143
+ stopListRefPoll() {
2144
+ this.listRefPollRunning = false;
2145
+ if (this.listRefPollTimer !== null) {
2146
+ clearTimeout(this.listRefPollTimer);
2147
+ this.listRefPollTimer = null;
2148
+ }
2149
+ }
2150
+ async pollListRefForActiveQueries() {
2151
+ const hashes = this.dataModule.getActiveQueryHashes();
2152
+ if (hashes.length === 0) return;
2153
+ for (const hash of hashes) try {
2154
+ await this.refetchListRefForQuery(hash);
2155
+ } catch (err) {
2156
+ this.logger.debug({
2157
+ err: err?.message ?? err,
2158
+ hash,
2159
+ Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2160
+ }, "Per-query list_ref poll failed");
2161
+ }
2162
+ }
2163
+ /**
2164
+ * Pull the upstream list_ref entries for `queryHash`, diff them
2165
+ * against the local `remoteArray` cache, sync any added/updated rows
2166
+ * through the SyncEngine, then persist the new remoteArray. This is
2167
+ * the same shape `createRemoteQuery` does for its initial fetch and
2168
+ * what `handleRemoteListRefChange` does per-LIVE-event — we reuse
2169
+ * it on a timer as a fallback for missed LIVE notifications.
2170
+ */
2171
+ async refetchListRefForQuery(queryHash) {
2172
+ const queryState = this.dataModule.getQueryByHash(queryHash);
2173
+ if (!queryState) return;
2174
+ const listRefTbl = this.listRefTable();
2175
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2176
+ if (!Array.isArray(items)) return;
2177
+ const fresh = items.map((item) => [encodeRecordId(item.out), item.version]);
2178
+ await this.dataModule.updateQueryRemoteArray(queryHash, fresh);
2179
+ try {
2180
+ await this.syncQuery(queryHash);
2181
+ } catch (err) {
2182
+ this.logger.info({
2183
+ err: err?.message ?? err,
2184
+ queryHash,
2185
+ Category: "sp00ky-client::Sp00kySync::refetchListRefForQuery"
2186
+ }, "syncQuery failed during poll");
2187
+ }
2188
+ }
2189
+ /**
2190
+ * Resolve the current `_00_list_ref` table name for the active auth
2191
+ * context. Public so the `createRemoteQuery` initial-fetch path can
2192
+ * read from the right per-user table.
2193
+ *
2194
+ * Reads the user id from `DataModule` rather than the local mirror,
2195
+ * because `DataModule.setCurrentUserId` runs synchronously from the
2196
+ * auth callback (before any `await`), whereas `sync.setCurrentUserId`
2197
+ * is async — the userQuery's initial fetch can fire between those
2198
+ * two points and we need the correct table name immediately.
2199
+ */
2200
+ listRefTable() {
2201
+ return listRefTableFor(this.refMode, this.dataModule.getCurrentUserId());
2202
+ }
2203
+ async killRefLiveQuery() {
2204
+ if (this.liveQueryUnsubscribe) {
2205
+ try {
2206
+ this.liveQueryUnsubscribe();
2207
+ } catch {}
2208
+ this.liveQueryUnsubscribe = null;
2209
+ }
2210
+ if (this.currentLiveQueryUuid !== null) {
2211
+ try {
2212
+ await this.remote.query("KILL $u", { u: this.currentLiveQueryUuid });
2213
+ } catch (err) {
2214
+ this.logger.debug({
2215
+ err,
2216
+ Category: "sp00ky-client::Sp00kySync::killRefLiveQuery"
2217
+ }, "Prior LIVE KILL failed; continuing");
2218
+ }
2219
+ this.currentLiveQueryUuid = null;
2220
+ }
2221
+ }
2222
+ async restartRefLiveQuery() {
2223
+ await this.killRefLiveQuery();
2224
+ await this.startRefLiveQueries();
2225
+ }
2226
+ subscribeToReconnect() {
2227
+ const client = this.remote.getClient();
2228
+ client.subscribe("disconnected", () => {
2229
+ this.wasDisconnected = true;
2230
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onDisconnect" }, "Remote disconnected");
2231
+ });
2232
+ client.subscribe("connected", () => {
2233
+ if (!this.wasDisconnected) return;
2234
+ this.wasDisconnected = false;
2235
+ this.logger.info({ Category: "sp00ky-client::Sp00kySync::onReconnect" }, "Remote reconnected, refetching active queries");
2236
+ for (const hash of this.dataModule.getActiveQueryHashes()) this.scheduler.enqueueDownEvent({
2237
+ type: "register",
2238
+ payload: { hash }
2239
+ });
2240
+ });
1814
2241
  }
1815
2242
  async startRefLiveQueries() {
2243
+ const tableName = this.listRefTable();
1816
2244
  this.logger.debug({
1817
- clientId: this.clientId,
2245
+ tableName,
1818
2246
  Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
1819
2247
  }, "Starting ref live queries");
1820
- const [queryUuid] = await this.remote.query("LIVE SELECT * FROM _00_list_ref");
1821
- (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
2248
+ const [queryUuid] = await this.remote.query(`LIVE SELECT * FROM ${tableName}`);
2249
+ this.currentLiveQueryUuid = queryUuid;
2250
+ this.liveQueryUnsubscribe = (await this.remote.getClient().liveOf(queryUuid)).subscribe((message) => {
1822
2251
  this.logger.debug({
1823
2252
  message,
1824
2253
  Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
@@ -1833,6 +2262,7 @@ var Sp00kySync = class {
1833
2262
  });
1834
2263
  }
1835
2264
  async handleRemoteListRefChange(action, queryId, recordId, version) {
2265
+ this.lastLiveEventAt = Date.now();
1836
2266
  if (action === "DELETE") {
1837
2267
  this.logger.debug({
1838
2268
  queryId: queryId.toString(),
@@ -2002,13 +2432,13 @@ var Sp00kySync = class {
2002
2432
  throw new Error("Query to register not found");
2003
2433
  }
2004
2434
  await this.remote.query("fn::query::register($config)", { config: {
2005
- clientId: this.clientId,
2006
2435
  id: queryState.config.id,
2007
2436
  surql: queryState.config.surql,
2008
2437
  params: queryState.config.params,
2009
2438
  ttl: queryState.config.ttl
2010
2439
  } });
2011
- const [items] = await this.remote.query(surql.selectByFieldsAnd("_00_list_ref", ["in"], ["out", "version"]), { in: queryState.config.id });
2440
+ const listRefTbl = this.listRefTable();
2441
+ const [items] = await this.remote.query(buildListRefSelect(listRefTbl), { in: queryState.config.id });
2012
2442
  this.logger.trace({
2013
2443
  queryId: encodeRecordId(queryState.config.id),
2014
2444
  items,
@@ -2555,19 +2985,23 @@ var StreamProcessorService = class {
2555
2985
  }
2556
2986
  try {
2557
2987
  const normalizedRecord = this.normalizeValue(record);
2988
+ const t0 = performance.now();
2558
2989
  const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
2990
+ const materializationTimeMs = performance.now() - t0;
2559
2991
  this.logger.debug({
2560
2992
  table,
2561
2993
  op,
2562
2994
  id,
2563
2995
  rawUpdates: rawUpdates.length,
2996
+ materializationTimeMs,
2564
2997
  Category: "sp00ky-client::StreamProcessorService::ingest"
2565
2998
  }, "Ingesting into ssp done");
2566
2999
  if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
2567
3000
  const updates = rawUpdates.map((u) => ({
2568
3001
  queryHash: u.query_id,
2569
3002
  localArray: u.result_data,
2570
- op
3003
+ op,
3004
+ materializationTimeMs
2571
3005
  }));
2572
3006
  this.notifyUpdates(updates);
2573
3007
  }
@@ -2649,6 +3083,7 @@ var StreamProcessorService = class {
2649
3083
  normalizeValue(value) {
2650
3084
  if (value === null || value === void 0) return value;
2651
3085
  if (typeof value === "object") {
3086
+ if (value instanceof Uint8Array || value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return null;
2652
3087
  const hasTable = "table" in value && typeof value.table?.toString === "function";
2653
3088
  const hasId = "id" in value;
2654
3089
  const hasToString = typeof value.toString === "function";
@@ -2738,7 +3173,7 @@ var CacheModule = class {
2738
3173
  });
2739
3174
  if (!skipDbInsert) {
2740
3175
  const query = surql.seal(surql.tx(populatedRecords.map((_, i) => {
2741
- return surql.upsert(`id${i}`, `content${i}`);
3176
+ return surql.upsertMerge(`id${i}`, `content${i}`);
2742
3177
  })));
2743
3178
  const params = populatedRecords.reduce((acc, record, i) => {
2744
3179
  const { id, ...content } = record.record;
@@ -2877,14 +3312,21 @@ function cursorColorFromName(name) {
2877
3312
  var CrdtField = class {
2878
3313
  doc;
2879
3314
  pushTimer = null;
3315
+ local = null;
2880
3316
  remote = null;
2881
3317
  recordId = null;
3318
+ sessionId = "";
2882
3319
  unsubscribe = null;
2883
3320
  lastPushTime = 0;
2884
3321
  lastCursorPushTime = 0;
2885
3322
  loadedFromCrdt = false;
2886
3323
  pushRetryCount = 0;
2887
3324
  logger;
3325
+ cursorsEnabled;
3326
+ /** Remote-push debounce. Local writes happen immediately on every Loro
3327
+ * update; the remote UPSERT is coalesced over this window. Configured
3328
+ * via `Sp00kyConfig.crdtDebounceMs`, default 500. */
3329
+ remoteDebounceMs = 500;
2888
3330
  _onCursorUpdate = null;
2889
3331
  pendingCursorUpdate = null;
2890
3332
  /** Callback set by the editor to receive remote cursor updates.
@@ -2906,13 +3348,21 @@ var CrdtField = class {
2906
3348
  get onCursorUpdate() {
2907
3349
  return this._onCursorUpdate;
2908
3350
  }
2909
- constructor(fieldName, initialState, logger) {
3351
+ constructor(fieldName, cursorsEnabled, initialState, logger) {
2910
3352
  this.fieldName = fieldName;
3353
+ 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_]*`);
2911
3354
  this.logger = logger ?? null;
3355
+ this.cursorsEnabled = cursorsEnabled;
2912
3356
  this.doc = new LoroDoc();
2913
- if (initialState) {
2914
- this.doc.import(decodeBase64(initialState));
3357
+ if (initialState && initialState.length > 0) try {
3358
+ this.doc.import(initialState);
2915
3359
  this.loadedFromCrdt = true;
3360
+ } catch (e) {
3361
+ this.logger?.warn({
3362
+ error: e,
3363
+ fieldName,
3364
+ Category: "sp00ky-client::CrdtField::constructor"
3365
+ }, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
2916
3366
  }
2917
3367
  }
2918
3368
  getDoc() {
@@ -2922,11 +3372,15 @@ var CrdtField = class {
2922
3372
  hasContent() {
2923
3373
  return this.loadedFromCrdt;
2924
3374
  }
2925
- startSync(remote, recordId) {
3375
+ startSync(local, remote, recordId, sessionId, debounceMs) {
3376
+ this.local = local;
2926
3377
  this.remote = remote;
2927
3378
  this.recordId = recordId;
3379
+ this.sessionId = sessionId;
3380
+ this.remoteDebounceMs = debounceMs;
2928
3381
  this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
2929
- this.schedulePush();
3382
+ this.persistLocal();
3383
+ this.scheduleRemotePush();
2930
3384
  });
2931
3385
  }
2932
3386
  stopSync() {
@@ -2940,10 +3394,11 @@ var CrdtField = class {
2940
3394
  }
2941
3395
  if (this.remote && this.recordId) this.pushToRemote();
2942
3396
  }
2943
- importRemote(base64State) {
2944
- if (Date.now() - this.lastPushTime < 500) return;
3397
+ importRemote(state) {
3398
+ if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
2945
3399
  try {
2946
- this.doc.import(decodeBase64(base64State));
3400
+ this.doc.import(state);
3401
+ this.persistLocal();
2947
3402
  } catch (e) {
2948
3403
  this.logger?.warn({
2949
3404
  error: e,
@@ -2952,18 +3407,23 @@ var CrdtField = class {
2952
3407
  }
2953
3408
  }
2954
3409
  exportSnapshot() {
2955
- return encodeBase64(this.doc.export({ mode: "snapshot" }));
2956
- }
2957
- /** Push cursor ephemeral state to _00_crdt as a "_cursor_<fieldName>" entry */
3410
+ return this.doc.export({ mode: "snapshot" });
3411
+ }
3412
+ /** Push this session's cursor blob into the parent row at
3413
+ * `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
3414
+ * field — the editor still calls this method optimistically, but
3415
+ * without `@cursor` on the schema there's nowhere to store the blob.
3416
+ * The UPDATE itself fires the parent table's LIVE feed, so other
3417
+ * browsers receive the cursor change without a separate `_00_rv` bump. */
2958
3418
  async pushCursorState(encoded) {
2959
3419
  if (!this.remote || !this.recordId) return;
3420
+ if (!this.cursorsEnabled) return;
2960
3421
  this.lastCursorPushTime = Date.now();
2961
3422
  try {
2962
3423
  const state = encodeBase64(encoded);
2963
- await this.remote.query(`INSERT INTO _00_crdt (record_id, field, state) VALUES ($rid, $field, $state)
2964
- ON DUPLICATE KEY UPDATE state = $state`, {
2965
- rid: parseRecordIdString(this.recordId),
2966
- field: `_cursor_${this.fieldName}`,
3424
+ await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
3425
+ id: parseRecordIdString(this.recordId),
3426
+ sid: this.sessionId,
2967
3427
  state
2968
3428
  });
2969
3429
  } catch (e) {
@@ -2987,18 +3447,41 @@ var CrdtField = class {
2987
3447
  }, "Failed to apply remote cursor data");
2988
3448
  }
2989
3449
  }
2990
- schedulePush() {
3450
+ scheduleRemotePush() {
2991
3451
  if (this.pushTimer) clearTimeout(this.pushTimer);
2992
- this.pushTimer = setTimeout(() => void this.pushToRemote(), 300);
3452
+ this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
3453
+ }
3454
+ /** SET path inside a parent row for the current snapshot. `@crdt`-only
3455
+ * fields hold the snapshot directly (`<field>`); `@crdt @cursor`
3456
+ * fields hold a `{ state, cursors }` object so the snapshot lives at
3457
+ * `<field>.state` next to per-session cursor blobs. */
3458
+ statePath() {
3459
+ return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
3460
+ }
3461
+ /** Mirror the LoroDoc snapshot into the parent row locally. Runs on
3462
+ * every local update and every remote import so reloads (online or
3463
+ * offline) see the freshest content immediately. Failures are
3464
+ * swallowed — a stale local write must never block user input. */
3465
+ async persistLocal() {
3466
+ if (!this.local || !this.recordId) return;
3467
+ try {
3468
+ await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
3469
+ id: parseRecordIdString(this.recordId),
3470
+ state: this.exportSnapshot()
3471
+ });
3472
+ } catch (e) {
3473
+ this.logger?.debug({
3474
+ error: e,
3475
+ Category: "sp00ky-client::CrdtField::persistLocal"
3476
+ }, "Local CRDT persist failed (best-effort)");
3477
+ }
2993
3478
  }
2994
3479
  async pushToRemote() {
2995
3480
  if (!this.remote || !this.recordId) return;
2996
3481
  this.lastPushTime = Date.now();
2997
3482
  try {
2998
- await this.remote.query(`INSERT INTO _00_crdt (record_id, field, state) VALUES ($rid, $field, $state)
2999
- ON DUPLICATE KEY UPDATE state = $state`, {
3000
- rid: parseRecordIdString(this.recordId),
3001
- field: this.fieldName,
3483
+ await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
3484
+ id: parseRecordIdString(this.recordId),
3002
3485
  state: this.exportSnapshot()
3003
3486
  });
3004
3487
  this.pushRetryCount = 0;
@@ -3009,7 +3492,7 @@ var CrdtField = class {
3009
3492
  }, "Failed to push CRDT state to remote");
3010
3493
  if (this.pushRetryCount < 2) {
3011
3494
  this.pushRetryCount++;
3012
- this.schedulePush();
3495
+ this.scheduleRemotePush();
3013
3496
  }
3014
3497
  }
3015
3498
  }
@@ -3037,18 +3520,44 @@ function encodeBase64(bytes) {
3037
3520
  /**
3038
3521
  * CrdtManager manages active CrdtField instances and their sync channels.
3039
3522
  *
3040
- * Each open record gets a LIVE SELECT on _00_crdt that delivers remote
3041
- * changes in real time.
3523
+ * Collaborative state lives in two dedicated tables (defined in
3524
+ * `apps/cli/src/meta_tables_remote.surql`):
3525
+ * - `_00_crdt` { record_id, field, state } — one row per (record, field)
3526
+ * - `_00_cursor` { record_id, session_id, field, state } — one row per
3527
+ * (record, session, field)
3528
+ *
3529
+ * Splitting them off the parent row is what makes offline edits mergeable:
3530
+ * each (record, field) gets its own row, so concurrent offline writes don't
3531
+ * collide on the parent's last-write-wins semantics.
3532
+ *
3533
+ * Cross-browser delivery still rides the parent table's existing LIVE feed
3534
+ * to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
3535
+ * (issues 3602, 4026). On every meta UPSERT the writer also bumps the
3536
+ * parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
3537
+ * feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
3538
+ * via subquery. Permission inheritance happens server-side via
3539
+ * `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
3042
3540
  */
3043
3541
  var CrdtManager = class {
3044
3542
  fields = /* @__PURE__ */ new Map();
3045
- liveQueries = /* @__PURE__ */ new Map();
3543
+ liveByTable = /* @__PURE__ */ new Map();
3544
+ pendingLive = /* @__PURE__ */ new Map();
3046
3545
  logger;
3047
- constructor(schema, remote, logger) {
3546
+ sessionId = "";
3547
+ constructor(schema, local, remote, logger, debounceMs = 500) {
3048
3548
  this.schema = schema;
3549
+ this.local = local;
3049
3550
  this.remote = remote;
3551
+ this.debounceMs = debounceMs;
3050
3552
  this.logger = logger.child({ service: "CrdtManager" });
3051
3553
  }
3554
+ /** Set the session id that scopes this client's cursor entries. Must be
3555
+ * called before `open()` for cursors to be pushed under a stable key.
3556
+ * Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
3557
+ * for the data-module salt). */
3558
+ setSessionId(sessionId) {
3559
+ this.sessionId = sessionId;
3560
+ }
3052
3561
  /**
3053
3562
  * Open a CRDT field for collaborative editing.
3054
3563
  *
@@ -3060,24 +3569,25 @@ var CrdtManager = class {
3060
3569
  */
3061
3570
  async open(table, recordId, field, fallbackText) {
3062
3571
  this.assertCrdtField(table, field);
3572
+ const cursorsEnabled = this.fieldHasCursor(table, field);
3063
3573
  const key = this.makeKey(table, recordId, field);
3064
3574
  let crdtField = this.fields.get(key);
3065
3575
  if (crdtField) return crdtField;
3066
3576
  let initialCrdtState;
3067
3577
  try {
3068
- const [result] = await this.remote.query("SELECT VALUE state FROM _00_crdt WHERE record_id = $rid AND field = $field LIMIT 1", {
3069
- rid: parseRecordIdString(recordId),
3070
- field
3071
- });
3072
- if (result && result.length > 0 && result[0]) initialCrdtState = result[0];
3578
+ const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
3579
+ const snapshot = this.extractSnapshot(result, cursorsEnabled);
3580
+ if (snapshot) initialCrdtState = snapshot;
3073
3581
  } catch (e) {
3074
- this.logger.debug({
3075
- error: e,
3582
+ this.logger.info({
3583
+ error: String(e),
3584
+ recordId,
3585
+ field,
3076
3586
  Category: "sp00ky-client::CrdtManager::open"
3077
- }, "No existing CRDT state found");
3587
+ }, "No existing CRDT state found in local cache (continuing with empty doc)");
3078
3588
  }
3079
- crdtField = new CrdtField(field, initialCrdtState, this.logger);
3080
- crdtField.startSync(this.remote, recordId);
3589
+ crdtField = new CrdtField(field, cursorsEnabled, initialCrdtState, this.logger);
3590
+ crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
3081
3591
  this.fields.set(key, crdtField);
3082
3592
  this.logger.info({
3083
3593
  key,
@@ -3085,7 +3595,8 @@ var CrdtManager = class {
3085
3595
  hasFallback: !!fallbackText,
3086
3596
  Category: "sp00ky-client::CrdtManager::open"
3087
3597
  }, "CrdtField opened");
3088
- await this.ensureLiveSelect(table, recordId);
3598
+ this.ensureTableSubscription(table);
3599
+ if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
3089
3600
  return crdtField;
3090
3601
  }
3091
3602
  close(table, recordId, field) {
@@ -3095,8 +3606,8 @@ var CrdtManager = class {
3095
3606
  crdtField.stopSync();
3096
3607
  this.fields.delete(key);
3097
3608
  }
3098
- const prefix = `${table}:${recordId}:`;
3099
- if (!Array.from(this.fields.keys()).some((k) => k !== key && k.startsWith(prefix))) this.killLiveSelect(recordId);
3609
+ const tablePrefix = `${table}:`;
3610
+ if (!Array.from(this.fields.keys()).some((k) => k.startsWith(tablePrefix))) this.killTableSubscription(table);
3100
3611
  this.logger.debug({
3101
3612
  key,
3102
3613
  Category: "sp00ky-client::CrdtManager::close"
@@ -3105,51 +3616,121 @@ var CrdtManager = class {
3105
3616
  closeAll() {
3106
3617
  for (const [_, field] of this.fields) field.stopSync();
3107
3618
  this.fields.clear();
3108
- for (const recordId of this.liveQueries.keys()) this.killLiveSelect(recordId);
3109
- }
3110
- async ensureLiveSelect(table, recordId) {
3111
- if (this.liveQueries.has(recordId)) return;
3619
+ for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
3620
+ }
3621
+ /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
3622
+ * every open CrdtField on `table`. */
3623
+ async ensureTableSubscription(table) {
3624
+ if (this.liveByTable.has(table)) return;
3625
+ const pending = this.pendingLive.get(table);
3626
+ if (pending) return pending;
3627
+ const start = (async () => {
3628
+ try {
3629
+ const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
3630
+ (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
3631
+ if (message.action === "KILLED") return;
3632
+ if (message.action !== "CREATE" && message.action !== "UPDATE") return;
3633
+ this.dispatchRow(table, message.value);
3634
+ });
3635
+ this.liveByTable.set(table, uuid);
3636
+ this.logger.info({
3637
+ table,
3638
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
3639
+ }, "LIVE SELECT started");
3640
+ } catch (e) {
3641
+ this.logger.warn({
3642
+ error: e,
3643
+ table,
3644
+ Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
3645
+ }, "Failed to start LIVE SELECT");
3646
+ }
3647
+ })();
3648
+ this.pendingLive.set(table, start);
3112
3649
  try {
3113
- const [uuid] = await this.remote.query(`LIVE SELECT * FROM _00_crdt WHERE record_id = $rid`, { rid: parseRecordIdString(recordId) });
3114
- this.liveQueries.set(recordId, {
3115
- uuid,
3116
- table
3117
- });
3118
- (await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
3119
- if (message.action === "KILLED") return;
3120
- if (message.action === "CREATE" || message.action === "UPDATE") {
3121
- const fieldName = message.value.field;
3122
- const state = message.value.state;
3123
- if (!fieldName || !state) return;
3124
- if (fieldName.startsWith("_cursor_")) {
3125
- const actualField = fieldName.slice(8);
3126
- const key = this.makeKey(table, recordId, actualField);
3127
- const crdtField = this.fields.get(key);
3128
- if (crdtField) crdtField.importRemoteCursor(state);
3129
- return;
3130
- }
3131
- const key = this.makeKey(table, recordId, fieldName);
3132
- const crdtField = this.fields.get(key);
3133
- if (crdtField) crdtField.importRemote(state);
3650
+ await start;
3651
+ } finally {
3652
+ this.pendingLive.delete(table);
3653
+ }
3654
+ }
3655
+ /** Apply a parent-row payload from a non-LIVE source (e.g. the
3656
+ * list_ref-driven sync engine, when the cross-user LIVE on the
3657
+ * parent table is filtered out by the SurrealDB cross-session
3658
+ * permission gap). Same semantics as the internal `dispatchRow`. */
3659
+ applyRow(table, row) {
3660
+ this.dispatchRow(table, row);
3661
+ }
3662
+ /** Dispatch a parent-row LIVE event to every open CrdtField on that
3663
+ * record. Each open field reads its slice of the row directly — the
3664
+ * CRDT snapshot is a column on the parent now, so there is no
3665
+ * follow-up subquery. */
3666
+ dispatchRow(table, row) {
3667
+ const id = row.id != null ? String(row.id) : "";
3668
+ if (!id) return;
3669
+ const rowKeyPrefix = `${table}:${id}:`;
3670
+ for (const [key, crdtField] of this.fields) {
3671
+ if (!key.startsWith(rowKeyPrefix)) continue;
3672
+ const fieldName = key.slice(rowKeyPrefix.length);
3673
+ const cursorsEnabled = this.fieldHasCursor(table, fieldName);
3674
+ const slice = row[fieldName];
3675
+ const snapshot = this.extractSnapshot(slice, cursorsEnabled);
3676
+ if (snapshot) crdtField.importRemote(snapshot);
3677
+ if (cursorsEnabled && slice && typeof slice === "object") {
3678
+ const cursors = slice.cursors;
3679
+ if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
3680
+ if (sid === this.sessionId) continue;
3681
+ if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
3134
3682
  }
3135
- });
3136
- this.logger.info({
3137
- recordId,
3138
- Category: "sp00ky-client::CrdtManager::ensureLiveSelect"
3139
- }, "LIVE SELECT on _00_crdt started");
3683
+ }
3684
+ }
3685
+ }
3686
+ /** One-shot remote fetch for a row whose CRDT field hasn't synced
3687
+ * locally yet (fresh device, memory-backed local DB after reload, …).
3688
+ * Used by `open()` when the local read came up empty. Subsequent
3689
+ * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
3690
+ async fetchAndDispatchRow(table, id) {
3691
+ try {
3692
+ const recordId = parseRecordIdString(id);
3693
+ const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
3694
+ if (!row || typeof row !== "object") return;
3695
+ this.dispatchRow(table, row);
3140
3696
  } catch (e) {
3141
3697
  this.logger.warn({
3142
3698
  error: e,
3143
- recordId,
3144
- Category: "sp00ky-client::CrdtManager::ensureLiveSelect"
3145
- }, "Failed to start LIVE SELECT on _00_crdt");
3699
+ table,
3700
+ id,
3701
+ Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
3702
+ }, "Failed to fetch parent row for CRDT hydration");
3146
3703
  }
3147
3704
  }
3148
- killLiveSelect(recordId) {
3149
- const entry = this.liveQueries.get(recordId);
3150
- if (entry) {
3151
- this.remote.query("KILL $uuid", { uuid: entry.uuid }).catch(() => {});
3152
- this.liveQueries.delete(recordId);
3705
+ /** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
3706
+ * Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
3707
+ fieldHasCursor(table, field) {
3708
+ return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
3709
+ }
3710
+ /** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
3711
+ * the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
3712
+ * `{ state, cursors }` where `state` carries the snapshot bytes. */
3713
+ extractSnapshot(value, cursorsEnabled) {
3714
+ const asBytes = (v) => {
3715
+ if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
3716
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
3717
+ if (ArrayBuffer.isView(v)) {
3718
+ const view = v;
3719
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
3720
+ }
3721
+ if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
3722
+ };
3723
+ if (cursorsEnabled) {
3724
+ if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
3725
+ return;
3726
+ }
3727
+ return asBytes(value);
3728
+ }
3729
+ killTableSubscription(table) {
3730
+ const uuid = this.liveByTable.get(table);
3731
+ if (uuid) {
3732
+ this.remote.query("KILL $uuid", { uuid }).catch(() => {});
3733
+ this.liveByTable.delete(table);
3153
3734
  }
3154
3735
  }
3155
3736
  makeKey(table, recordId, field) {
@@ -3161,6 +3742,7 @@ var CrdtManager = class {
3161
3742
  * of silently producing a non-CRDT writer.
3162
3743
  */
3163
3744
  assertCrdtField(table, field) {
3745
+ 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_]*`);
3164
3746
  const tableSchema = this.schema.tables.find((t) => t.name === table);
3165
3747
  if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
3166
3748
  const column = tableSchema.columns[field];
@@ -3329,6 +3911,14 @@ var Sp00kyClient = class {
3329
3911
  get pendingMutationCount() {
3330
3912
  return this.sync.pendingMutationCount;
3331
3913
  }
3914
+ /** Number of times the initial list_ref LIVE subscription retried on
3915
+ * the most recent `setCurrentUserId` call. 0 when the SSP's
3916
+ * pre-emptive user-table creation got there first; >0 when LIVE
3917
+ * registration hit a "table not found" race. Exposed so the e2e
3918
+ * suite can guard the pre-emptive path against regression. */
3919
+ get liveRetryCount() {
3920
+ return this.sync.liveRetryCount;
3921
+ }
3332
3922
  subscribeToPendingMutations(cb) {
3333
3923
  return this.sync.subscribeToPendingMutations(cb);
3334
3924
  }
@@ -3354,10 +3944,10 @@ var Sp00kyClient = class {
3354
3944
  this.cache = new CacheModule(this.local, this.streamProcessor, (update) => {
3355
3945
  this.dataModule.onStreamUpdate(update);
3356
3946
  }, logger);
3357
- this.crdtManager = new CrdtManager(this.config.schema, this.remote, logger);
3947
+ this.crdtManager = new CrdtManager(this.config.schema, this.local, this.remote, logger, config.crdtDebounceMs ?? 500);
3358
3948
  this.dataModule = new DataModule(this.cache, this.local, this.config.schema, logger, this.config.streamDebounceTime);
3359
3949
  this.auth = new AuthService(this.config.schema, this.remote, this.persistenceClient, logger);
3360
- this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger);
3950
+ this.sync = new Sp00kySync(this.local, this.remote, this.cache, this.dataModule, this.config.schema, this.logger, { refSyncIntervalMs: this.config.refSyncIntervalMs });
3361
3951
  this.devTools = new DevToolsService(this.local, this.remote, logger, this.config.schema, this.auth, this.dataModule);
3362
3952
  this.streamProcessor.addReceiver(this.devTools);
3363
3953
  this.setupCallbacks();
@@ -3373,6 +3963,22 @@ var Sp00kyClient = class {
3373
3963
  this.sync.events.subscribe("SYNC_QUERY_UPDATED", (event) => {
3374
3964
  this.devTools.logEvent("SYNC_QUERY_UPDATED", event.payload);
3375
3965
  });
3966
+ this.sync.engineEvents.subscribe("SYNC_REMOTE_DATA_INGESTED", (event) => {
3967
+ try {
3968
+ const records = event.payload?.records ?? [];
3969
+ for (const row of records) {
3970
+ const id = row?.id;
3971
+ const table = id && typeof id === "object" && id.table !== void 0 ? String(id.table) : void 0;
3972
+ if (!table) continue;
3973
+ this.crdtManager.applyRow(table, row);
3974
+ }
3975
+ } catch (err) {
3976
+ this.logger.debug({
3977
+ err,
3978
+ Category: "sp00ky-client::engineEvents::ingested"
3979
+ }, "applyRow forwarding from sync ingest failed");
3980
+ }
3981
+ });
3376
3982
  this.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => {
3377
3983
  this.devTools.logEvent("LOCAL_QUERY", event.payload);
3378
3984
  });
@@ -3383,12 +3989,6 @@ var Sp00kyClient = class {
3383
3989
  async init() {
3384
3990
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
3385
3991
  try {
3386
- const clientId = this.config.clientId ?? await this.loadOrGenerateClientId();
3387
- this.persistClientId(clientId);
3388
- this.logger.debug({
3389
- clientId,
3390
- Category: "sp00ky-client::Sp00kyClient::init"
3391
- }, "Client ID loaded");
3392
3992
  await this.local.connect();
3393
3993
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Local database connected");
3394
3994
  await this.migrator.provision(this.config.schemaSurql);
@@ -3399,9 +3999,28 @@ var Sp00kyClient = class {
3399
3999
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "StreamProcessor initialized");
3400
4000
  await this.auth.init();
3401
4001
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Auth initialized");
3402
- await this.dataModule.init();
3403
- this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "DataModule initialized");
3404
- await this.sync.init(clientId);
4002
+ const sessionId = await this.fetchSessionId();
4003
+ await this.dataModule.init(sessionId);
4004
+ this.crdtManager.setSessionId(sessionId);
4005
+ this.logger.debug({
4006
+ sessionId,
4007
+ Category: "sp00ky-client::Sp00kyClient::init"
4008
+ }, "DataModule initialized");
4009
+ this.auth.subscribe(async (userId) => {
4010
+ this.dataModule.setCurrentUserId(userId);
4011
+ const next = await this.fetchSessionId();
4012
+ this.dataModule.setSessionId(next);
4013
+ this.crdtManager.setSessionId(next);
4014
+ try {
4015
+ await this.sync.setCurrentUserId(userId);
4016
+ } catch (e) {
4017
+ this.logger.error({
4018
+ error: e,
4019
+ Category: "sp00ky-client::Sp00kyClient::authChange"
4020
+ }, "sync.setCurrentUserId failed");
4021
+ }
4022
+ });
4023
+ await this.sync.init();
3405
4024
  this.logger.debug({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sync initialized");
3406
4025
  this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization completed successfully");
3407
4026
  } catch (e) {
@@ -3423,7 +4042,8 @@ var Sp00kyClient = class {
3423
4042
  /**
3424
4043
  * Open a CRDT field for collaborative editing.
3425
4044
  * Returns a CrdtField with a LoroDoc that can be bound to any editor.
3426
- * Also starts a LIVE SELECT on _00_crdt for real-time sync.
4045
+ * Also starts a LIVE SELECT on the parent table for real-time sync;
4046
+ * incoming events trigger a subquery fetch of `_00_crdt` / `_00_cursor`.
3427
4047
  */
3428
4048
  async openCrdtField(table, recordId, field, fallbackText) {
3429
4049
  return this.crdtManager.open(table, recordId, field, fallbackText);
@@ -3475,24 +4095,25 @@ var Sp00kyClient = class {
3475
4095
  async useRemote(fn) {
3476
4096
  return fn(this.remote.getClient());
3477
4097
  }
3478
- persistClientId(id) {
4098
+ /**
4099
+ * Fetch SurrealDB's `session::id()` as a string. Used as a salt for
4100
+ * query-id hashing so two sessions for the same user get distinct
4101
+ * `_00_query` rows. Returns empty string if the query fails (we still
4102
+ * boot, just without session scoping for IDs).
4103
+ */
4104
+ async fetchSessionId() {
3479
4105
  try {
3480
- this.persistenceClient.set("sp00ky_client_id", id);
4106
+ const [sid] = await this.remote.query("RETURN <string>session::id()");
4107
+ return typeof sid === "string" ? sid : "";
3481
4108
  } catch (e) {
3482
4109
  this.logger.warn({
3483
4110
  error: e,
3484
- Category: "sp00ky-client::Sp00kyClient::persistClientId"
3485
- }, "Failed to persist client ID");
4111
+ Category: "sp00ky-client::Sp00kyClient::fetchSessionId"
4112
+ }, "Failed to fetch session::id() — proceeding with empty salt");
4113
+ return "";
3486
4114
  }
3487
4115
  }
3488
- async loadOrGenerateClientId() {
3489
- const clientId = await this.persistenceClient.get("sp00ky_client_id");
3490
- if (clientId) return clientId;
3491
- const newId = generateId();
3492
- await this.persistClientId(newId);
3493
- return newId;
3494
- }
3495
4116
  };
3496
4117
 
3497
4118
  //#endregion
3498
- export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };
4119
+ export { AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, createAuthEventSystem, cursorColorFromName, fileToUint8Array, textToHtml };