@spooky-sync/core 0.0.1-canary.322 → 0.0.1-canary.324
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 +156 -161
- package/dist/index.js +910 -482
- package/dist/types.d.ts +17 -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 +3 -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/cache-engine.ts +10 -0
- package/src/services/database/local-migrator.ts +5 -3
- package/src/services/database/sqlite-cache-engine.ts +28 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/stream-processor/index.ts +24 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +36 -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.324";
|
|
2192
|
+
const WASM_VERSION = "0.0.1-canary.324";
|
|
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];
|
|
@@ -9627,6 +9649,84 @@ var PortSqliteTransport = class extends BaseTransport {
|
|
|
9627
9649
|
}
|
|
9628
9650
|
};
|
|
9629
9651
|
|
|
9652
|
+
//#endregion
|
|
9653
|
+
//#region src/services/database/sqlite-column-reset.ts
|
|
9654
|
+
/**
|
|
9655
|
+
* Cached bodies outlive a schema change on the SQLite engine, and that loses
|
|
9656
|
+
* columns for good.
|
|
9657
|
+
*
|
|
9658
|
+
* A body is landed through `cleanRecord`, which keeps only the columns the
|
|
9659
|
+
* running app's schema declares, and is stored at the server's `_00_rv`. The
|
|
9660
|
+
* engine is schemaless (no migrator, nothing is recreated when the schema
|
|
9661
|
+
* changes), so a tab on an older build that fetched a row after the server
|
|
9662
|
+
* added and backfilled a column stored it WITHOUT that column at the new
|
|
9663
|
+
* version. The next build knows the column, but its local version equals the
|
|
9664
|
+
* server's, so the fetch plan never asks for the row again and the column
|
|
9665
|
+
* stays missing until something else bumps the row. (WhitePawn, 2026-10-11:
|
|
9666
|
+
* every puzzle read as unconverted because `layout` never arrived.)
|
|
9667
|
+
*
|
|
9668
|
+
* So the store remembers each table's column set (`_00_schema`, row
|
|
9669
|
+
* `columns`). On boot, before the prime reads the versions, a table whose set
|
|
9670
|
+
* changed, or every table when no set was recorded yet, gets its cached
|
|
9671
|
+
* `_00_rv` reset to 0. The bodies stay and keep rendering; any view that names
|
|
9672
|
+
* one of those rows sees it as stale and fetches it again, whole. Rows with an
|
|
9673
|
+
* unsettled local write keep their version: their echo is a newer body anyway.
|
|
9674
|
+
*/
|
|
9675
|
+
/** `_00_schema` row holding the recorded column sets. */
|
|
9676
|
+
const COLUMNS_ROW_ID = "columns";
|
|
9677
|
+
/**
|
|
9678
|
+
* The column set of every table, as `cleanRecord` sees it: the sorted declared
|
|
9679
|
+
* column names. Plain names rather than a hash, so a stored set says what it is.
|
|
9680
|
+
*/
|
|
9681
|
+
function columnFingerprints(tables) {
|
|
9682
|
+
const out = {};
|
|
9683
|
+
for (const t of tables) out[t.name] = Object.keys(t.columns).sort().join(",");
|
|
9684
|
+
return out;
|
|
9685
|
+
}
|
|
9686
|
+
const sameSets = (a, b) => {
|
|
9687
|
+
if (!a) return false;
|
|
9688
|
+
const ka = Object.keys(a);
|
|
9689
|
+
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
|
9690
|
+
};
|
|
9691
|
+
/**
|
|
9692
|
+
* What to run for the column sets `current`, given the `stored` ones (null when
|
|
9693
|
+
* none were recorded), the tables that exist in the store, and the encoded ids
|
|
9694
|
+
* (`table:id`) with an unsettled local write.
|
|
9695
|
+
*/
|
|
9696
|
+
function planColumnReset(stored, current, existingTables, keep) {
|
|
9697
|
+
if (sameSets(stored, current)) return {
|
|
9698
|
+
changed: [],
|
|
9699
|
+
statements: []
|
|
9700
|
+
};
|
|
9701
|
+
const keepByTable = /* @__PURE__ */ new Map();
|
|
9702
|
+
for (const id of keep) {
|
|
9703
|
+
const table = id.slice(0, id.indexOf(":"));
|
|
9704
|
+
if (!keepByTable.has(table)) keepByTable.set(table, []);
|
|
9705
|
+
keepByTable.get(table).push(id);
|
|
9706
|
+
}
|
|
9707
|
+
const changed = [];
|
|
9708
|
+
const statements = [];
|
|
9709
|
+
for (const [table, set] of Object.entries(current)) {
|
|
9710
|
+
if (stored && stored[table] === set) continue;
|
|
9711
|
+
if (!existingTables.has(table)) continue;
|
|
9712
|
+
changed.push(table);
|
|
9713
|
+
const reset = `UPDATE ${`"${table.replace(/"/g, "\"\"")}"`} SET data = json_set(data, '$._00_rv', 0) WHERE json_extract(data, '$._00_rv') != 0`;
|
|
9714
|
+
const kept = keepByTable.get(table);
|
|
9715
|
+
statements.push(kept ? {
|
|
9716
|
+
sql: `${reset} AND id NOT IN (SELECT value FROM json_each(?))`,
|
|
9717
|
+
bind: [JSON.stringify(kept)]
|
|
9718
|
+
} : { sql: reset });
|
|
9719
|
+
}
|
|
9720
|
+
statements.push({
|
|
9721
|
+
sql: `INSERT OR REPLACE INTO "_00_schema"(id, data) VALUES (?, ?)`,
|
|
9722
|
+
bind: [COLUMNS_ROW_ID, JSON.stringify(current)]
|
|
9723
|
+
});
|
|
9724
|
+
return {
|
|
9725
|
+
changed,
|
|
9726
|
+
statements
|
|
9727
|
+
};
|
|
9728
|
+
}
|
|
9729
|
+
|
|
9630
9730
|
//#endregion
|
|
9631
9731
|
//#region src/services/database/sqlite-cache-engine.ts
|
|
9632
9732
|
/**
|
|
@@ -9661,6 +9761,7 @@ const SYSTEM_TABLES = [
|
|
|
9661
9761
|
"_00_pending_mutations",
|
|
9662
9762
|
"_00_pending_writes",
|
|
9663
9763
|
"_00_blob",
|
|
9764
|
+
"_00_crdt_local",
|
|
9664
9765
|
"_00_user_feature",
|
|
9665
9766
|
"_00_app_release"
|
|
9666
9767
|
];
|
|
@@ -10259,6 +10360,28 @@ var SqliteCacheEngine = class {
|
|
|
10259
10360
|
}
|
|
10260
10361
|
return out;
|
|
10261
10362
|
}
|
|
10363
|
+
/**
|
|
10364
|
+
* Reset the cached `_00_rv` of every table whose column set changed since
|
|
10365
|
+
* the last boot, so its rows are fetched again whole. See
|
|
10366
|
+
* `sqlite-column-reset.ts`. Returns the tables reset.
|
|
10367
|
+
*/
|
|
10368
|
+
async resetChangedTables(fingerprints, keep) {
|
|
10369
|
+
const { rows } = await this.call("exec", {
|
|
10370
|
+
sql: `SELECT data FROM "_00_schema" WHERE id = ?`,
|
|
10371
|
+
bind: [COLUMNS_ROW_ID]
|
|
10372
|
+
});
|
|
10373
|
+
let stored = null;
|
|
10374
|
+
try {
|
|
10375
|
+
const raw = rows?.[0]?.data;
|
|
10376
|
+
if (typeof raw === "string") stored = JSON.parse(raw);
|
|
10377
|
+
} catch {
|
|
10378
|
+
stored = null;
|
|
10379
|
+
}
|
|
10380
|
+
const { rows: tables } = await this.call("exec", { sql: `SELECT name FROM sqlite_master WHERE type = 'table'` });
|
|
10381
|
+
const plan = planColumnReset(stored, fingerprints, new Set((tables ?? []).map((t) => t.name)), keep);
|
|
10382
|
+
if (plan.statements.length > 0) await this.call("batch", plan.statements);
|
|
10383
|
+
return plan.changed;
|
|
10384
|
+
}
|
|
10262
10385
|
async getSnapshot(key) {
|
|
10263
10386
|
const { rows } = await this.call("exec", {
|
|
10264
10387
|
sql: `SELECT id, data FROM "${SNAPSHOT_TABLE}" WHERE id IN (?, ?)`,
|
|
@@ -10955,6 +11078,19 @@ var StreamProcessorService = class {
|
|
|
10955
11078
|
const epoch = this.db.epoch;
|
|
10956
11079
|
const alive = () => this.processor === processor && generation === this.stateGeneration && epoch === this.db.epoch;
|
|
10957
11080
|
const t0 = performance.now();
|
|
11081
|
+
if (ctx.columnFingerprints && typeof this.db.resetChangedTables === "function") try {
|
|
11082
|
+
const reset = await this.db.resetChangedTables(ctx.columnFingerprints, ctx.pendingIds);
|
|
11083
|
+
if (!alive()) return;
|
|
11084
|
+
if (reset.length > 0) this.logger.info({
|
|
11085
|
+
tables: reset,
|
|
11086
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
11087
|
+
}, "Columns changed since these rows were cached; they will be fetched again");
|
|
11088
|
+
} catch (e) {
|
|
11089
|
+
this.logger.warn({
|
|
11090
|
+
error: e,
|
|
11091
|
+
Category: "sp00ky-client::StreamProcessorService::primeFromLocal"
|
|
11092
|
+
}, "Column change check failed; cached versions kept");
|
|
11093
|
+
}
|
|
10958
11094
|
let restored = false;
|
|
10959
11095
|
if (this.persistCircuit && typeof this.db.getSnapshot === "function" && typeof processor.load_store_state === "function") {
|
|
10960
11096
|
const snapshot = await this.db.getSnapshot(CIRCUIT_SNAPSHOT_KEY);
|
|
@@ -12055,6 +12191,109 @@ var AuthService = class {
|
|
|
12055
12191
|
}
|
|
12056
12192
|
};
|
|
12057
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
|
+
|
|
12058
12297
|
//#endregion
|
|
12059
12298
|
//#region src/modules/crdt/crdt-field.ts
|
|
12060
12299
|
const CURSOR_COLORS = [
|
|
@@ -12072,222 +12311,139 @@ function cursorColorFromName(name) {
|
|
|
12072
12311
|
for (let i = 0; i < name.length; i++) hash = (hash << 5) - hash + name.charCodeAt(i) | 0;
|
|
12073
12312
|
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
12074
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
|
+
*/
|
|
12075
12320
|
var CrdtField = class {
|
|
12076
|
-
doc;
|
|
12077
|
-
pushTimer = null;
|
|
12078
|
-
local = null;
|
|
12079
|
-
remote = null;
|
|
12080
|
-
recordId = null;
|
|
12081
|
-
sessionId = "";
|
|
12082
|
-
unsubscribe = null;
|
|
12083
|
-
lastPushTime = 0;
|
|
12084
|
-
lastCursorPushTime = 0;
|
|
12085
|
-
loadedFromCrdt = false;
|
|
12086
|
-
pushRetryCount = 0;
|
|
12087
|
-
logger;
|
|
12088
|
-
cursorsEnabled;
|
|
12089
|
-
/** Remote-push debounce. Local writes happen immediately on every Loro
|
|
12090
|
-
* update; the remote UPSERT is coalesced over this window. Configured
|
|
12091
|
-
* via `Sp00kyConfig.crdtDebounceMs`, default 500. */
|
|
12092
|
-
remoteDebounceMs = 500;
|
|
12093
12321
|
_onCursorUpdate = null;
|
|
12094
12322
|
pendingCursorUpdate = null;
|
|
12095
|
-
|
|
12096
|
-
|
|
12097
|
-
|
|
12098
|
-
this._onCursorUpdate = cb;
|
|
12099
|
-
if (cb && this.pendingCursorUpdate) {
|
|
12100
|
-
try {
|
|
12101
|
-
cb(this.pendingCursorUpdate);
|
|
12102
|
-
} catch (e) {
|
|
12103
|
-
this.logger?.warn({
|
|
12104
|
-
error: e,
|
|
12105
|
-
Category: "sp00ky-client::CrdtField::onCursorUpdate"
|
|
12106
|
-
}, "Failed to replay pending cursor update");
|
|
12107
|
-
}
|
|
12108
|
-
this.pendingCursorUpdate = null;
|
|
12109
|
-
}
|
|
12110
|
-
}
|
|
12111
|
-
get onCursorUpdate() {
|
|
12112
|
-
return this._onCursorUpdate;
|
|
12113
|
-
}
|
|
12114
|
-
/**
|
|
12115
|
-
* @param LoroDocClass the `LoroDoc` constructor, injected by the caller after
|
|
12116
|
-
* awaiting {@link loadLoro} — keeps `loro-crdt` out of this module's static
|
|
12117
|
-
* import graph so it only ships to apps that use CRDT fields.
|
|
12118
|
-
*/
|
|
12119
|
-
constructor(fieldName, cursorsEnabled, LoroDocClass, initialState, logger) {
|
|
12323
|
+
constructor(table, recordId, fieldName, kind, cursorsEnabled, doc, host) {
|
|
12324
|
+
this.table = table;
|
|
12325
|
+
this.recordId = recordId;
|
|
12120
12326
|
this.fieldName = fieldName;
|
|
12121
|
-
|
|
12122
|
-
this.logger = logger ?? null;
|
|
12327
|
+
this.kind = kind;
|
|
12123
12328
|
this.cursorsEnabled = cursorsEnabled;
|
|
12124
|
-
this.doc =
|
|
12125
|
-
|
|
12126
|
-
this.doc.import(initialState);
|
|
12127
|
-
this.loadedFromCrdt = true;
|
|
12128
|
-
} catch (e) {
|
|
12129
|
-
this.logger?.warn({
|
|
12130
|
-
error: e,
|
|
12131
|
-
fieldName,
|
|
12132
|
-
Category: "sp00ky-client::CrdtField::constructor"
|
|
12133
|
-
}, "Initial CRDT state is not a valid LoroDoc snapshot — starting empty and will seed from fallback text");
|
|
12134
|
-
}
|
|
12329
|
+
this.doc = doc;
|
|
12330
|
+
this.host = host;
|
|
12135
12331
|
}
|
|
12136
12332
|
getDoc() {
|
|
12137
12333
|
return this.doc;
|
|
12138
12334
|
}
|
|
12139
|
-
/** Whether the
|
|
12335
|
+
/** Whether the document holds anything yet (local state or edits). */
|
|
12140
12336
|
hasContent() {
|
|
12141
|
-
return this.
|
|
12142
|
-
}
|
|
12143
|
-
startSync(local, remote, recordId, sessionId, debounceMs) {
|
|
12144
|
-
this.local = local;
|
|
12145
|
-
this.remote = remote;
|
|
12146
|
-
this.recordId = recordId;
|
|
12147
|
-
this.sessionId = sessionId;
|
|
12148
|
-
this.remoteDebounceMs = debounceMs;
|
|
12149
|
-
this.unsubscribe = this.doc.subscribeLocalUpdates(() => {
|
|
12150
|
-
this.persistLocal();
|
|
12151
|
-
this.scheduleRemotePush();
|
|
12152
|
-
});
|
|
12337
|
+
return this.doc.oplogVersion().length() > 0;
|
|
12153
12338
|
}
|
|
12154
|
-
/**
|
|
12155
|
-
*
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
* (previous user's) snapshot under it would clobber the record remotely.
|
|
12159
|
-
*/
|
|
12160
|
-
stopSync(options = {}) {
|
|
12161
|
-
const flush = options.flush ?? true;
|
|
12162
|
-
if (this.unsubscribe) {
|
|
12163
|
-
this.unsubscribe();
|
|
12164
|
-
this.unsubscribe = null;
|
|
12165
|
-
}
|
|
12166
|
-
if (this.pushTimer) {
|
|
12167
|
-
clearTimeout(this.pushTimer);
|
|
12168
|
-
this.pushTimer = null;
|
|
12169
|
-
}
|
|
12170
|
-
if (flush && this.remote && this.recordId) this.pushToRemote();
|
|
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);
|
|
12171
12343
|
}
|
|
12172
|
-
|
|
12173
|
-
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
|
|
12178
|
-
this.
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
}
|
|
12182
|
-
}
|
|
12183
|
-
}
|
|
12184
|
-
exportSnapshot() {
|
|
12185
|
-
return this.doc.export({ mode: "snapshot" });
|
|
12186
|
-
}
|
|
12187
|
-
/** Push this session's cursor blob into the parent row at
|
|
12188
|
-
* `<field>.cursors[$sid]`. No-op when cursors aren't enabled on this
|
|
12189
|
-
* field — the editor still calls this method optimistically, but
|
|
12190
|
-
* without `@cursor` on the schema there's nowhere to store the blob.
|
|
12191
|
-
* The UPDATE itself fires the parent table's LIVE feed, so other
|
|
12192
|
-
* browsers receive the cursor change without a separate `_00_rv` bump. */
|
|
12193
|
-
async pushCursorState(encoded) {
|
|
12194
|
-
if (!this.remote || !this.recordId) return;
|
|
12195
|
-
if (!this.cursorsEnabled) return;
|
|
12196
|
-
this.lastCursorPushTime = Date.now();
|
|
12197
|
-
try {
|
|
12198
|
-
const state = encodeBase64(encoded);
|
|
12199
|
-
await this.remote.query(`UPDATE $id SET ${this.fieldName}.cursors[$sid] = $state RETURN NONE;`, {
|
|
12200
|
-
id: parseRecordIdString(this.recordId),
|
|
12201
|
-
sid: this.sessionId,
|
|
12202
|
-
state
|
|
12203
|
-
});
|
|
12204
|
-
} catch (e) {
|
|
12205
|
-
this.logger?.warn({
|
|
12206
|
-
error: e,
|
|
12207
|
-
Category: "sp00ky-client::CrdtField::pushCursorState"
|
|
12208
|
-
}, "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 {}
|
|
12209
12354
|
}
|
|
12210
12355
|
}
|
|
12211
|
-
|
|
12212
|
-
|
|
12213
|
-
if (Date.now() - this.lastCursorPushTime < 300) return;
|
|
12214
|
-
try {
|
|
12215
|
-
const data = decodeBase64(base64State);
|
|
12216
|
-
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
12217
|
-
else this.pendingCursorUpdate = data;
|
|
12218
|
-
} catch (e) {
|
|
12219
|
-
this.logger?.warn({
|
|
12220
|
-
error: e,
|
|
12221
|
-
Category: "sp00ky-client::CrdtField::importRemoteCursor"
|
|
12222
|
-
}, "Failed to apply remote cursor data");
|
|
12223
|
-
}
|
|
12224
|
-
}
|
|
12225
|
-
scheduleRemotePush() {
|
|
12226
|
-
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
12227
|
-
this.pushTimer = setTimeout(() => void this.pushToRemote(), this.remoteDebounceMs);
|
|
12228
|
-
}
|
|
12229
|
-
/** SET path inside a parent row for the current snapshot. `@crdt`-only
|
|
12230
|
-
* fields hold the snapshot directly (`<field>`); `@crdt @cursor`
|
|
12231
|
-
* fields hold a `{ state, cursors }` object so the snapshot lives at
|
|
12232
|
-
* `<field>.state` next to per-session cursor blobs. */
|
|
12233
|
-
statePath() {
|
|
12234
|
-
return this.cursorsEnabled ? `${this.fieldName}.state` : this.fieldName;
|
|
12235
|
-
}
|
|
12236
|
-
/** Mirror the LoroDoc snapshot into the parent row locally. Runs on
|
|
12237
|
-
* every local update and every remote import so reloads (online or
|
|
12238
|
-
* offline) see the freshest content immediately. Failures are
|
|
12239
|
-
* swallowed — a stale local write must never block user input. */
|
|
12240
|
-
async persistLocal() {
|
|
12241
|
-
if (!this.local || !this.recordId) return;
|
|
12242
|
-
try {
|
|
12243
|
-
await this.local.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
12244
|
-
id: parseRecordIdString(this.recordId),
|
|
12245
|
-
state: this.exportSnapshot()
|
|
12246
|
-
});
|
|
12247
|
-
} catch (e) {
|
|
12248
|
-
this.logger?.debug({
|
|
12249
|
-
error: e,
|
|
12250
|
-
Category: "sp00ky-client::CrdtField::persistLocal"
|
|
12251
|
-
}, "Local CRDT persist failed (best-effort)");
|
|
12252
|
-
}
|
|
12356
|
+
get onCursorUpdate() {
|
|
12357
|
+
return this._onCursorUpdate;
|
|
12253
12358
|
}
|
|
12254
|
-
|
|
12255
|
-
|
|
12256
|
-
this.
|
|
12257
|
-
|
|
12258
|
-
await this.remote.query(`UPDATE $id SET ${this.statePath()} = $state RETURN NONE;`, {
|
|
12259
|
-
id: parseRecordIdString(this.recordId),
|
|
12260
|
-
state: this.exportSnapshot()
|
|
12261
|
-
});
|
|
12262
|
-
this.pushRetryCount = 0;
|
|
12263
|
-
} catch (e) {
|
|
12264
|
-
this.logger?.warn({
|
|
12265
|
-
error: e,
|
|
12266
|
-
Category: "sp00ky-client::CrdtField::pushToRemote"
|
|
12267
|
-
}, "Failed to push CRDT state to remote");
|
|
12268
|
-
if (this.pushRetryCount < 2) {
|
|
12269
|
-
this.pushRetryCount++;
|
|
12270
|
-
this.scheduleRemotePush();
|
|
12271
|
-
}
|
|
12272
|
-
}
|
|
12359
|
+
/** @internal Deliver one remote presence blob. */
|
|
12360
|
+
deliverPresence(data) {
|
|
12361
|
+
if (this._onCursorUpdate) this._onCursorUpdate(data);
|
|
12362
|
+
else this.pendingCursorUpdate = data;
|
|
12273
12363
|
}
|
|
12274
12364
|
};
|
|
12275
|
-
|
|
12276
|
-
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
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;
|
|
12283
12423
|
}
|
|
12284
|
-
function
|
|
12285
|
-
if (
|
|
12286
|
-
|
|
12287
|
-
|
|
12288
|
-
|
|
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}`);
|
|
12289
12446
|
}
|
|
12290
|
-
return Buffer.from(bytes).toString("base64");
|
|
12291
12447
|
}
|
|
12292
12448
|
|
|
12293
12449
|
//#endregion
|
|
@@ -12305,319 +12461,559 @@ function loadLoro() {
|
|
|
12305
12461
|
|
|
12306
12462
|
//#endregion
|
|
12307
12463
|
//#region src/modules/crdt/index.ts
|
|
12308
|
-
|
|
12309
|
-
|
|
12310
|
-
|
|
12311
|
-
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
*
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
* Cross-browser delivery still rides the parent table's existing LIVE feed
|
|
12322
|
-
* to avoid SurrealDB v3 LIVE bugs around dereference-based permission rules
|
|
12323
|
-
* (issues 3602, 4026). On every meta UPSERT the writer also bumps the
|
|
12324
|
-
* parent's `_00_rv` (a no-op assignment); that fires the parent's LIVE
|
|
12325
|
-
* feed, and the receiver pulls the matching `_00_crdt` / `_00_cursor` rows
|
|
12326
|
-
* via subquery. Permission inheritance happens server-side via
|
|
12327
|
-
* `record_id.id != NONE` (SELECT) and `fn::can_update_record` (UPDATE).
|
|
12328
|
-
*/
|
|
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;
|
|
12329
12477
|
var CrdtManager = class {
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
staleTables = /* @__PURE__ */ new Set();
|
|
12334
|
-
connectionGeneration = 0;
|
|
12335
|
-
connectionUnsubscribes = [];
|
|
12478
|
+
sessions = /* @__PURE__ */ new Map();
|
|
12479
|
+
opening = /* @__PURE__ */ new Map();
|
|
12480
|
+
ports = null;
|
|
12336
12481
|
logger;
|
|
12337
|
-
|
|
12338
|
-
|
|
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) {
|
|
12339
12488
|
this.schema = schema;
|
|
12340
12489
|
this.local = local;
|
|
12341
12490
|
this.remote = remote;
|
|
12342
12491
|
this.debounceMs = debounceMs;
|
|
12343
12492
|
this.logger = logger.child({ service: "CrdtManager" });
|
|
12344
|
-
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;
|
|
12345
12500
|
}
|
|
12346
12501
|
/**
|
|
12347
|
-
*
|
|
12348
|
-
*
|
|
12349
|
-
*
|
|
12350
|
-
* `ensureTableSubscription` is memoized on `liveByTable` — so without this,
|
|
12351
|
-
* the first reconnect leaves CRDT realtime permanently dead: the map still
|
|
12352
|
-
* holds a uuid for a subscription the server has forgotten, so every later
|
|
12353
|
-
* `open()` short-circuits and no LIVE is ever re-issued.
|
|
12354
|
-
*
|
|
12355
|
-
* Both drop events matter: the SDK publishes `reconnecting` (not
|
|
12356
|
-
* `disconnected`) when it intends to recover on its own, and `disconnected`
|
|
12357
|
-
* 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.
|
|
12358
12505
|
*/
|
|
12359
|
-
|
|
12360
|
-
|
|
12361
|
-
|
|
12362
|
-
|
|
12363
|
-
for (const table of this.pendingLive.keys()) this.staleTables.add(table);
|
|
12364
|
-
this.liveByTable.clear();
|
|
12365
|
-
};
|
|
12366
|
-
this.connectionUnsubscribes.push(this.remote.subscribeConnection("reconnecting", onDrop), this.remote.subscribeConnection("disconnected", onDrop), this.remote.subscribeConnection("connected", () => {
|
|
12367
|
-
const tables = Array.from(this.staleTables);
|
|
12368
|
-
this.staleTables.clear();
|
|
12369
|
-
for (const table of tables) {
|
|
12370
|
-
if (!this.hasOpenFieldFor(table)) continue;
|
|
12371
|
-
this.ensureTableSubscription(table);
|
|
12372
|
-
}
|
|
12373
|
-
}));
|
|
12506
|
+
setSessionId(_salt) {
|
|
12507
|
+
this.peer = randomPeer();
|
|
12508
|
+
this.claim = null;
|
|
12509
|
+
for (const s of this.sessions.values()) s.doc.setPeerId(this.peer);
|
|
12374
12510
|
}
|
|
12375
|
-
/** Stop observing transport events. Separate from {@link closeAll}, which also
|
|
12376
|
-
* runs on a bucket switch where the manager keeps being used. */
|
|
12377
12511
|
dispose() {
|
|
12378
|
-
for (const off of this.connectionUnsubscribes) try {
|
|
12512
|
+
for (const off of this.connectionUnsubscribes.splice(0)) try {
|
|
12379
12513
|
off();
|
|
12380
12514
|
} catch {}
|
|
12381
|
-
this.
|
|
12382
|
-
this.staleTables.clear();
|
|
12383
|
-
}
|
|
12384
|
-
hasOpenFieldFor(table) {
|
|
12385
|
-
const prefix = `${table}:`;
|
|
12386
|
-
return Array.from(this.fields.keys()).some((k) => k.startsWith(prefix));
|
|
12387
|
-
}
|
|
12388
|
-
/** Set the session id that scopes this client's cursor entries. Must be
|
|
12389
|
-
* called before `open()` for cursors to be pushed under a stable key.
|
|
12390
|
-
* Passed in from `sp00ky.ts` at boot (it already fetches `session::id()`
|
|
12391
|
-
* for the data-module salt). */
|
|
12392
|
-
setSessionId(sessionId) {
|
|
12393
|
-
this.sessionId = sessionId;
|
|
12515
|
+
if (this.claimRetry) clearTimeout(this.claimRetry);
|
|
12394
12516
|
}
|
|
12395
12517
|
/**
|
|
12396
|
-
* Open a
|
|
12518
|
+
* Open a collaborative field. Every open of the same field shares one
|
|
12519
|
+
* document; close each open with {@link close}.
|
|
12397
12520
|
*
|
|
12398
|
-
* @
|
|
12399
|
-
*
|
|
12400
|
-
* @param field - Field name (e.g., "title", "content")
|
|
12401
|
-
* @param fallbackText - Current plain text from the record, used to seed the
|
|
12402
|
-
* 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.
|
|
12403
12523
|
*/
|
|
12404
|
-
async open(table, recordId, field,
|
|
12405
|
-
this.
|
|
12406
|
-
const
|
|
12407
|
-
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
12411
|
-
|
|
12412
|
-
|
|
12413
|
-
|
|
12414
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
12427
|
-
|
|
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({
|
|
12428
12644
|
key,
|
|
12429
|
-
|
|
12430
|
-
hasFallback: !!fallbackText,
|
|
12645
|
+
peer: this.peer,
|
|
12431
12646
|
Category: "sp00ky-client::CrdtManager::open"
|
|
12432
|
-
}, "
|
|
12433
|
-
|
|
12434
|
-
if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
|
|
12435
|
-
return crdtField;
|
|
12647
|
+
}, "CRDT field opened");
|
|
12648
|
+
return session;
|
|
12436
12649
|
}
|
|
12437
|
-
|
|
12438
|
-
|
|
12439
|
-
const
|
|
12440
|
-
|
|
12441
|
-
|
|
12442
|
-
|
|
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);
|
|
12443
12689
|
}
|
|
12444
|
-
if (
|
|
12445
|
-
|
|
12446
|
-
|
|
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;
|
|
12447
12707
|
}
|
|
12448
|
-
|
|
12449
|
-
|
|
12450
|
-
|
|
12451
|
-
|
|
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);
|
|
12452
12739
|
}
|
|
12453
12740
|
/**
|
|
12454
|
-
*
|
|
12455
|
-
*
|
|
12456
|
-
*
|
|
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.
|
|
12457
12746
|
*/
|
|
12458
|
-
|
|
12459
|
-
|
|
12460
|
-
|
|
12461
|
-
|
|
12462
|
-
|
|
12463
|
-
|
|
12464
|
-
|
|
12465
|
-
|
|
12466
|
-
|
|
12467
|
-
|
|
12468
|
-
const
|
|
12469
|
-
const
|
|
12470
|
-
if (
|
|
12471
|
-
|
|
12472
|
-
|
|
12473
|
-
|
|
12474
|
-
|
|
12475
|
-
|
|
12476
|
-
|
|
12477
|
-
|
|
12478
|
-
|
|
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
|
+
});
|
|
12479
12839
|
try {
|
|
12480
|
-
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
}, "Socket dropped while registering LIVE; discarding it");
|
|
12486
|
-
return;
|
|
12487
|
-
}
|
|
12488
|
-
(await this.remote.getClient().liveOf(uuid)).subscribe((message) => {
|
|
12489
|
-
if (message.action === "KILLED") return;
|
|
12490
|
-
if (message.action !== "CREATE" && message.action !== "UPDATE") return;
|
|
12491
|
-
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
|
|
12492
12845
|
});
|
|
12493
|
-
if (generation !== this.connectionGeneration) return;
|
|
12494
|
-
this.liveByTable.set(table, uuid);
|
|
12495
|
-
this.logger.info({
|
|
12496
|
-
table,
|
|
12497
|
-
Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
|
|
12498
|
-
}, "LIVE SELECT started");
|
|
12499
12846
|
} catch (e) {
|
|
12500
12847
|
this.logger.warn({
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
Category: "sp00ky-client::CrdtManager::
|
|
12504
|
-
}, "
|
|
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;
|
|
12505
12854
|
}
|
|
12506
|
-
|
|
12507
|
-
|
|
12508
|
-
|
|
12509
|
-
|
|
12510
|
-
|
|
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) {
|
|
12511
12874
|
try {
|
|
12512
|
-
await
|
|
12513
|
-
|
|
12514
|
-
|
|
12515
|
-
|
|
12516
|
-
|
|
12517
|
-
|
|
12518
|
-
|
|
12519
|
-
|
|
12520
|
-
|
|
12521
|
-
applyRow(table, row) {
|
|
12522
|
-
this.dispatchRow(table, row);
|
|
12523
|
-
}
|
|
12524
|
-
/** Dispatch a parent-row LIVE event to every open CrdtField on that
|
|
12525
|
-
* record. Each open field reads its slice of the row directly — the
|
|
12526
|
-
* CRDT snapshot is a column on the parent now, so there is no
|
|
12527
|
-
* follow-up subquery. */
|
|
12528
|
-
dispatchRow(table, row) {
|
|
12529
|
-
const id = row.id != null ? String(row.id) : "";
|
|
12530
|
-
if (!id) return;
|
|
12531
|
-
const rowKeyPrefix = `${table}:${id}:`;
|
|
12532
|
-
for (const [key, crdtField] of this.fields) {
|
|
12533
|
-
if (!key.startsWith(rowKeyPrefix)) continue;
|
|
12534
|
-
const fieldName = key.slice(rowKeyPrefix.length);
|
|
12535
|
-
const cursorsEnabled = this.fieldHasCursor(table, fieldName);
|
|
12536
|
-
const slice = row[fieldName];
|
|
12537
|
-
const snapshot = this.extractSnapshot(slice, cursorsEnabled);
|
|
12538
|
-
if (snapshot) crdtField.importRemote(snapshot);
|
|
12539
|
-
if (cursorsEnabled && slice && typeof slice === "object") {
|
|
12540
|
-
const cursors = slice.cursors;
|
|
12541
|
-
if (cursors && typeof cursors === "object") for (const [sid, blob] of Object.entries(cursors)) {
|
|
12542
|
-
if (sid === this.sessionId) continue;
|
|
12543
|
-
if (typeof blob === "string" && blob.length > 0) crdtField.importRemoteCursor(blob);
|
|
12544
|
-
}
|
|
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;
|
|
12545
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;
|
|
12546
12891
|
}
|
|
12547
12892
|
}
|
|
12548
|
-
|
|
12549
|
-
|
|
12550
|
-
|
|
12551
|
-
|
|
12552
|
-
async fetchAndDispatchRow(table, id) {
|
|
12893
|
+
localId(recordId, field) {
|
|
12894
|
+
return new RecordId$1(LOCAL_TABLE, `${recordId}|${field}`);
|
|
12895
|
+
}
|
|
12896
|
+
async readLocal(recordId, field) {
|
|
12553
12897
|
try {
|
|
12554
|
-
const
|
|
12555
|
-
|
|
12556
|
-
|
|
12557
|
-
|
|
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
|
+
};
|
|
12558
12904
|
} catch (e) {
|
|
12559
|
-
this.logger.
|
|
12560
|
-
error: e,
|
|
12561
|
-
|
|
12562
|
-
|
|
12563
|
-
Category: "sp00ky-client::CrdtManager::fetchAndDispatchRow"
|
|
12564
|
-
}, "Failed to fetch parent row for CRDT hydration");
|
|
12565
|
-
}
|
|
12566
|
-
}
|
|
12567
|
-
/** Schema lookup: does `<table>.<field>` carry a `@cursor` annotation?
|
|
12568
|
-
* Determines the on-disk shape (plain snapshot vs. `{ state, cursors }`). */
|
|
12569
|
-
fieldHasCursor(table, field) {
|
|
12570
|
-
return !!this.schema.tables.find((t) => t.name === table)?.columns[field]?.cursor;
|
|
12571
|
-
}
|
|
12572
|
-
/** Pull the LoroDoc snapshot bytes out of a row slice. For `@crdt`-only
|
|
12573
|
-
* the slice IS the snapshot (Uint8Array); for `@crdt @cursor` it's
|
|
12574
|
-
* `{ state, cursors }` where `state` carries the snapshot bytes. */
|
|
12575
|
-
extractSnapshot(value, cursorsEnabled) {
|
|
12576
|
-
const asBytes = (v) => {
|
|
12577
|
-
if (v instanceof Uint8Array) return v.length > 0 ? v : void 0;
|
|
12578
|
-
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
|
12579
|
-
if (ArrayBuffer.isView(v)) {
|
|
12580
|
-
const view = v;
|
|
12581
|
-
return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
12582
|
-
}
|
|
12583
|
-
if (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "number")) return Uint8Array.from(v);
|
|
12584
|
-
};
|
|
12585
|
-
if (cursorsEnabled) {
|
|
12586
|
-
if (value && typeof value === "object" && !(value instanceof Uint8Array)) return asBytes(value.state);
|
|
12587
|
-
return;
|
|
12905
|
+
this.logger.debug({
|
|
12906
|
+
error: String(e),
|
|
12907
|
+
Category: "sp00ky-client::CrdtManager::readLocal"
|
|
12908
|
+
}, "No local CRDT state");
|
|
12588
12909
|
}
|
|
12589
|
-
return
|
|
12910
|
+
return {
|
|
12911
|
+
rev: 0,
|
|
12912
|
+
peers: {}
|
|
12913
|
+
};
|
|
12590
12914
|
}
|
|
12591
|
-
|
|
12592
|
-
|
|
12593
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
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
|
|
12600
12971
|
});
|
|
12601
|
-
|
|
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");
|
|
12602
12979
|
}
|
|
12603
12980
|
}
|
|
12604
|
-
|
|
12605
|
-
|
|
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
|
+
}
|
|
12606
12996
|
}
|
|
12607
|
-
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
12611
|
-
|
|
12612
|
-
|
|
12613
|
-
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_]*`);
|
|
12614
|
-
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
12615
|
-
if (!tableSchema) throw new Error(`openCrdtField: unknown table '${table}'. Available: ${this.schema.tables.map((t) => t.name).join(", ")}`);
|
|
12616
|
-
const column = tableSchema.columns[field];
|
|
12617
|
-
if (!column) throw new Error(`openCrdtField: '${table}.${field}' is not in the schema. Available fields: ${Object.keys(tableSchema.columns).join(", ")}`);
|
|
12618
|
-
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
|
+
}
|
|
12619
13003
|
}
|
|
12620
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
|
+
}
|
|
12621
13017
|
|
|
12622
13018
|
//#endregion
|
|
12623
13019
|
//#region src/services/persistence/localstorage.ts
|
|
@@ -14839,7 +15235,7 @@ function createServices(config) {
|
|
|
14839
15235
|
streamProcessor.configureCircuitPersistence(config.persistCircuit ?? config.localEngine === "sqlite", config.circuitCheckpointMs);
|
|
14840
15236
|
streamProcessor.configureProjection(config.circuitProjection ?? true);
|
|
14841
15237
|
const migrator = new LocalMigrator(local, logger);
|
|
14842
|
-
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ??
|
|
15238
|
+
const crdt = new CrdtManager(config.schema, local, remote, logger, config.crdtDebounceMs ?? 300);
|
|
14843
15239
|
const auth = new AuthService(config.schema, remote, persistence, logger);
|
|
14844
15240
|
const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
|
|
14845
15241
|
return {
|
|
@@ -14865,7 +15261,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
14865
15261
|
return new TabsCoordinator({
|
|
14866
15262
|
tabId: s.tabId,
|
|
14867
15263
|
fingerprint: computeTabsFingerprint({
|
|
14868
|
-
coreVersion: "0.0.1-canary.
|
|
15264
|
+
coreVersion: "0.0.1-canary.324",
|
|
14869
15265
|
schemaHash: hash53(config.schemaSurql),
|
|
14870
15266
|
endpoint: config.database.endpoint ?? "",
|
|
14871
15267
|
namespace: config.database.namespace,
|
|
@@ -15005,6 +15401,7 @@ function createAdapters(config, s, host, lateModules) {
|
|
|
15005
15401
|
],
|
|
15006
15402
|
schemaHash: String(hash53(config.schemaSurql)),
|
|
15007
15403
|
pendingIds: new Set(pendingIds),
|
|
15404
|
+
columnFingerprints: columnFingerprints(config.schema.tables),
|
|
15008
15405
|
onVersions: (_table, entries) => host.dispatch({
|
|
15009
15406
|
type: "VersionsPrimed",
|
|
15010
15407
|
entries
|
|
@@ -15535,6 +15932,28 @@ var Sp00kyClient = class {
|
|
|
15535
15932
|
subscribe: (hash, cb, options) => this.runtime.subscribe(hash, cb, options)
|
|
15536
15933
|
};
|
|
15537
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
|
+
});
|
|
15538
15957
|
this.featureFlags = new FeatureFlagModule({
|
|
15539
15958
|
dataModule: liveQueryPort,
|
|
15540
15959
|
sync: remoteRegisterPort,
|
|
@@ -15937,11 +16356,20 @@ var Sp00kyClient = class {
|
|
|
15937
16356
|
appRelease(app, options) {
|
|
15938
16357
|
return this.appReleases.release(app, options);
|
|
15939
16358
|
}
|
|
15940
|
-
|
|
15941
|
-
|
|
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);
|
|
15942
16368
|
}
|
|
16369
|
+
/** Close a `@crdt` field. The last close resolves once its unsent edits
|
|
16370
|
+
* are queued in the outbox. */
|
|
15943
16371
|
closeCrdtField(table, recordId, field) {
|
|
15944
|
-
this.services.crdt.close(table, recordId, field);
|
|
16372
|
+
return this.services.crdt.close(table, recordId, field);
|
|
15945
16373
|
}
|
|
15946
16374
|
bucket(name) {
|
|
15947
16375
|
return new BucketHandle(name, this.services.remote, this.services.blobs, {
|