@spooky-sync/core 0.0.1-canary.323 → 0.0.1-canary.325
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/AGENTS.md +4 -3
- package/dist/index.d.ts +152 -161
- package/dist/index.js +796 -482
- package/dist/types.d.ts +7 -6
- package/package.json +4 -4
- package/src/boot/preload.saga.test.ts +29 -0
- package/src/boot/preload.saga.ts +11 -2
- package/src/client/services.ts +1 -1
- package/src/client/sp00ky-client.test.ts +4 -1
- package/src/client/sp00ky-client.ts +37 -4
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +48 -255
- package/src/modules/crdt/crdt.test.ts +365 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +610 -363
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/mutation/push.saga.test.ts +16 -0
- package/src/mutation/push.saga.ts +9 -1
- package/src/query/anon-query-isolation.integration.test.ts +31 -0
- package/src/query/env.ts +9 -0
- package/src/services/database/local-migrator.ts +5 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +7 -6
- package/src/utils/surql.ts +3 -4
- package/src/modules/crdt/crdt-hydration.test.ts +0 -210
- package/src/modules/crdt/crdt-reconnect.test.ts +0 -195
package/dist/index.js
CHANGED
|
@@ -2188,8 +2188,8 @@ function trimHistory(history, cap) {
|
|
|
2188
2188
|
|
|
2189
2189
|
//#endregion
|
|
2190
2190
|
//#region src/modules/devtools/index.ts
|
|
2191
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2192
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2191
|
+
const CORE_VERSION = "0.0.1-canary.325";
|
|
2192
|
+
const WASM_VERSION = "0.0.1-canary.325";
|
|
2193
2193
|
const SURREAL_VERSION = "3.0.3";
|
|
2194
2194
|
var DevToolsService = class DevToolsService {
|
|
2195
2195
|
version = CORE_VERSION;
|
|
@@ -3814,6 +3814,14 @@ function listRefTable(env, s) {
|
|
|
3814
3814
|
const userId = s.userId ?? (env.anonLive ? ANON_USER_ID : null);
|
|
3815
3815
|
return listRefTableFor(env.refMode, userId);
|
|
3816
3816
|
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Whether this session can be sent a view's membership at all. A signed-out
|
|
3819
|
+
* session reads edges only from `_00_list_ref_anon`, and only with
|
|
3820
|
+
* `enableAnonymousLiveQueries`: without it the SSP files its views under no
|
|
3821
|
+
* identity in `_00_list_ref`, whose rule shows those edges to no caller. Such
|
|
3822
|
+
* a view can register and report its row count, but never its rows.
|
|
3823
|
+
*/
|
|
3824
|
+
const receivesMembership = (env, s) => s.userId !== null || env.anonLive;
|
|
3817
3825
|
function columnsFor(env, table) {
|
|
3818
3826
|
const found = env.schema.tables.find((t) => t.name === table);
|
|
3819
3827
|
if (found) return found.columns;
|
|
@@ -5629,6 +5637,12 @@ function* refreshFailedCount() {
|
|
|
5629
5637
|
}
|
|
5630
5638
|
const ERR = (r) => r && r.status === "ERR" ? r.error : null;
|
|
5631
5639
|
/**
|
|
5640
|
+
* A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
|
|
5641
|
+
* so a CREATE retried after its first attempt committed (and only the answer
|
|
5642
|
+
* was lost) finds it already there: that is a success, not a rejection.
|
|
5643
|
+
*/
|
|
5644
|
+
const isRepeatedLogCreate = (row, err) => row.mutationType === "create" && row.tableName.endsWith("__crdt_log") && /already exists/i.test(err);
|
|
5645
|
+
/**
|
|
5632
5646
|
* Drain the outbox: one serial lane, FIFO, up to `outboxBatchSize` statements
|
|
5633
5647
|
* per request, per-statement outcome. Accepted rows are acked (they stay in
|
|
5634
5648
|
* the overlay until membership names them), rejected rows are rolled back,
|
|
@@ -5692,7 +5706,7 @@ function* drain(env) {
|
|
|
5692
5706
|
break;
|
|
5693
5707
|
}
|
|
5694
5708
|
const err = ERR(r);
|
|
5695
|
-
if (err === null) {
|
|
5709
|
+
if (err === null || isRepeatedLogCreate(row, err)) {
|
|
5696
5710
|
const del = deletePendingRow(row.id);
|
|
5697
5711
|
try {
|
|
5698
5712
|
yield fx.local.query(del.sql, del.vars);
|
|
@@ -6025,10 +6039,16 @@ var PreloadFailedError = class extends Error {
|
|
|
6025
6039
|
* Never resolved: blocks until the server's membership and every body are
|
|
6026
6040
|
* local and the first materialization ran. The entry is evicted like any
|
|
6027
6041
|
* other query a ttl after it was last watched (or registered).
|
|
6042
|
+
*
|
|
6043
|
+
* A session that cannot be sent membership (signed out, without anonymous
|
|
6044
|
+
* live queries) does not wait either: a view with rows would never settle,
|
|
6045
|
+
* and an app that gates its first paint on a preload showed its loading
|
|
6046
|
+
* screen forever, signed-out landing page included.
|
|
6028
6047
|
*/
|
|
6029
6048
|
function* preload(env, input) {
|
|
6030
6049
|
const hash = yield* registerLocal(env, input);
|
|
6031
|
-
|
|
6050
|
+
const [phase, reachable] = yield fx.state.read((s) => [s.queries.get(hash)?.lifecycle.phase, receivesMembership(env, s)]);
|
|
6051
|
+
if (phase !== "cold" || !reachable) return {
|
|
6032
6052
|
hash,
|
|
6033
6053
|
waited: false
|
|
6034
6054
|
};
|
|
@@ -6419,7 +6439,7 @@ function rowsFromResult(effect, result) {
|
|
|
6419
6439
|
* the SurrealDB one. `null` for a row that carries none (a projection
|
|
6420
6440
|
* without `id`, a `SELECT VALUE`).
|
|
6421
6441
|
*/
|
|
6422
|
-
function rowKey(row) {
|
|
6442
|
+
function rowKey$1(row) {
|
|
6423
6443
|
const id = row !== null && typeof row === "object" ? row.id : void 0;
|
|
6424
6444
|
if (typeof id === "string") return id;
|
|
6425
6445
|
if (id !== null && typeof id === "object" && "table" in id && "id" in id) return encodeRecordId(id);
|
|
@@ -6519,7 +6539,7 @@ function* materialize(hash) {
|
|
|
6519
6539
|
let rows;
|
|
6520
6540
|
try {
|
|
6521
6541
|
rows = rowsFromResult(effect, yield effect);
|
|
6522
|
-
if (ids === null) rows = rows.filter((row) => paints(rowKey(row)));
|
|
6542
|
+
if (ids === null) rows = rows.filter((row) => paints(rowKey$1(row)));
|
|
6523
6543
|
} catch (error) {
|
|
6524
6544
|
yield fx.state.update(compose(recordError(hash), clearDirty(hash)));
|
|
6525
6545
|
yield fx.emit({
|
|
@@ -8097,7 +8117,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8097
8117
|
try {
|
|
8098
8118
|
await client.close();
|
|
8099
8119
|
} catch {}
|
|
8100
|
-
await delay(150 * attempt);
|
|
8120
|
+
await delay$1(150 * attempt);
|
|
8101
8121
|
try {
|
|
8102
8122
|
await this.openStore(client, storeUrl, namespace, database);
|
|
8103
8123
|
this.logger.info({
|
|
@@ -8176,7 +8196,7 @@ var LocalDatabaseService = class extends AbstractDatabaseService {
|
|
|
8176
8196
|
this.logger.debug({ Category: "sp00ky-client::LocalDatabaseService::connect" }, "[LocalDatabaseService] client.use returned");
|
|
8177
8197
|
}
|
|
8178
8198
|
};
|
|
8179
|
-
function delay(ms) {
|
|
8199
|
+
function delay$1(ms) {
|
|
8180
8200
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8181
8201
|
}
|
|
8182
8202
|
/** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
|
|
@@ -8841,13 +8861,15 @@ const sha1 = async (str) => {
|
|
|
8841
8861
|
};
|
|
8842
8862
|
/**
|
|
8843
8863
|
* Local-only state a schema change must not throw away: the outbox, the
|
|
8844
|
-
* failed-mutation tray
|
|
8845
|
-
* rows refill from the server;
|
|
8864
|
+
* failed-mutation tray, debounced patches not yet in the outbox and CRDT
|
|
8865
|
+
* documents with edits not yet sent. Cached rows refill from the server;
|
|
8866
|
+
* these exist nowhere else.
|
|
8846
8867
|
*/
|
|
8847
8868
|
const KEPT_TABLES = [
|
|
8848
8869
|
"_00_pending_mutations",
|
|
8849
8870
|
"_00_failed_mutations",
|
|
8850
|
-
"_00_pending_writes"
|
|
8871
|
+
"_00_pending_writes",
|
|
8872
|
+
"_00_crdt_local"
|
|
8851
8873
|
];
|
|
8852
8874
|
var LocalMigrator = class {
|
|
8853
8875
|
logger;
|
|
@@ -8865,7 +8887,7 @@ var LocalMigrator = class {
|
|
|
8865
8887
|
}
|
|
8866
8888
|
const kept = await this.readKeptRows();
|
|
8867
8889
|
await this.recreateDatabase(database);
|
|
8868
|
-
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
8890
|
+
const fullSchema = schemaSurql + "\n\n DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_pending_writes SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n DEFINE TABLE IF NOT EXISTS _00_crdt_local SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;\n ";
|
|
8869
8891
|
const statements = this.splitStatements(fullSchema);
|
|
8870
8892
|
for (let i = 0; i < statements.length; i++) {
|
|
8871
8893
|
const statement = statements[i];
|
|
@@ -9739,6 +9761,7 @@ const SYSTEM_TABLES = [
|
|
|
9739
9761
|
"_00_pending_mutations",
|
|
9740
9762
|
"_00_pending_writes",
|
|
9741
9763
|
"_00_blob",
|
|
9764
|
+
"_00_crdt_local",
|
|
9742
9765
|
"_00_user_feature",
|
|
9743
9766
|
"_00_app_release"
|
|
9744
9767
|
];
|
|
@@ -12168,6 +12191,109 @@ var AuthService = class {
|
|
|
12168
12191
|
}
|
|
12169
12192
|
};
|
|
12170
12193
|
|
|
12194
|
+
//#endregion
|
|
12195
|
+
//#region src/modules/crdt/acceptance.ts
|
|
12196
|
+
/** The largest blob a row may carry (the generated column's cap). */
|
|
12197
|
+
const MAX_DELTA_BYTES = 256 * 1024;
|
|
12198
|
+
const PEER = /^\d{1,20}$/;
|
|
12199
|
+
/** `"loro"`: every Loro blob starts with it. */
|
|
12200
|
+
const MAGIC = [
|
|
12201
|
+
108,
|
|
12202
|
+
111,
|
|
12203
|
+
114,
|
|
12204
|
+
111
|
|
12205
|
+
];
|
|
12206
|
+
/** Header bytes 20..22 (big-endian) carry the encoding mode; 4 is an update. */
|
|
12207
|
+
const MODE_UPDATES = 4;
|
|
12208
|
+
function check(loro, blob, peer, maxBytes = MAX_DELTA_BYTES) {
|
|
12209
|
+
if (!blob || blob.length === 0) return {
|
|
12210
|
+
ok: false,
|
|
12211
|
+
reason: "empty"
|
|
12212
|
+
};
|
|
12213
|
+
if (blob.length > maxBytes) return {
|
|
12214
|
+
ok: false,
|
|
12215
|
+
reason: "too_large"
|
|
12216
|
+
};
|
|
12217
|
+
if (typeof peer !== "string" || !PEER.test(peer)) return {
|
|
12218
|
+
ok: false,
|
|
12219
|
+
reason: "bad_peer"
|
|
12220
|
+
};
|
|
12221
|
+
if (blob.length < 22 || MAGIC.some((b, i) => blob[i] !== b)) return {
|
|
12222
|
+
ok: false,
|
|
12223
|
+
reason: "header"
|
|
12224
|
+
};
|
|
12225
|
+
if ((blob[20] << 8 | blob[21]) !== MODE_UPDATES) return {
|
|
12226
|
+
ok: false,
|
|
12227
|
+
reason: "not_an_update"
|
|
12228
|
+
};
|
|
12229
|
+
let meta;
|
|
12230
|
+
try {
|
|
12231
|
+
meta = loro.decodeImportBlobMeta(blob, true);
|
|
12232
|
+
} catch {
|
|
12233
|
+
return {
|
|
12234
|
+
ok: false,
|
|
12235
|
+
reason: "undecodable"
|
|
12236
|
+
};
|
|
12237
|
+
}
|
|
12238
|
+
if (meta.mode !== "update") return {
|
|
12239
|
+
ok: false,
|
|
12240
|
+
reason: "not_an_update"
|
|
12241
|
+
};
|
|
12242
|
+
const start = meta.partialStartVersionVector.toJSON();
|
|
12243
|
+
const authors = [];
|
|
12244
|
+
for (const [p, end] of meta.partialEndVersionVector.toJSON()) if (end > (start.get(p) ?? 0)) authors.push([String(p), end]);
|
|
12245
|
+
if (authors.length === 0) return {
|
|
12246
|
+
ok: false,
|
|
12247
|
+
reason: "no_ops"
|
|
12248
|
+
};
|
|
12249
|
+
if (authors.length > 1 || BigInt(authors[0][0]) !== BigInt(peer)) return {
|
|
12250
|
+
ok: false,
|
|
12251
|
+
reason: "foreign_peer"
|
|
12252
|
+
};
|
|
12253
|
+
return {
|
|
12254
|
+
ok: true,
|
|
12255
|
+
accepted: {
|
|
12256
|
+
peer,
|
|
12257
|
+
end: authors[0][1]
|
|
12258
|
+
}
|
|
12259
|
+
};
|
|
12260
|
+
}
|
|
12261
|
+
|
|
12262
|
+
//#endregion
|
|
12263
|
+
//#region src/modules/crdt/bytes.ts
|
|
12264
|
+
/**
|
|
12265
|
+
* Byte columns reach the client in several shapes depending on the engine and
|
|
12266
|
+
* the path: `Uint8Array` (SQLite, a fresh local write), `ArrayBuffer` (the
|
|
12267
|
+
* surrealdb SDK's CBOR decode), a typed-array view, or a `number[]` (bytes
|
|
12268
|
+
* nested in a FLEXIBLE object on the SurrealDB-WASM engine). Normalize all of
|
|
12269
|
+
* them; anything else is not bytes.
|
|
12270
|
+
*/
|
|
12271
|
+
function asBytes(value) {
|
|
12272
|
+
if (value instanceof Uint8Array) return value;
|
|
12273
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
12274
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
12275
|
+
if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === "number")) return Uint8Array.from(value);
|
|
12276
|
+
}
|
|
12277
|
+
function encodeBase64(bytes) {
|
|
12278
|
+
if (typeof btoa === "function") {
|
|
12279
|
+
let binary = "";
|
|
12280
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
12281
|
+
return btoa(binary);
|
|
12282
|
+
}
|
|
12283
|
+
return Buffer.from(bytes).toString("base64");
|
|
12284
|
+
}
|
|
12285
|
+
/**
|
|
12286
|
+
* A random Loro peer id below 2^53 (so it is exact as a JS number), as the
|
|
12287
|
+
* decimal string Loro uses for `PeerID`. Each tab picks a new one per session
|
|
12288
|
+
* and claims it in `_00_crdt_peer` before any of its edits are sent.
|
|
12289
|
+
*/
|
|
12290
|
+
function randomPeer() {
|
|
12291
|
+
const words = new Uint32Array(2);
|
|
12292
|
+
crypto.getRandomValues(words);
|
|
12293
|
+
const id = (words[0] & 2097151) * 4294967296 + words[1];
|
|
12294
|
+
return String(id === 0 ? 1 : id);
|
|
12295
|
+
}
|
|
12296
|
+
|
|
12171
12297
|
//#endregion
|
|
12172
12298
|
//#region src/modules/crdt/crdt-field.ts
|
|
12173
12299
|
const CURSOR_COLORS = [
|
|
@@ -12185,222 +12311,139 @@ function cursorColorFromName(name) {
|
|
|
12185
12311
|
for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
|
|
12186
12312
|
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
12187
12313
|
}
|
|
12314
|
+
/**
|
|
12315
|
+
* An open collaborative field: the Loro document an editor binds to. Edits
|
|
12316
|
+
* made on it are sent as the client's own edit-log rows; other clients' rows
|
|
12317
|
+
* are imported into it as they arrive. Every open of the same field in one tab
|
|
12318
|
+
* shares this document.
|
|
12319
|
+
*/
|
|
12188
12320
|
var CrdtField = class {
|
|
12189
|
-
doc;
|
|
12190
|
-
pushTimer = null;
|
|
12191
|
-
local = null;
|
|
12192
|
-
remote = null;
|
|
12193
|
-
recordId = null;
|
|
12194
|
-
sessionId = "";
|
|
12195
|
-
unsubscribe = null;
|
|
12196
|
-
lastPushTime = 0;
|
|
12197
|
-
lastCursorPushTime = 0;
|
|
12198
|
-
loadedFromCrdt = false;
|
|
12199
|
-
pushRetryCount = 0;
|
|
12200
|
-
logger;
|
|
12201
|
-
cursorsEnabled;
|
|
12202
|
-
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
12203
|
-
* update; the remote UPSERT is coalesced over this window. Configured
|
|
12204
|
-
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
12205
|
-
remoteDebounceMs = 500;
|
|
12206
12321
|
_onCursorUpdate = null;
|
|
12207
12322
|
pendingCursorUpdate = null;
|
|
12208
|
-
|
|
12209
|
-
|
|
12210
|
-
|
|
12211
|
-
this._onCursorUpdate = cb;
|
|
12212
|
-
if (cb && this.pendingCursorUpdate) {
|
|
12213
|
-
try {
|
|
12214
|
-
cb(this.pendingCursorUpdate);
|
|
12215
|
-
} catch (e) {
|
|
12216
|
-
this.logger?.warn({
|
|
12217
|
-
error: e,
|
|
12218
|
-
Category: "sp00ky-client::CrdtField::onCursorUpdate"
|
|
12219
|
-
}, "Failed to replay pending cursor update");
|
|
12220
|
-
}
|
|
12221
|
-
this.pendingCursorUpdate = null;
|
|
12222
|
-
}
|
|
12223
|
-
}
|
|
12224
|
-
get onCursorUpdate() {
|
|
12225
|
-
return this._onCursorUpdate;
|
|
12226
|
-
}
|
|
12227
|
-
/**
|
|
12228
|
-
* @param LoroDocClass the `LoroDoc` constructor, injected by the caller after
|
|
12229
|
-
* awaiting {@link loadLoro} — keeps `loro-crdt` out of this module's static
|
|
12230
|
-
* import graph so it only ships to apps that use CRDT fields.
|
|
12231
|
-
*/
|
|
12232
|
-
constructor(fieldName, cursorsEnabled, LoroDocClass, initialState, logger) {
|
|
12323
|
+
constructor(table, recordId, fieldName, kind, cursorsEnabled, doc, host) {
|
|
12324
|
+
this.table = table;
|
|
12325
|
+
this.recordId = recordId;
|
|
12233
12326
|
this.fieldName = fieldName;
|
|
12234
|
-
|
|
12235
|
-
this.logger = logger ?? null;
|
|
12327
|
+
this.kind = kind;
|
|
12236
12328
|
this.cursorsEnabled = cursorsEnabled;
|
|
12237
|
-
this.doc =
|
|
12238
|
-
|
|
12239
|
-
this.doc.import(initialState);
|
|
12240
|
-
this.loadedFromCrdt = true;
|
|
12241
|
-
} catch (e) {
|
|
12242
|
-
this.logger?.warn({
|
|
12243
|
-
error: e,
|
|
12244
|
-
fieldName,
|
|
12245
|
-
Category: "sp00ky-client::CrdtField::constructor"
|
|
12246
|
-
}, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
|
|
12247
|
-
}
|
|
12329
|
+
this.doc = doc;
|
|
12330
|
+
this.host = host;
|
|
12248
12331
|
}
|
|
12249
12332
|
getDoc() {
|
|
12250
12333
|
return this.doc;
|
|
12251
12334
|
}
|
|
12252
|
-
/** Whether the
|
|
12335
|
+
/** Whether the document holds anything yet (local state or edits). */
|
|
12253
12336
|
hasContent() {
|
|
12254
|
-
return this.
|
|
12337
|
+
return this.doc.oplogVersion().length() > 0;
|
|
12255
12338
|
}
|
|
12256
|
-
|
|
12257
|
-
|
|
12258
|
-
|
|
12259
|
-
this.
|
|
12260
|
-
this.sessionId = sessionId;
|
|
12261
|
-
this.remoteDebounceMs = debounceMs;
|
|
12262
|
-
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
12263
|
-
this.persistLocal();
|
|
12264
|
-
this.scheduleRemotePush();
|
|
12265
|
-
});
|
|
12339
|
+
/** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
|
|
12340
|
+
* editor's cursor). No-op unless the field is `@cursor`. */
|
|
12341
|
+
pushCursorState(encoded) {
|
|
12342
|
+
if (this.cursorsEnabled) this.host.pushPresence(encoded);
|
|
12266
12343
|
}
|
|
12267
|
-
/**
|
|
12268
|
-
*
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
|
|
12274
|
-
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
this.unsubscribe = null;
|
|
12278
|
-
}
|
|
12279
|
-
if (this.pushTimer) {
|
|
12280
|
-
clearTimeout(this.pushTimer);
|
|
12281
|
-
this.pushTimer = null;
|
|
12282
|
-
}
|
|
12283
|
-
if (flush && this.remote && this.recordId) this.pushToRemote();
|
|
12284
|
-
}
|
|
12285
|
-
importRemote(state) {
|
|
12286
|
-
if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
|
|
12287
|
-
try {
|
|
12288
|
-
this.doc.import(state);
|
|
12289
|
-
this.persistLocal();
|
|
12290
|
-
} catch (e) {
|
|
12291
|
-
this.logger?.warn({
|
|
12292
|
-
error: e,
|
|
12293
|
-
Category: "sp00ky-client::CrdtField::importRemote"
|
|
12294
|
-
}, "Failed to import remote CRDT state");
|
|
12295
|
-
}
|
|
12296
|
-
}
|
|
12297
|
-
exportSnapshot() {
|
|
12298
|
-
return this.doc.export({ mode: "snapshot" });
|
|
12299
|
-
}
|
|
12300
|
-
/** Push this session's cursor blob into the parent row at
|
|
12301
|
-
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
12302
|
-
* field — the editor still calls this method optimistically, but
|
|
12303
|
-
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
12304
|
-
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
12305
|
-
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
12306
|
-
async pushCursorState(encoded) {
|
|
12307
|
-
if (!this.remote || !this.recordId) return;
|
|
12308
|
-
if (!this.cursorsEnabled) return;
|
|
12309
|
-
this.lastCursorPushTime = Date.now();
|
|
12310
|
-
try {
|
|
12311
|
-
const state = encodeBase64(encoded);
|
|
12312
|
-
await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
|
|
12313
|
-
id: parseRecordIdString(this.recordId),
|
|
12314
|
-
sid: this.sessionId,
|
|
12315
|
-
state
|
|
12316
|
-
});
|
|
12317
|
-
} catch (e) {
|
|
12318
|
-
this.logger?.warn({
|
|
12319
|
-
error: e,
|
|
12320
|
-
Category: "sp00ky-client::CrdtField::pushCursorState"
|
|
12321
|
-
}, "Failed to push cursor state");
|
|
12344
|
+
/** Called with every other session's presence blob. A blob that arrives
|
|
12345
|
+
* before the editor sets this is replayed when it does. */
|
|
12346
|
+
set onCursorUpdate(cb) {
|
|
12347
|
+
this._onCursorUpdate = cb;
|
|
12348
|
+
if (cb && this.pendingCursorUpdate) {
|
|
12349
|
+
const data = this.pendingCursorUpdate;
|
|
12350
|
+
this.pendingCursorUpdate = null;
|
|
12351
|
+
try {
|
|
12352
|
+
cb(data);
|
|
12353
|
+
} catch {}
|
|
12322
12354
|
}
|
|
12323
12355
|
}
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
if (Date.now() - this.lastCursorPushTime < 300) return;
|
|
12327
|
-
try {
|
|
12328
|
-
const data = decodeBase64(base64State);
|
|
12329
|
-
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
12330
|
-
else this.pendingCursorUpdate = data;
|
|
12331
|
-
} catch (e) {
|
|
12332
|
-
this.logger?.warn({
|
|
12333
|
-
error: e,
|
|
12334
|
-
Category: "sp00ky-client::CrdtField::importRemoteCursor"
|
|
12335
|
-
}, "Failed to apply remote cursor data");
|
|
12336
|
-
}
|
|
12337
|
-
}
|
|
12338
|
-
scheduleRemotePush() {
|
|
12339
|
-
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
12340
|
-
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
12341
|
-
}
|
|
12342
|
-
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
12343
|
-
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
12344
|
-
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
12345
|
-
* `<field>.state` next to per-session cursor blobs. */
|
|
12346
|
-
statePath() {
|
|
12347
|
-
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
12348
|
-
}
|
|
12349
|
-
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
12350
|
-
* every local update and every remote import so reloads (online or
|
|
12351
|
-
* offline) see the freshest content immediately. Failures are
|
|
12352
|
-
* swallowed — a stale local write must never block user input. */
|
|
12353
|
-
async persistLocal() {
|
|
12354
|
-
if (!this.local || !this.recordId) return;
|
|
12355
|
-
try {
|
|
12356
|
-
await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
12357
|
-
id: parseRecordIdString(this.recordId),
|
|
12358
|
-
state: this.exportSnapshot()
|
|
12359
|
-
});
|
|
12360
|
-
} catch (e) {
|
|
12361
|
-
this.logger?.debug({
|
|
12362
|
-
error: e,
|
|
12363
|
-
Category: "sp00ky-client::CrdtField::persistLocal"
|
|
12364
|
-
}, "Local CRDT persist failed (best-effort)");
|
|
12365
|
-
}
|
|
12356
|
+
get onCursorUpdate() {
|
|
12357
|
+
return this._onCursorUpdate;
|
|
12366
12358
|
}
|
|
12367
|
-
|
|
12368
|
-
|
|
12369
|
-
this.
|
|
12370
|
-
|
|
12371
|
-
await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
12372
|
-
id: parseRecordIdString(this.recordId),
|
|
12373
|
-
state: this.exportSnapshot()
|
|
12374
|
-
});
|
|
12375
|
-
this.pushRetryCount = 0;
|
|
12376
|
-
} catch (e) {
|
|
12377
|
-
this.logger?.warn({
|
|
12378
|
-
error: e,
|
|
12379
|
-
Category: "sp00ky-client::CrdtField::pushToRemote"
|
|
12380
|
-
}, "Failed to push CRDT state to remote");
|
|
12381
|
-
if (this.pushRetryCount < 2) {
|
|
12382
|
-
this.pushRetryCount++;
|
|
12383
|
-
this.scheduleRemotePush();
|
|
12384
|
-
}
|
|
12385
|
-
}
|
|
12359
|
+
/** @internal Deliver one remote presence blob. */
|
|
12360
|
+
deliverPresence(data) {
|
|
12361
|
+
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
12362
|
+
else this.pendingCursorUpdate = data;
|
|
12386
12363
|
}
|
|
12387
12364
|
};
|
|
12388
|
-
|
|
12389
|
-
|
|
12390
|
-
|
|
12391
|
-
|
|
12392
|
-
|
|
12393
|
-
|
|
12394
|
-
|
|
12395
|
-
|
|
12365
|
+
|
|
12366
|
+
//#endregion
|
|
12367
|
+
//#region src/modules/crdt/hash.ts
|
|
12368
|
+
/**
|
|
12369
|
+
* Canonical state hash of a document: the same function as the server fold's
|
|
12370
|
+
* (`packages/ssp-node/src/crdt/hash.rs`), checked against the shared vectors in
|
|
12371
|
+
* `packages/ssp-node/tests/fixtures/crdt_state_hash.json`.
|
|
12372
|
+
*
|
|
12373
|
+
* stateHash(doc) = hex(SHA-256(utf8(JCS(roots(doc)))))
|
|
12374
|
+
*
|
|
12375
|
+
* JCS is RFC 8785 (keys in UTF-16 order, `JSON.stringify` strings,
|
|
12376
|
+
* `Number#toString` numbers). Loro values map as: integers to numbers,
|
|
12377
|
+
* NaN/Infinity to `{"$f64": ...}`, bytes to `{"$bytes": "<base64>"}`, a text
|
|
12378
|
+
* to its string (marks are not hashed). Root keys holding `""`, `{}`, `[]` or
|
|
12379
|
+
* `0` are left out: touching a root creates it, but a document built from
|
|
12380
|
+
* update blobs alone does not have it.
|
|
12381
|
+
*/
|
|
12382
|
+
async function stateHash(loro, doc) {
|
|
12383
|
+
const bytes = new TextEncoder().encode(canonicalState(loro, doc));
|
|
12384
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
12385
|
+
return Array.from(digest, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
12386
|
+
}
|
|
12387
|
+
/** The canonical JSON {@link stateHash} is taken over. */
|
|
12388
|
+
function canonicalState(loro, doc) {
|
|
12389
|
+
const roots = Object.create(null);
|
|
12390
|
+
for (const [name, id] of Object.entries(doc.getShallowValue())) {
|
|
12391
|
+
const value = walk(loro, doc.getContainerById(id));
|
|
12392
|
+
if (!isEmptyRoot(value)) roots[name] = value;
|
|
12393
|
+
}
|
|
12394
|
+
return canonicalize(roots);
|
|
12395
|
+
}
|
|
12396
|
+
function walk(loro, v) {
|
|
12397
|
+
if (!loro.isContainer(v)) return v;
|
|
12398
|
+
const c = v;
|
|
12399
|
+
switch (c.kind()) {
|
|
12400
|
+
case "Map": {
|
|
12401
|
+
const map = c;
|
|
12402
|
+
const out = Object.create(null);
|
|
12403
|
+
for (const k of map.keys()) out[k] = walk(loro, map.get(k));
|
|
12404
|
+
return out;
|
|
12405
|
+
}
|
|
12406
|
+
case "List":
|
|
12407
|
+
case "MovableList": return c.toArray().map((item) => walk(loro, item));
|
|
12408
|
+
case "Text": return c.toString();
|
|
12409
|
+
case "Counter": return c.value;
|
|
12410
|
+
case "Tree": return c.roots().map((n) => walkTreeNode(loro, n));
|
|
12411
|
+
default: throw new Error(`unsupported Loro container ${c.kind()}`);
|
|
12412
|
+
}
|
|
12413
|
+
}
|
|
12414
|
+
function walkTreeNode(loro, n) {
|
|
12415
|
+
const out = Object.create(null);
|
|
12416
|
+
out.parent = n.parent()?.id ?? null;
|
|
12417
|
+
out.index = n.index();
|
|
12418
|
+
out.meta = walk(loro, n.data);
|
|
12419
|
+
out.id = n.id;
|
|
12420
|
+
out.fractional_index = n.fractionalIndex();
|
|
12421
|
+
out.children = (n.children() ?? []).map((child) => walkTreeNode(loro, child));
|
|
12422
|
+
return out;
|
|
12396
12423
|
}
|
|
12397
|
-
function
|
|
12398
|
-
if (
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12424
|
+
function isEmptyRoot(v) {
|
|
12425
|
+
if (v === "" || v === 0) return true;
|
|
12426
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
12427
|
+
if (v && typeof v === "object" && !(v instanceof Uint8Array)) return Object.keys(v).length === 0;
|
|
12428
|
+
return false;
|
|
12429
|
+
}
|
|
12430
|
+
function canonicalize(v) {
|
|
12431
|
+
if (v === null || v === void 0) return "null";
|
|
12432
|
+
switch (typeof v) {
|
|
12433
|
+
case "boolean": return v ? "true" : "false";
|
|
12434
|
+
case "number":
|
|
12435
|
+
if (!Number.isFinite(v)) return canonicalize({ $f64: String(v) });
|
|
12436
|
+
return Object.is(v, -0) ? "0" : String(v);
|
|
12437
|
+
case "string": return JSON.stringify(v);
|
|
12438
|
+
case "bigint": return canonicalize(Number(v));
|
|
12439
|
+
case "object": {
|
|
12440
|
+
if (v instanceof Uint8Array) return canonicalize({ $bytes: encodeBase64(v) });
|
|
12441
|
+
if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]";
|
|
12442
|
+
const obj = v;
|
|
12443
|
+
return "{" + Object.keys(obj).sort().map((k) => JSON.stringify(k) + ":" + canonicalize(obj[k])).join(",") + "}";
|
|
12444
|
+
}
|
|
12445
|
+
default: throw new Error(`unsupported value type ${typeof v}`);
|
|
12402
12446
|
}
|
|
12403
|
-
return Buffer.from(bytes).toString("base64");
|
|
12404
12447
|
}
|
|
12405
12448
|
|
|
12406
12449
|
//#endregion
|
|
@@ -12418,319 +12461,559 @@ function loadLoro() {
|
|
|
12418
12461
|
|
|
12419
12462
|
//#endregion
|
|
12420
12463
|
//#region src/modules/crdt/index.ts
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
12428
|
-
|
|
12429
|
-
|
|
12430
|
-
*
|
|
12431
|
-
|
|
12432
|
-
|
|
12433
|
-
|
|
12434
|
-
* Cross-browser delivery still rides the parent table's existing LIVE feed
|
|
12435
|
-
* to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
|
|
12436
|
-
* (issues 3602, 4026). On every meta UPSERT the writer also bumps the
|
|
12437
|
-
* parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
|
|
12438
|
-
* feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
|
|
12439
|
-
* via subquery. Permission inheritance happens server-side via
|
|
12440
|
-
* `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
|
|
12441
|
-
*/
|
|
12464
|
+
const LOCAL_TABLE = "_00_crdt_local";
|
|
12465
|
+
const LOG_SUFFIX = "__crdt_log";
|
|
12466
|
+
const PRESENCE_SUFFIX = "__crdt_presence";
|
|
12467
|
+
/** Longest a burst of typing waits before its edits go out. */
|
|
12468
|
+
const MAX_WAIT_MS = 2e3;
|
|
12469
|
+
const PERSIST_DEBOUNCE_MS = 400;
|
|
12470
|
+
const PRESENCE_THROTTLE_MS = 250;
|
|
12471
|
+
const CLAIM_RETRY_MS = 5e3;
|
|
12472
|
+
/** How long an open of a field with nothing local waits for the server's
|
|
12473
|
+
* first answer before handing out the (still empty) document. */
|
|
12474
|
+
const FIRST_ANSWER_TIMEOUT_MS = 4e3;
|
|
12475
|
+
/** Pause after a fold is noticed before checking the published document. */
|
|
12476
|
+
const VERIFY_DELAY_MS = 1e3;
|
|
12442
12477
|
var CrdtManager = class {
|
|
12443
|
-
|
|
12444
|
-
|
|
12445
|
-
|
|
12446
|
-
staleTables = /* @__PURE__ */ new Set();
|
|
12447
|
-
connectionGeneration = 0;
|
|
12448
|
-
connectionUnsubscribes = [];
|
|
12478
|
+
sessions = /* @__PURE__ */ new Map();
|
|
12479
|
+
opening = /* @__PURE__ */ new Map();
|
|
12480
|
+
ports = null;
|
|
12449
12481
|
logger;
|
|
12450
|
-
|
|
12451
|
-
|
|
12482
|
+
peer = randomPeer();
|
|
12483
|
+
/** The current peer's claim: settled once the database holds it for us. */
|
|
12484
|
+
claim = null;
|
|
12485
|
+
claimRetry = null;
|
|
12486
|
+
connectionUnsubscribes = [];
|
|
12487
|
+
constructor(schema, local, remote, logger, debounceMs = 300) {
|
|
12452
12488
|
this.schema = schema;
|
|
12453
12489
|
this.local = local;
|
|
12454
12490
|
this.remote = remote;
|
|
12455
12491
|
this.debounceMs = debounceMs;
|
|
12456
12492
|
this.logger = logger.child({ service: "CrdtManager" });
|
|
12457
|
-
this.
|
|
12493
|
+
this.connectionUnsubscribes.push(this.remote.subscribeConnection("connected", () => {
|
|
12494
|
+
for (const s of this.sessions.values()) this.scheduleFlush(s, 0);
|
|
12495
|
+
}));
|
|
12496
|
+
}
|
|
12497
|
+
/** Hand over the runtime ports. Called once by the client facade. */
|
|
12498
|
+
attach(ports) {
|
|
12499
|
+
this.ports = ports;
|
|
12458
12500
|
}
|
|
12459
12501
|
/**
|
|
12460
|
-
*
|
|
12461
|
-
*
|
|
12462
|
-
*
|
|
12463
|
-
* `ensureTableSubscription` is memoized on `liveByTable` — so without this,
|
|
12464
|
-
* the first reconnect leaves CRDT realtime permanently dead: the map still
|
|
12465
|
-
* holds a uuid for a subscription the server has forgotten, so every later
|
|
12466
|
-
* `open()` short-circuits and no LIVE is ever re-issued.
|
|
12467
|
-
*
|
|
12468
|
-
* Both drop events matter: the SDK publishes `reconnecting` (not
|
|
12469
|
-
* `disconnected`) when it intends to recover on its own, and `disconnected`
|
|
12470
|
-
* only once it has given up.
|
|
12502
|
+
* A new signed-in identity (boot, sign-in, sign-out): edits from here on
|
|
12503
|
+
* belong to it, under a peer it claims. Edits already made stay with the
|
|
12504
|
+
* peer they were made under.
|
|
12471
12505
|
*/
|
|
12472
|
-
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
for (const table of this.pendingLive.keys()) this.staleTables.add(table);
|
|
12477
|
-
this.liveByTable.clear();
|
|
12478
|
-
};
|
|
12479
|
-
this.connectionUnsubscribes.push(this.remote.subscribeConnection("reconnecting", onDrop), this.remote.subscribeConnection("disconnected", onDrop), this.remote.subscribeConnection("connected", () => {
|
|
12480
|
-
const tables = Array.from(this.staleTables);
|
|
12481
|
-
this.staleTables.clear();
|
|
12482
|
-
for (const table of tables) {
|
|
12483
|
-
if (!this.hasOpenFieldFor(table)) continue;
|
|
12484
|
-
this.ensureTableSubscription(table);
|
|
12485
|
-
}
|
|
12486
|
-
}));
|
|
12506
|
+
setSessionId(_salt) {
|
|
12507
|
+
this.peer = randomPeer();
|
|
12508
|
+
this.claim = null;
|
|
12509
|
+
for (const s of this.sessions.values()) s.doc.setPeerId(this.peer);
|
|
12487
12510
|
}
|
|
12488
|
-
/** Stop observing transport events. Separate from {@link closeAll}, which also
|
|
12489
|
-
* runs on a bucket switch where the manager keeps being used. */
|
|
12490
12511
|
dispose() {
|
|
12491
|
-
for (const off of this.connectionUnsubscribes) try {
|
|
12512
|
+
for (const off of this.connectionUnsubscribes.splice(0)) try {
|
|
12492
12513
|
off();
|
|
12493
12514
|
} catch {}
|
|
12494
|
-
this.
|
|
12495
|
-
this.staleTables.clear();
|
|
12496
|
-
}
|
|
12497
|
-
hasOpenFieldFor(table) {
|
|
12498
|
-
const prefix = `${table}:`;
|
|
12499
|
-
return Array.from(this.fields.keys()).some((k) => k.startsWith(prefix));
|
|
12500
|
-
}
|
|
12501
|
-
/** Set the session id that scopes this client's cursor entries. Must be
|
|
12502
|
-
* called before `open()` for cursors to be pushed under a stable key.
|
|
12503
|
-
* Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
|
|
12504
|
-
* for the data-module salt). */
|
|
12505
|
-
setSessionId(sessionId) {
|
|
12506
|
-
this.sessionId = sessionId;
|
|
12515
|
+
if (this.claimRetry) clearTimeout(this.claimRetry);
|
|
12507
12516
|
}
|
|
12508
12517
|
/**
|
|
12509
|
-
* Open a
|
|
12518
|
+
* Open a collaborative field. Every open of the same field shares one
|
|
12519
|
+
* document; close each open with {@link close}.
|
|
12510
12520
|
*
|
|
12511
|
-
* @
|
|
12512
|
-
*
|
|
12513
|
-
* @param field - Field name (e.g., "title", "content")
|
|
12514
|
-
* @param fallbackText - Current plain text from the record, used to seed the
|
|
12515
|
-
* LoroDoc if no CRDT state exists yet (migration path)
|
|
12521
|
+
* `draft` (from {@link createDraft}) hands over edits made before the record
|
|
12522
|
+
* existed: they are merged in and sent like this tab's own.
|
|
12516
12523
|
*/
|
|
12517
|
-
async open(table, recordId, field,
|
|
12518
|
-
this.
|
|
12519
|
-
const
|
|
12520
|
-
|
|
12521
|
-
|
|
12522
|
-
|
|
12523
|
-
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
12531
|
-
|
|
12532
|
-
|
|
12533
|
-
|
|
12534
|
-
|
|
12535
|
-
|
|
12536
|
-
|
|
12537
|
-
|
|
12538
|
-
|
|
12539
|
-
|
|
12540
|
-
|
|
12524
|
+
async open(table, recordId, field, draft) {
|
|
12525
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
12526
|
+
const key = `${table}|${recordId}|${field}`;
|
|
12527
|
+
let session = this.sessions.get(key);
|
|
12528
|
+
if (session) session.refs++;
|
|
12529
|
+
else {
|
|
12530
|
+
const inFlight = this.opening.get(key);
|
|
12531
|
+
if (inFlight) {
|
|
12532
|
+
session = await inFlight;
|
|
12533
|
+
session.refs++;
|
|
12534
|
+
} else {
|
|
12535
|
+
const pending = this.start(key, table, recordId, field, kind, cursor).finally(() => this.opening.delete(key));
|
|
12536
|
+
this.opening.set(key, pending);
|
|
12537
|
+
session = await pending;
|
|
12538
|
+
}
|
|
12539
|
+
}
|
|
12540
|
+
if (draft) this.adopt(session, draft.getDoc());
|
|
12541
|
+
return session.handle;
|
|
12542
|
+
}
|
|
12543
|
+
/**
|
|
12544
|
+
* A detached document for a field of a record that does not exist yet (a
|
|
12545
|
+
* "new thread" form). Nothing is sent; pass it to {@link open} once the
|
|
12546
|
+
* record is created and its edits become the record's first ones.
|
|
12547
|
+
*/
|
|
12548
|
+
async createDraft(table, field) {
|
|
12549
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
12550
|
+
return new CrdtField(table, "", field, kind, cursor, new (await (loadLoro())).LoroDoc(), { pushPresence: () => {} });
|
|
12551
|
+
}
|
|
12552
|
+
adopt(s, draft) {
|
|
12553
|
+
const peers = [...draft.oplogVersion().toJSON().keys()];
|
|
12554
|
+
if (peers.length === 0) return;
|
|
12555
|
+
this.tryImport(s.doc, draft.export({ mode: "update" }), s.key);
|
|
12556
|
+
for (const peer of peers) s.local.peers[String(peer)] ??= { enqueued: 0 };
|
|
12557
|
+
this.schedulePersist(s);
|
|
12558
|
+
this.scheduleFlush(s, 0);
|
|
12559
|
+
}
|
|
12560
|
+
/** Close one open. The last close of a field resolves once its unsent
|
|
12561
|
+
* edits are in the outbox (or are left for a later session, offline). */
|
|
12562
|
+
close(table, recordId, field) {
|
|
12563
|
+
const s = this.sessions.get(`${table}|${recordId}|${field}`);
|
|
12564
|
+
if (!s || --s.refs > 0) return Promise.resolve();
|
|
12565
|
+
return this.stop(s, true);
|
|
12566
|
+
}
|
|
12567
|
+
/** Close every field. `flush: false` (a bucket switch) drops unsent edits
|
|
12568
|
+
* from this session's memory; they stay in the local snapshot of the
|
|
12569
|
+
* bucket they belong to. */
|
|
12570
|
+
closeAll(options = {}) {
|
|
12571
|
+
for (const s of [...this.sessions.values()]) this.stop(s, options.flush ?? true);
|
|
12572
|
+
}
|
|
12573
|
+
fieldSpec(table, field) {
|
|
12574
|
+
const t = this.schema.tables.find((x) => x.name === table);
|
|
12575
|
+
if (!t) throw new Error(`CrdtManager: unknown table '${table}'`);
|
|
12576
|
+
const col = t.columns[field];
|
|
12577
|
+
if (!col) throw new Error(`CrdtManager: unknown field '${table}.${field}'`);
|
|
12578
|
+
if (!col.crdt) throw new Error(`CrdtManager: '${table}.${field}' is not a CRDT field. Add \`-- @crdt text\` or \`-- @crdt prosemirror\` above its DEFINE FIELD and re-run \`spky generate\`.`);
|
|
12579
|
+
return {
|
|
12580
|
+
kind: col.crdt === "prosemirror" ? "prosemirror" : "text",
|
|
12581
|
+
cursor: !!col.cursor
|
|
12582
|
+
};
|
|
12583
|
+
}
|
|
12584
|
+
async start(key, table, recordId, field, kind, cursor) {
|
|
12585
|
+
if (!this.ports) throw new Error("CrdtManager: not attached to a client");
|
|
12586
|
+
const doc = new (await (loadLoro())).LoroDoc();
|
|
12587
|
+
doc.setPeerId(this.peer);
|
|
12588
|
+
const local = await this.readLocal(recordId, field);
|
|
12589
|
+
if (local.snapshot) this.tryImport(doc, local.snapshot, key);
|
|
12590
|
+
const session = {
|
|
12591
|
+
key,
|
|
12592
|
+
table,
|
|
12593
|
+
recordId,
|
|
12594
|
+
field,
|
|
12595
|
+
kind,
|
|
12596
|
+
cursor,
|
|
12597
|
+
doc,
|
|
12598
|
+
handle: void 0,
|
|
12599
|
+
refs: 1,
|
|
12600
|
+
local,
|
|
12601
|
+
seen: /* @__PURE__ */ new Set(),
|
|
12602
|
+
present: /* @__PURE__ */ new Set(),
|
|
12603
|
+
verifiedRev: 0,
|
|
12604
|
+
verifyTimer: null,
|
|
12605
|
+
disposers: [],
|
|
12606
|
+
flushTimer: null,
|
|
12607
|
+
firstDirtyAt: 0,
|
|
12608
|
+
persistTimer: null,
|
|
12609
|
+
flushing: null,
|
|
12610
|
+
refetching: false,
|
|
12611
|
+
presenceTimer: null,
|
|
12612
|
+
presencePending: null,
|
|
12613
|
+
presenceSent: false,
|
|
12614
|
+
ingesting: Promise.resolve()
|
|
12615
|
+
};
|
|
12616
|
+
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, { pushPresence: (state) => this.pushPresence(session, state) });
|
|
12617
|
+
this.sessions.set(key, session);
|
|
12618
|
+
this.ensureClaimed();
|
|
12619
|
+
if (!local.snapshot) await this.refetchCanonical(session);
|
|
12620
|
+
session.disposers.push(doc.subscribeLocalUpdates(() => {
|
|
12621
|
+
session.local.peers[this.peer] ??= { enqueued: 0 };
|
|
12622
|
+
this.scheduleFlush(session);
|
|
12623
|
+
this.schedulePersist(session);
|
|
12624
|
+
}));
|
|
12625
|
+
const ports = this.ports;
|
|
12626
|
+
const log = await ports.watch(`${table}${LOG_SUFFIX}`, {
|
|
12627
|
+
doc: recordId,
|
|
12628
|
+
field
|
|
12629
|
+
}, (rows) => {
|
|
12630
|
+
session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
|
|
12631
|
+
});
|
|
12632
|
+
session.disposers.push(log.stop);
|
|
12633
|
+
if (cursor) {
|
|
12634
|
+
const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, {
|
|
12635
|
+
doc: recordId,
|
|
12636
|
+
field
|
|
12637
|
+
}, (rows) => this.ingestPresence(session, rows));
|
|
12638
|
+
session.disposers.push(presence.stop);
|
|
12639
|
+
}
|
|
12640
|
+
if (!session.handle.hasContent()) await Promise.race([log.settled, delay(FIRST_ANSWER_TIMEOUT_MS)]);
|
|
12641
|
+
await session.ingesting;
|
|
12642
|
+
this.scheduleFlush(session, 0);
|
|
12643
|
+
this.logger.debug({
|
|
12541
12644
|
key,
|
|
12542
|
-
|
|
12543
|
-
hasFallback: !!fallbackText,
|
|
12645
|
+
peer: this.peer,
|
|
12544
12646
|
Category: "sp00ky-client::CrdtManager::open"
|
|
12545
|
-
}, "
|
|
12546
|
-
|
|
12547
|
-
if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
|
|
12548
|
-
return crdtField;
|
|
12647
|
+
}, "CRDT field opened");
|
|
12648
|
+
return session;
|
|
12549
12649
|
}
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
const
|
|
12553
|
-
|
|
12554
|
-
|
|
12555
|
-
|
|
12650
|
+
async stop(s, flush) {
|
|
12651
|
+
this.sessions.delete(s.key);
|
|
12652
|
+
for (const off of s.disposers.splice(0)) try {
|
|
12653
|
+
off();
|
|
12654
|
+
} catch {}
|
|
12655
|
+
if (s.flushTimer) clearTimeout(s.flushTimer);
|
|
12656
|
+
if (s.persistTimer) clearTimeout(s.persistTimer);
|
|
12657
|
+
if (s.presenceTimer) clearTimeout(s.presenceTimer);
|
|
12658
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
12659
|
+
if (s.presenceSent) this.dropPresence(s);
|
|
12660
|
+
if (flush) {
|
|
12661
|
+
await this.persist(s);
|
|
12662
|
+
await this.flush(s);
|
|
12663
|
+
}
|
|
12664
|
+
}
|
|
12665
|
+
async ingest(s, rows) {
|
|
12666
|
+
const loro = await loadLoro();
|
|
12667
|
+
const ids = new Set(rows.map((row) => String(row.id ?? "")));
|
|
12668
|
+
for (const id of s.present) if (!ids.has(id)) {
|
|
12669
|
+
this.scheduleVerify(s);
|
|
12670
|
+
break;
|
|
12671
|
+
}
|
|
12672
|
+
s.present = ids;
|
|
12673
|
+
const blobs = [];
|
|
12674
|
+
for (const row of rows) {
|
|
12675
|
+
const id = String(row.id ?? "");
|
|
12676
|
+
if (!id || s.seen.has(id)) continue;
|
|
12677
|
+
s.seen.add(id);
|
|
12678
|
+
const blob = asBytes(row.data);
|
|
12679
|
+
const verdict = check(loro, blob, row.peer == null ? void 0 : String(row.peer));
|
|
12680
|
+
if (!verdict.ok) {
|
|
12681
|
+
this.logger.debug({
|
|
12682
|
+
row: id,
|
|
12683
|
+
reason: verdict.reason,
|
|
12684
|
+
Category: "sp00ky-client::CrdtManager::ingest"
|
|
12685
|
+
}, "CRDT log row refused");
|
|
12686
|
+
continue;
|
|
12687
|
+
}
|
|
12688
|
+
blobs.push(blob);
|
|
12556
12689
|
}
|
|
12557
|
-
if (
|
|
12558
|
-
|
|
12559
|
-
|
|
12690
|
+
if (blobs.length === 0) return;
|
|
12691
|
+
let pending = false;
|
|
12692
|
+
for (const blob of blobs) if (this.tryImport(s.doc, blob, s.key)?.pending) pending = true;
|
|
12693
|
+
this.schedulePersist(s);
|
|
12694
|
+
if (pending) this.refetchCanonical(s);
|
|
12695
|
+
}
|
|
12696
|
+
tryImport(doc, blob, key) {
|
|
12697
|
+
try {
|
|
12698
|
+
const status = doc.import(blob);
|
|
12699
|
+
return { pending: !!status.pending && status.pending.size > 0 };
|
|
12700
|
+
} catch (e) {
|
|
12701
|
+
this.logger.warn({
|
|
12702
|
+
key,
|
|
12703
|
+
error: String(e),
|
|
12704
|
+
Category: "sp00ky-client::CrdtManager::import"
|
|
12705
|
+
}, "CRDT import failed");
|
|
12706
|
+
return null;
|
|
12560
12707
|
}
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
|
|
12564
|
-
|
|
12708
|
+
}
|
|
12709
|
+
async refetchCanonical(s) {
|
|
12710
|
+
if (s.refetching) return;
|
|
12711
|
+
s.refetching = true;
|
|
12712
|
+
try {
|
|
12713
|
+
const [row] = await this.remote.query("SELECT rev, snapshot FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])", {
|
|
12714
|
+
doc: s.recordId,
|
|
12715
|
+
field: s.field
|
|
12716
|
+
});
|
|
12717
|
+
const snapshot = asBytes(row?.snapshot);
|
|
12718
|
+
if (snapshot) {
|
|
12719
|
+
this.tryImport(s.doc, snapshot, s.key);
|
|
12720
|
+
s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
|
|
12721
|
+
this.schedulePersist(s);
|
|
12722
|
+
}
|
|
12723
|
+
} catch (e) {
|
|
12724
|
+
this.logger.debug({
|
|
12725
|
+
key: s.key,
|
|
12726
|
+
error: String(e),
|
|
12727
|
+
Category: "sp00ky-client::CrdtManager::refetch"
|
|
12728
|
+
}, "No canonical CRDT document fetched");
|
|
12729
|
+
} finally {
|
|
12730
|
+
s.refetching = false;
|
|
12731
|
+
}
|
|
12732
|
+
}
|
|
12733
|
+
scheduleVerify(s) {
|
|
12734
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
12735
|
+
s.verifyTimer = setTimeout(() => {
|
|
12736
|
+
s.verifyTimer = null;
|
|
12737
|
+
this.verify(s);
|
|
12738
|
+
}, VERIFY_DELAY_MS);
|
|
12565
12739
|
}
|
|
12566
12740
|
/**
|
|
12567
|
-
*
|
|
12568
|
-
*
|
|
12569
|
-
*
|
|
12741
|
+
* After a fold: compare this device's document with the one the server
|
|
12742
|
+
* published, at the version the server published. Missing ops (rows this
|
|
12743
|
+
* device never got before they were folded away) are repaired by merging
|
|
12744
|
+
* the published snapshot in. The same ops giving a different state cannot
|
|
12745
|
+
* be merged away; that is logged.
|
|
12570
12746
|
*/
|
|
12571
|
-
|
|
12572
|
-
|
|
12573
|
-
|
|
12574
|
-
|
|
12575
|
-
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
const
|
|
12582
|
-
const
|
|
12583
|
-
if (
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
|
|
12747
|
+
async verify(s) {
|
|
12748
|
+
let row;
|
|
12749
|
+
try {
|
|
12750
|
+
[row] = await this.remote.query("SELECT rev, upto, state_hash FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])", {
|
|
12751
|
+
doc: s.recordId,
|
|
12752
|
+
field: s.field
|
|
12753
|
+
});
|
|
12754
|
+
} catch {
|
|
12755
|
+
return;
|
|
12756
|
+
}
|
|
12757
|
+
const rev = row?.rev ?? 0;
|
|
12758
|
+
const frontiers = row?.upto?.frontiers;
|
|
12759
|
+
if (!row?.state_hash || !frontiers || !row.upto?.vv || rev <= s.verifiedRev || !this.sessions.has(s.key)) return;
|
|
12760
|
+
const have = s.doc.oplogVersion().toJSON();
|
|
12761
|
+
if (Object.entries(row.upto.vv).some(([peer, end]) => (have.get(peer) ?? 0) < end)) {
|
|
12762
|
+
this.logger.info({
|
|
12763
|
+
key: s.key,
|
|
12764
|
+
rev,
|
|
12765
|
+
Category: "sp00ky-client::CrdtManager::verify"
|
|
12766
|
+
}, "CRDT document behind the published one; merging it in");
|
|
12767
|
+
await this.refetchCanonical(s);
|
|
12768
|
+
return;
|
|
12769
|
+
}
|
|
12770
|
+
s.verifiedRev = rev;
|
|
12771
|
+
if (!globalThis.crypto?.subtle) return;
|
|
12772
|
+
const loro = await loadLoro();
|
|
12773
|
+
let fork;
|
|
12774
|
+
try {
|
|
12775
|
+
fork = s.doc.forkAt(frontiers);
|
|
12776
|
+
const hash = await stateHash(loro, fork);
|
|
12777
|
+
if (hash !== row.state_hash) this.logger.warn({
|
|
12778
|
+
key: s.key,
|
|
12779
|
+
rev,
|
|
12780
|
+
hash,
|
|
12781
|
+
published: row.state_hash,
|
|
12782
|
+
Category: "sp00ky-client::CrdtManager::verify"
|
|
12783
|
+
}, "CRDT document differs from the published one at the same version");
|
|
12784
|
+
} catch (e) {
|
|
12785
|
+
this.logger.debug({
|
|
12786
|
+
key: s.key,
|
|
12787
|
+
error: String(e),
|
|
12788
|
+
Category: "sp00ky-client::CrdtManager::verify"
|
|
12789
|
+
}, "CRDT document check failed");
|
|
12790
|
+
} finally {
|
|
12791
|
+
fork?.free();
|
|
12792
|
+
}
|
|
12793
|
+
}
|
|
12794
|
+
scheduleFlush(s, delay = this.debounceMs) {
|
|
12795
|
+
const now = Date.now();
|
|
12796
|
+
if (!s.flushTimer) s.firstDirtyAt = now;
|
|
12797
|
+
else clearTimeout(s.flushTimer);
|
|
12798
|
+
const wait = Math.max(0, Math.min(delay, s.firstDirtyAt + MAX_WAIT_MS - now));
|
|
12799
|
+
s.flushTimer = setTimeout(() => {
|
|
12800
|
+
s.flushTimer = null;
|
|
12801
|
+
this.flush(s);
|
|
12802
|
+
}, wait);
|
|
12803
|
+
}
|
|
12804
|
+
async flush(s) {
|
|
12805
|
+
if (s.flushing) return s.flushing;
|
|
12806
|
+
s.flushing = this.sendOwnEdits(s).finally(() => {
|
|
12807
|
+
s.flushing = null;
|
|
12808
|
+
});
|
|
12809
|
+
return s.flushing;
|
|
12810
|
+
}
|
|
12811
|
+
/** Send every own peer's ops that are not in the outbox yet, one row per
|
|
12812
|
+
* peer. Only the peer's own ops travel (`updates-in-range`): a plain
|
|
12813
|
+
* `from: version` export would carry ops imported from others too, and
|
|
12814
|
+
* the acceptance rule refuses such a row. */
|
|
12815
|
+
async sendOwnEdits(s) {
|
|
12816
|
+
const vv = s.doc.oplogVersion();
|
|
12817
|
+
const due = [...new Set([...Object.keys(s.local.peers), this.peer])].map((peer) => ({
|
|
12818
|
+
peer,
|
|
12819
|
+
end: vv.get(peer) ?? 0,
|
|
12820
|
+
from: s.local.peers[peer]?.enqueued ?? 0
|
|
12821
|
+
})).filter((p) => p.end > p.from);
|
|
12822
|
+
if (due.length === 0 || !this.ports) return;
|
|
12823
|
+
if (!await this.ensureClaimed()) {
|
|
12824
|
+
this.retryLater(s);
|
|
12825
|
+
return;
|
|
12826
|
+
}
|
|
12827
|
+
for (const { peer, end, from } of due) {
|
|
12828
|
+
if (peer !== this.peer && !await this.claimPeer(peer)) continue;
|
|
12829
|
+
const data = s.doc.export({
|
|
12830
|
+
mode: "updates-in-range",
|
|
12831
|
+
spans: [{
|
|
12832
|
+
id: {
|
|
12833
|
+
peer,
|
|
12834
|
+
counter: from
|
|
12835
|
+
},
|
|
12836
|
+
len: end - from
|
|
12837
|
+
}]
|
|
12838
|
+
});
|
|
12592
12839
|
try {
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
}, "Socket dropped while registering LIVE; discarding it");
|
|
12599
|
-
return;
|
|
12600
|
-
}
|
|
12601
|
-
(await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
|
|
12602
|
-
if (message.action === "KILLED") return;
|
|
12603
|
-
if (message.action !== "CREATE" && message.action !== "UPDATE") return;
|
|
12604
|
-
this.dispatchRow(table, message.value);
|
|
12840
|
+
await this.ports.create(`${s.table}${LOG_SUFFIX}:${rowKey()}`, {
|
|
12841
|
+
doc: s.recordId,
|
|
12842
|
+
field: s.field,
|
|
12843
|
+
peer,
|
|
12844
|
+
data
|
|
12605
12845
|
});
|
|
12606
|
-
if (generation !== this.connectionGeneration) return;
|
|
12607
|
-
this.liveByTable.set(table, uuid);
|
|
12608
|
-
this.logger.info({
|
|
12609
|
-
table,
|
|
12610
|
-
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
12611
|
-
}, "LIVE SELECT started");
|
|
12612
12846
|
} catch (e) {
|
|
12613
12847
|
this.logger.warn({
|
|
12614
|
-
|
|
12615
|
-
|
|
12616
|
-
Category: "sp00ky-client::CrdtManager::
|
|
12617
|
-
}, "
|
|
12848
|
+
key: s.key,
|
|
12849
|
+
error: String(e),
|
|
12850
|
+
Category: "sp00ky-client::CrdtManager::flush"
|
|
12851
|
+
}, "Queueing CRDT edits failed");
|
|
12852
|
+
this.retryLater(s);
|
|
12853
|
+
return;
|
|
12618
12854
|
}
|
|
12619
|
-
|
|
12620
|
-
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
|
|
12855
|
+
s.local.peers[peer] = { enqueued: end };
|
|
12856
|
+
}
|
|
12857
|
+
await this.persist(s);
|
|
12858
|
+
}
|
|
12859
|
+
retryLater(s) {
|
|
12860
|
+
if (this.sessions.get(s.key) === s) this.scheduleFlush(s, CLAIM_RETRY_MS);
|
|
12861
|
+
}
|
|
12862
|
+
/** Claim this session's peer once; a failure (offline, signed out) is retried. */
|
|
12863
|
+
ensureClaimed() {
|
|
12864
|
+
if (!this.claim) {
|
|
12865
|
+
const peer = this.peer;
|
|
12866
|
+
this.claim = this.claimPeer(peer).then((ok) => {
|
|
12867
|
+
if (!ok && this.peer === peer) this.claim = null;
|
|
12868
|
+
return ok;
|
|
12869
|
+
});
|
|
12870
|
+
}
|
|
12871
|
+
return this.claim;
|
|
12872
|
+
}
|
|
12873
|
+
async claimPeer(peer) {
|
|
12624
12874
|
try {
|
|
12625
|
-
await
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
|
|
12629
|
-
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
applyRow(table, row) {
|
|
12635
|
-
this.dispatchRow(table, row);
|
|
12636
|
-
}
|
|
12637
|
-
/** Dispatch a parent-row LIVE event to every open CrdtField on that
|
|
12638
|
-
* record. Each open field reads its slice of the row directly — the
|
|
12639
|
-
* CRDT snapshot is a column on the parent now, so there is no
|
|
12640
|
-
* follow-up subquery. */
|
|
12641
|
-
dispatchRow(table, row) {
|
|
12642
|
-
const id = row.id != null ? String(row.id) : "";
|
|
12643
|
-
if (!id) return;
|
|
12644
|
-
const rowKeyPrefix = `${table}:${id}:`;
|
|
12645
|
-
for (const [key, crdtField] of this.fields) {
|
|
12646
|
-
if (!key.startsWith(rowKeyPrefix)) continue;
|
|
12647
|
-
const fieldName = key.slice(rowKeyPrefix.length);
|
|
12648
|
-
const cursorsEnabled = this.fieldHasCursor(table, fieldName);
|
|
12649
|
-
const slice = row[fieldName];
|
|
12650
|
-
const snapshot = this.extractSnapshot(slice, cursorsEnabled);
|
|
12651
|
-
if (snapshot) crdtField.importRemote(snapshot);
|
|
12652
|
-
if (cursorsEnabled && slice && typeof slice === "object") {
|
|
12653
|
-
const cursors = slice.cursors;
|
|
12654
|
-
if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
|
|
12655
|
-
if (sid === this.sessionId) continue;
|
|
12656
|
-
if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
|
|
12657
|
-
}
|
|
12875
|
+
const [created] = await this.remote.query("CREATE type::record('_00_crdt_peer', $peer) RETURN id", { peer });
|
|
12876
|
+
return Array.isArray(created) && created.length > 0;
|
|
12877
|
+
} catch (e) {
|
|
12878
|
+
if (!/already exists/i.test(String(e))) return false;
|
|
12879
|
+
try {
|
|
12880
|
+
const [owner] = await this.remote.query("SELECT VALUE author FROM ONLY type::record('_00_crdt_peer', $peer)", { peer });
|
|
12881
|
+
if (owner != null) return true;
|
|
12882
|
+
} catch {
|
|
12883
|
+
return false;
|
|
12658
12884
|
}
|
|
12885
|
+
this.logger.error({
|
|
12886
|
+
peer,
|
|
12887
|
+
Category: "sp00ky-client::CrdtManager::claim"
|
|
12888
|
+
}, "CRDT peer id taken by another user");
|
|
12889
|
+
if (peer === this.peer) this.setSessionId("");
|
|
12890
|
+
return false;
|
|
12659
12891
|
}
|
|
12660
12892
|
}
|
|
12661
|
-
|
|
12662
|
-
|
|
12663
|
-
|
|
12664
|
-
|
|
12665
|
-
async fetchAndDispatchRow(table, id) {
|
|
12893
|
+
localId(recordId, field) {
|
|
12894
|
+
return new RecordId$1(LOCAL_TABLE, `${recordId}|${field}`);
|
|
12895
|
+
}
|
|
12896
|
+
async readLocal(recordId, field) {
|
|
12666
12897
|
try {
|
|
12667
|
-
const
|
|
12668
|
-
|
|
12669
|
-
|
|
12670
|
-
|
|
12898
|
+
const row = await this.local.getById(LOCAL_TABLE, this.localId(recordId, field));
|
|
12899
|
+
if (row) return {
|
|
12900
|
+
snapshot: asBytes(row.snapshot),
|
|
12901
|
+
rev: Number(row.rev ?? 0),
|
|
12902
|
+
peers: row.peers ?? {}
|
|
12903
|
+
};
|
|
12671
12904
|
} catch (e) {
|
|
12672
|
-
this.logger.
|
|
12673
|
-
error: e,
|
|
12674
|
-
|
|
12675
|
-
|
|
12676
|
-
Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
|
|
12677
|
-
}, "Failed to fetch parent row for CRDT hydration");
|
|
12678
|
-
}
|
|
12679
|
-
}
|
|
12680
|
-
/** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
|
|
12681
|
-
* Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
|
|
12682
|
-
fieldHasCursor(table, field) {
|
|
12683
|
-
return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
|
|
12684
|
-
}
|
|
12685
|
-
/** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
|
|
12686
|
-
* the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
|
|
12687
|
-
* `{ state, cursors }` where `state` carries the snapshot bytes. */
|
|
12688
|
-
extractSnapshot(value, cursorsEnabled) {
|
|
12689
|
-
const asBytes = (v) => {
|
|
12690
|
-
if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
|
|
12691
|
-
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
12692
|
-
if (ArrayBuffer.isView(v)) {
|
|
12693
|
-
const view = v;
|
|
12694
|
-
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
12695
|
-
}
|
|
12696
|
-
if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
|
|
12697
|
-
};
|
|
12698
|
-
if (cursorsEnabled) {
|
|
12699
|
-
if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
|
|
12700
|
-
return;
|
|
12905
|
+
this.logger.debug({
|
|
12906
|
+
error: String(e),
|
|
12907
|
+
Category: "sp00ky-client::CrdtManager::readLocal"
|
|
12908
|
+
}, "No local CRDT state");
|
|
12701
12909
|
}
|
|
12702
|
-
return
|
|
12910
|
+
return {
|
|
12911
|
+
rev: 0,
|
|
12912
|
+
peers: {}
|
|
12913
|
+
};
|
|
12703
12914
|
}
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
12712
|
-
|
|
12915
|
+
schedulePersist(s) {
|
|
12916
|
+
if (s.persistTimer) return;
|
|
12917
|
+
s.persistTimer = setTimeout(() => {
|
|
12918
|
+
s.persistTimer = null;
|
|
12919
|
+
this.persist(s);
|
|
12920
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
12921
|
+
}
|
|
12922
|
+
/** Save the merged document. Another tab may have saved too: merge with
|
|
12923
|
+
* its snapshot rather than overwrite it, and keep the further of the two
|
|
12924
|
+
* outbox marks per peer. */
|
|
12925
|
+
async persist(s) {
|
|
12926
|
+
try {
|
|
12927
|
+
const stored = await this.readLocal(s.recordId, s.field);
|
|
12928
|
+
if (stored.snapshot) this.tryImport(s.doc, stored.snapshot, s.key);
|
|
12929
|
+
for (const [peer, mark] of Object.entries(stored.peers)) {
|
|
12930
|
+
const mine = s.local.peers[peer]?.enqueued ?? 0;
|
|
12931
|
+
if (mark.enqueued > mine) s.local.peers[peer] = { enqueued: mark.enqueued };
|
|
12932
|
+
}
|
|
12933
|
+
s.local.rev = Math.max(s.local.rev, stored.rev);
|
|
12934
|
+
s.local.snapshot = s.doc.export({ mode: "snapshot" });
|
|
12935
|
+
await this.local.upsert(LOCAL_TABLE, this.localId(s.recordId, s.field), {
|
|
12936
|
+
doc: s.recordId,
|
|
12937
|
+
field: s.field,
|
|
12938
|
+
snapshot: s.local.snapshot,
|
|
12939
|
+
rev: s.local.rev,
|
|
12940
|
+
peers: s.local.peers
|
|
12941
|
+
}, "replace");
|
|
12942
|
+
} catch (e) {
|
|
12943
|
+
this.logger.debug({
|
|
12944
|
+
key: s.key,
|
|
12945
|
+
error: String(e),
|
|
12946
|
+
Category: "sp00ky-client::CrdtManager::persist"
|
|
12947
|
+
}, "Saving local CRDT state failed");
|
|
12948
|
+
}
|
|
12949
|
+
}
|
|
12950
|
+
pushPresence(s, state) {
|
|
12951
|
+
s.presencePending = state;
|
|
12952
|
+
if (s.presenceTimer) return;
|
|
12953
|
+
s.presenceTimer = setTimeout(() => {
|
|
12954
|
+
s.presenceTimer = null;
|
|
12955
|
+
const data = s.presencePending;
|
|
12956
|
+
s.presencePending = null;
|
|
12957
|
+
if (data) this.sendPresence(s, data);
|
|
12958
|
+
}, PRESENCE_THROTTLE_MS);
|
|
12959
|
+
}
|
|
12960
|
+
/** Presence is the latest state only: no outbox, no offline queue. */
|
|
12961
|
+
async sendPresence(s, state) {
|
|
12962
|
+
if (!await this.ensureClaimed()) return;
|
|
12963
|
+
try {
|
|
12964
|
+
await this.remote.query(`UPSERT type::record($table, $key) SET doc = type::record($doc), field = $field, session = $sid, state = $state RETURN NONE`, {
|
|
12965
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
12966
|
+
key: presenceKey(this.peer, s.field, s.recordId),
|
|
12967
|
+
doc: s.recordId,
|
|
12968
|
+
field: s.field,
|
|
12969
|
+
sid: this.peer,
|
|
12970
|
+
state
|
|
12713
12971
|
});
|
|
12714
|
-
|
|
12972
|
+
s.presenceSent = true;
|
|
12973
|
+
} catch (e) {
|
|
12974
|
+
this.logger.debug({
|
|
12975
|
+
key: s.key,
|
|
12976
|
+
error: String(e),
|
|
12977
|
+
Category: "sp00ky-client::CrdtManager::presence"
|
|
12978
|
+
}, "Presence update failed");
|
|
12715
12979
|
}
|
|
12716
12980
|
}
|
|
12717
|
-
|
|
12718
|
-
|
|
12981
|
+
/** Remove this session's cursor when the field closes; the server's TTL
|
|
12982
|
+
* sweep covers a tab that never gets here. */
|
|
12983
|
+
async dropPresence(s) {
|
|
12984
|
+
try {
|
|
12985
|
+
await this.remote.query(`DELETE type::record($table, $key) RETURN NONE`, {
|
|
12986
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
12987
|
+
key: presenceKey(this.peer, s.field, s.recordId)
|
|
12988
|
+
});
|
|
12989
|
+
} catch (e) {
|
|
12990
|
+
this.logger.debug({
|
|
12991
|
+
key: s.key,
|
|
12992
|
+
error: String(e),
|
|
12993
|
+
Category: "sp00ky-client::CrdtManager::presence"
|
|
12994
|
+
}, "Presence removal failed");
|
|
12995
|
+
}
|
|
12719
12996
|
}
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
|
|
12726
|
-
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_]*`);
|
|
12727
|
-
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
12728
|
-
if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
|
|
12729
|
-
const column = tableSchema.columns[field];
|
|
12730
|
-
if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
|
|
12731
|
-
if (!column.crdt) throw new Error(`openCrdtField: '${table}.${field}' is not annotated '@crdt' in the schema. Add '-- @crdt text' above the field's DEFINE FIELD and regenerate the client schema.`);
|
|
12997
|
+
ingestPresence(s, rows) {
|
|
12998
|
+
for (const row of rows) {
|
|
12999
|
+
if (String(row.session ?? "") === this.peer) continue;
|
|
13000
|
+
const state = asBytes(row.state);
|
|
13001
|
+
if (state) s.handle.deliverPresence(state);
|
|
13002
|
+
}
|
|
12732
13003
|
}
|
|
12733
13004
|
};
|
|
13005
|
+
/** `p<session>_<field>_<record key>`, letters, digits and `_` only. */
|
|
13006
|
+
function presenceKey(session, field, recordId) {
|
|
13007
|
+
return `p${session}_${field}_${recordId.slice(recordId.indexOf(":") + 1).replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
13008
|
+
}
|
|
13009
|
+
function delay(ms) {
|
|
13010
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13011
|
+
}
|
|
13012
|
+
function rowKey() {
|
|
13013
|
+
const bytes = new Uint8Array(12);
|
|
13014
|
+
crypto.getRandomValues(bytes);
|
|
13015
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
13016
|
+
}
|
|
12734
13017
|
|
|
12735
13018
|
//#endregion
|
|
12736
13019
|
//#region src/services/persistence/localstorage.ts
|
|
@@ -14952,7 +15235,7 @@ function createServices(config) {
|
|
|
14952
15235
|
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === "sqlite", config.circuitCheckpointMs);
|
|
14953
15236
|
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
14954
15237
|
const migrator = new LocalMigrator(local, logger);
|
|
14955
|
-
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ??
|
|
15238
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
|
|
14956
15239
|
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
14957
15240
|
const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
14958
15241
|
return {
|
|
@@ -14978,7 +15261,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14978
15261
|
return new TabsCoordinator({
|
|
14979
15262
|
tabId: s.tabId,
|
|
14980
15263
|
fingerprint: computeTabsFingerprint({
|
|
14981
|
-
coreVersion: "0.0.1-canary.
|
|
15264
|
+
coreVersion: "0.0.1-canary.325",
|
|
14982
15265
|
schemaHash: hash53(config.schemaSurql),
|
|
14983
15266
|
endpoint: config.database.endpoint ?? "",
|
|
14984
15267
|
namespace: config.database.namespace,
|
|
@@ -15649,6 +15932,28 @@ var Sp00kyClient = class {
|
|
|
15649
15932
|
subscribe: (hash, cb, options) => this.runtime.subscribe(hash, cb, options)
|
|
15650
15933
|
};
|
|
15651
15934
|
const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: "EnsureRegistered" }) };
|
|
15935
|
+
s.crdt.attach({
|
|
15936
|
+
watch: async (table, where, onRows) => {
|
|
15937
|
+
const { hash } = await this.query(table, {}).where(where).build().run();
|
|
15938
|
+
const stopRows = this.runtime.subscribe(hash, onRows, { immediate: true });
|
|
15939
|
+
let known = false;
|
|
15940
|
+
let idle = false;
|
|
15941
|
+
let done;
|
|
15942
|
+
const settled = new Promise((resolve) => done = resolve);
|
|
15943
|
+
const check = () => known && idle && done();
|
|
15944
|
+
const stopAuthority = this.runtime.subscribeAuthority(hash, (k) => (known = k, check()), { immediate: true });
|
|
15945
|
+
const stopStatus = this.runtime.subscribeStatus(hash, (st) => (idle = st === "idle", check()), { immediate: true });
|
|
15946
|
+
return {
|
|
15947
|
+
stop: () => {
|
|
15948
|
+
stopRows();
|
|
15949
|
+
stopAuthority();
|
|
15950
|
+
stopStatus();
|
|
15951
|
+
},
|
|
15952
|
+
settled
|
|
15953
|
+
};
|
|
15954
|
+
},
|
|
15955
|
+
create: (id, data) => this.create(id, data)
|
|
15956
|
+
});
|
|
15652
15957
|
this.featureFlags = new FeatureFlagModule({
|
|
15653
15958
|
dataModule: liveQueryPort,
|
|
15654
15959
|
sync: remoteRegisterPort,
|
|
@@ -16051,11 +16356,20 @@ var Sp00kyClient = class {
|
|
|
16051
16356
|
appRelease(app, options) {
|
|
16052
16357
|
return this.appReleases.release(app, options);
|
|
16053
16358
|
}
|
|
16054
|
-
|
|
16055
|
-
|
|
16359
|
+
/** Open a `@crdt` field for editing. `draft` (from {@link createCrdtDraft})
|
|
16360
|
+
* hands over edits made before the record existed. */
|
|
16361
|
+
async openCrdtField(table, recordId, field, draft) {
|
|
16362
|
+
return this.services.crdt.open(table, recordId, field, draft);
|
|
16363
|
+
}
|
|
16364
|
+
/** A detached `@crdt` document for a record not created yet; see
|
|
16365
|
+
* {@link openCrdtField}. */
|
|
16366
|
+
async createCrdtDraft(table, field) {
|
|
16367
|
+
return this.services.crdt.createDraft(table, field);
|
|
16056
16368
|
}
|
|
16369
|
+
/** Close a `@crdt` field. The last close resolves once its unsent edits
|
|
16370
|
+
* are queued in the outbox. */
|
|
16057
16371
|
closeCrdtField(table, recordId, field) {
|
|
16058
|
-
this.services.crdt.close(table, recordId, field);
|
|
16372
|
+
return this.services.crdt.close(table, recordId, field);
|
|
16059
16373
|
}
|
|
16060
16374
|
bucket(name) {
|
|
16061
16375
|
return new BucketHandle(name, this.services.remote, this.services.blobs, {
|