@spooky-sync/core 0.0.1-canary.66 → 0.0.1-canary.68
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.d.ts +105 -17
- package/dist/index.js +754 -133
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +29 -3
- package/package.json +2 -2
- package/src/modules/cache/index.ts +6 -1
- package/src/modules/crdt/crdt-field.ts +115 -23
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +207 -68
- package/src/modules/data/index.ts +170 -17
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +47 -11
- package/src/modules/sync/sync.ts +304 -13
- package/src/modules/sync/utils.test.ts +157 -0
- package/src/modules/sync/utils.ts +79 -0
- package/src/services/stream-processor/index.ts +25 -0
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +112 -31
- package/src/types.ts +29 -2
- package/src/utils/surql.ts +9 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2245
|
+
tableName,
|
|
1818
2246
|
Category: "sp00ky-client::Sp00kySync::startRefLiveQueries"
|
|
1819
2247
|
}, "Starting ref live queries");
|
|
1820
|
-
const [queryUuid] = await this.remote.query(
|
|
1821
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
2944
|
-
if (Date.now() - this.lastPushTime <
|
|
3397
|
+
importRemote(state) {
|
|
3398
|
+
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
2945
3399
|
try {
|
|
2946
|
-
this.doc.import(
|
|
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
|
|
2956
|
-
}
|
|
2957
|
-
/** Push
|
|
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(`
|
|
2964
|
-
|
|
2965
|
-
|
|
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
|
-
|
|
3450
|
+
scheduleRemotePush() {
|
|
2991
3451
|
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
2992
|
-
this.pushTimer = setTimeout(() => void this.pushToRemote(),
|
|
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(`
|
|
2999
|
-
|
|
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.
|
|
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
|
-
*
|
|
3041
|
-
*
|
|
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
|
-
|
|
3543
|
+
liveByTable = /* @__PURE__ */ new Map();
|
|
3544
|
+
pendingLive = /* @__PURE__ */ new Map();
|
|
3046
3545
|
logger;
|
|
3047
|
-
|
|
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.
|
|
3069
|
-
|
|
3070
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
3099
|
-
if (!Array.from(this.fields.keys()).some((k) => k
|
|
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
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
|
|
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
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
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
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3699
|
+
table,
|
|
3700
|
+
id,
|
|
3701
|
+
Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
|
|
3702
|
+
}, "Failed to fetch parent row for CRDT hydration");
|
|
3146
3703
|
}
|
|
3147
3704
|
}
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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.
|
|
3403
|
-
this.
|
|
3404
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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::
|
|
3485
|
-
}, "Failed to
|
|
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 };
|