@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/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.323";
2192
- const WASM_VERSION = "0.0.1-canary.323";
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
- if ((yield fx.state.read((s) => s.queries.get(hash)?.lifecycle.phase)) !== "cold") return {
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 and debounced patches not yet in the outbox. Cached
8845
- * rows refill from the server; these exist nowhere else.
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
- /** Callback set by the editor to receive remote cursor updates.
12209
- * Any cursor data that arrived before this callback was set will be replayed. */
12210
- set onCursorUpdate(cb) {
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
- if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(fieldName)) throw new Error(`CrdtField: refusing unsafe field identifier '${fieldName}' — must match [a-zA-Z_][a-zA-Z0-9_]*`);
12235
- this.logger = logger ?? null;
12327
+ this.kind = kind;
12236
12328
  this.cursorsEnabled = cursorsEnabled;
12237
- this.doc = new LoroDocClass();
12238
- if (initialState && initialState.length > 0) try {
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 LoroDoc was loaded from saved CRDT state */
12335
+ /** Whether the document holds anything yet (local state or edits). */
12253
12336
  hasContent() {
12254
- return this.loadedFromCrdt;
12337
+ return this.doc.oplogVersion().length() > 0;
12255
12338
  }
12256
- startSync(local, remote, recordId, sessionId, debounceMs) {
12257
- this.local = local;
12258
- this.remote = remote;
12259
- this.recordId = recordId;
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
- * Stop syncing this field. Flushes one final remote push by default so the
12269
- * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
12270
- * the remote session already belongs to the NEXT user, and pushing this
12271
- * (previous user's) snapshot under it would clobber the record remotely.
12272
- */
12273
- stopSync(options = {}) {
12274
- const flush = options.flush ?? true;
12275
- if (this.unsubscribe) {
12276
- this.unsubscribe();
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
- /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
12325
- importRemoteCursor(base64State) {
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
- async pushToRemote() {
12368
- if (!this.remote || !this.recordId) return;
12369
- this.lastPushTime = Date.now();
12370
- try {
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
- function decodeBase64(b64) {
12389
- if (typeof atob === "function") {
12390
- const binary = atob(b64);
12391
- const bytes = new Uint8Array(binary.length);
12392
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
12393
- return bytes;
12394
- }
12395
- return new Uint8Array(Buffer.from(b64, "base64"));
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 encodeBase64(bytes) {
12398
- if (typeof btoa === "function") {
12399
- let binary = "";
12400
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
12401
- return btoa(binary);
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
- * CrdtManager manages active CrdtField instances and their sync channels.
12423
- *
12424
- * Collaborative state lives in two dedicated tables (defined in
12425
- * `apps/cli/src/meta_tables_remote.surql`):
12426
- * - `_00_crdt` { record_id, field, state } — one row per (record, field)
12427
- * - `_00_cursor` { record_id, session_id, field, state } — one row per
12428
- * (record, session, field)
12429
- *
12430
- * Splitting them off the parent row is what makes offline edits mergeable:
12431
- * each (record, field) gets its own row, so concurrent offline writes don't
12432
- * collide on the parent's last-write-wins semantics.
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
- fields = /* @__PURE__ */ new Map();
12444
- liveByTable = /* @__PURE__ */ new Map();
12445
- pendingLive = /* @__PURE__ */ new Map();
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
- sessionId = "";
12451
- constructor(schema, local, remote, logger, debounceMs = 500) {
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.subscribeToReconnect();
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
- * Re-establish table LIVEs after a socket drop.
12461
- *
12462
- * A LIVE subscription lives and dies with its WebSocket session, and
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
- subscribeToReconnect() {
12473
- const onDrop = () => {
12474
- this.connectionGeneration++;
12475
- for (const table of this.liveByTable.keys()) this.staleTables.add(table);
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.connectionUnsubscribes = [];
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 CRDT field for collaborative editing.
12518
+ * Open a collaborative field. Every open of the same field shares one
12519
+ * document; close each open with {@link close}.
12510
12520
  *
12511
- * @param table - Table name
12512
- * @param recordId - Full record ID (e.g., "thread:abc")
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, fallbackText) {
12518
- this.assertCrdtField(table, field);
12519
- const cursorsEnabled = this.fieldHasCursor(table, field);
12520
- const key = this.makeKey(table, recordId, field);
12521
- let crdtField = this.fields.get(key);
12522
- if (crdtField) return crdtField;
12523
- let initialCrdtState;
12524
- try {
12525
- const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
12526
- const snapshot = this.extractSnapshot(result, cursorsEnabled);
12527
- if (snapshot) initialCrdtState = snapshot;
12528
- } catch (e) {
12529
- this.logger.info({
12530
- error: String(e),
12531
- recordId,
12532
- field,
12533
- Category: "sp00ky-client::CrdtManager::open"
12534
- }, "No existing CRDT state found in local cache (continuing with empty doc)");
12535
- }
12536
- const { LoroDoc } = await loadLoro();
12537
- crdtField = new CrdtField(field, cursorsEnabled, LoroDoc, initialCrdtState, this.logger);
12538
- crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
12539
- this.fields.set(key, crdtField);
12540
- this.logger.info({
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
- hasInitialState: !!initialCrdtState,
12543
- hasFallback: !!fallbackText,
12645
+ peer: this.peer,
12544
12646
  Category: "sp00ky-client::CrdtManager::open"
12545
- }, "CrdtField opened");
12546
- this.ensureTableSubscription(table);
12547
- if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
12548
- return crdtField;
12647
+ }, "CRDT field opened");
12648
+ return session;
12549
12649
  }
12550
- close(table, recordId, field) {
12551
- const key = this.makeKey(table, recordId, field);
12552
- const crdtField = this.fields.get(key);
12553
- if (crdtField) {
12554
- crdtField.stopSync();
12555
- this.fields.delete(key);
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 (!this.hasOpenFieldFor(table)) {
12558
- this.killTableSubscription(table);
12559
- this.staleTables.delete(table);
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
- this.logger.debug({
12562
- key,
12563
- Category: "sp00ky-client::CrdtManager::close"
12564
- }, "CrdtField closed");
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
- * Close every open field + table LIVE. Fields flush a final remote push by
12568
- * default; pass `{ flush: false }` on a bucket switch, where that flush
12569
- * would push the previous user's snapshot under the next user's session.
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
- closeAll(options = {}) {
12572
- for (const [_, field] of this.fields) field.stopSync(options);
12573
- this.fields.clear();
12574
- for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
12575
- this.staleTables.clear();
12576
- }
12577
- /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
12578
- * every open CrdtField on `table`. */
12579
- async ensureTableSubscription(table) {
12580
- if (this.liveByTable.has(table)) return;
12581
- const generation = this.connectionGeneration;
12582
- const pending = this.pendingLive.get(table);
12583
- if (pending) {
12584
- if (pending.generation === generation) return pending.promise;
12585
- await pending.promise.catch(() => {});
12586
- if (this.liveByTable.has(table)) return;
12587
- if (generation !== this.connectionGeneration) return;
12588
- const successor = this.pendingLive.get(table);
12589
- if (successor && successor.generation === generation) return successor.promise;
12590
- }
12591
- const start = (async () => {
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
- const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
12594
- if (generation !== this.connectionGeneration) {
12595
- this.logger.debug({
12596
- table,
12597
- Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
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
- error: e,
12615
- table,
12616
- Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
12617
- }, "Failed to start LIVE SELECT");
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
- this.pendingLive.set(table, {
12621
- promise: start,
12622
- generation
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 start;
12626
- } finally {
12627
- this.pendingLive.delete(table);
12628
- }
12629
- }
12630
- /** Apply a parent-row payload from a non-LIVE source (e.g. the
12631
- * list_ref-driven sync engine, when the cross-user LIVE on the
12632
- * parent table is filtered out by the SurrealDB cross-session
12633
- * permission gap). Same semantics as the internal `dispatchRow`. */
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
- /** One-shot remote fetch for a row whose CRDT field hasn't synced
12662
- * locally yet (fresh device, memory-backed local DB after reload, …).
12663
- * Used by `open()` when the local read came up empty. Subsequent
12664
- * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
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 recordId = parseRecordIdString(id);
12668
- const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
12669
- if (!row || typeof row !== "object") return;
12670
- this.dispatchRow(table, row);
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.warn({
12673
- error: e,
12674
- table,
12675
- id,
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 asBytes(value);
12910
+ return {
12911
+ rev: 0,
12912
+ peers: {}
12913
+ };
12703
12914
  }
12704
- killTableSubscription(table) {
12705
- const uuid = this.liveByTable.get(table);
12706
- if (uuid) {
12707
- this.remote.query("KILL $uuid", { uuid }).catch((err) => {
12708
- this.logger.debug({
12709
- err,
12710
- table,
12711
- Category: "sp00ky-client::CrdtManager::killTableSubscription"
12712
- }, "KILL of table LIVE failed (already closed?)");
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
- this.liveByTable.delete(table);
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
- makeKey(table, recordId, field) {
12718
- return `${table}:${recordId}:${field}`;
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
- * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
12722
- * typos, removed annotations, and stale schema codegen at the call site instead
12723
- * of silently producing a non-CRDT writer.
12724
- */
12725
- assertCrdtField(table, field) {
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 ?? 500);
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.323",
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
- async openCrdtField(table, recordId, field, fallbackText) {
16055
- return this.services.crdt.open(table, recordId, field, fallbackText);
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, {