@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.
Files changed (33) hide show
  1. package/AGENTS.md +4 -3
  2. package/dist/index.d.ts +156 -161
  3. package/dist/index.js +910 -482
  4. package/dist/types.d.ts +17 -6
  5. package/package.json +4 -4
  6. package/src/boot/preload.saga.test.ts +29 -0
  7. package/src/boot/preload.saga.ts +11 -2
  8. package/src/client/services.ts +3 -1
  9. package/src/client/sp00ky-client.test.ts +4 -1
  10. package/src/client/sp00ky-client.ts +37 -4
  11. package/src/modules/crdt/acceptance.ts +75 -0
  12. package/src/modules/crdt/bytes.ts +50 -0
  13. package/src/modules/crdt/crdt-field.ts +48 -255
  14. package/src/modules/crdt/crdt.test.ts +365 -0
  15. package/src/modules/crdt/hash.ts +103 -0
  16. package/src/modules/crdt/index.ts +610 -363
  17. package/src/modules/crdt/mirror.ts +37 -0
  18. package/src/mutation/push.saga.test.ts +16 -0
  19. package/src/mutation/push.saga.ts +9 -1
  20. package/src/query/anon-query-isolation.integration.test.ts +31 -0
  21. package/src/query/env.ts +9 -0
  22. package/src/services/database/cache-engine.ts +10 -0
  23. package/src/services/database/local-migrator.ts +5 -3
  24. package/src/services/database/sqlite-cache-engine.ts +28 -0
  25. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  26. package/src/services/database/sqlite-column-reset.ts +91 -0
  27. package/src/services/stream-processor/index.ts +24 -0
  28. package/src/services/stream-processor/stream-processor.prime.test.ts +36 -0
  29. package/src/testing/fake-services.ts +1 -1
  30. package/src/types.ts +7 -6
  31. package/src/utils/surql.ts +3 -4
  32. package/src/modules/crdt/crdt-hydration.test.ts +0 -210
  33. 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.322";
2192
- const WASM_VERSION = "0.0.1-canary.322";
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
- 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];
@@ -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
- /** Callback set by the editor to receive remote cursor updates.
12096
- * Any cursor data that arrived before this callback was set will be replayed. */
12097
- set onCursorUpdate(cb) {
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
- 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_]*`);
12122
- this.logger = logger ?? null;
12327
+ this.kind = kind;
12123
12328
  this.cursorsEnabled = cursorsEnabled;
12124
- this.doc = new LoroDocClass();
12125
- if (initialState && initialState.length > 0) try {
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 LoroDoc was loaded from saved CRDT state */
12335
+ /** Whether the document holds anything yet (local state or edits). */
12140
12336
  hasContent() {
12141
- return this.loadedFromCrdt;
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
- * Stop syncing this field. Flushes one final remote push by default so the
12156
- * last keystrokes aren't lost. Pass `{ flush: false }` on a bucket switch —
12157
- * the remote session already belongs to the NEXT user, and pushing this
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
- importRemote(state) {
12173
- if (Date.now() - this.lastPushTime < this.remoteDebounceMs + 200) return;
12174
- try {
12175
- this.doc.import(state);
12176
- this.persistLocal();
12177
- } catch (e) {
12178
- this.logger?.warn({
12179
- error: e,
12180
- Category: "sp00ky-client::CrdtField::importRemote"
12181
- }, "Failed to import remote CRDT state");
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
- /** Import remote cursor state (called by CrdtManager from LIVE SELECT) */
12212
- importRemoteCursor(base64State) {
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
- async pushToRemote() {
12255
- if (!this.remote || !this.recordId) return;
12256
- this.lastPushTime = Date.now();
12257
- try {
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
- function decodeBase64(b64) {
12276
- if (typeof atob === "function") {
12277
- const binary = atob(b64);
12278
- const bytes = new Uint8Array(binary.length);
12279
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
12280
- return bytes;
12281
- }
12282
- 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;
12283
12423
  }
12284
- function encodeBase64(bytes) {
12285
- if (typeof btoa === "function") {
12286
- let binary = "";
12287
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
12288
- 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}`);
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
- * CrdtManager manages active CrdtField instances and their sync channels.
12310
- *
12311
- * Collaborative state lives in two dedicated tables (defined in
12312
- * `apps/cli/src/meta_tables_remote.surql`):
12313
- * - `_00_crdt` { record_id, field, state } — one row per (record, field)
12314
- * - `_00_cursor` { record_id, session_id, field, state } — one row per
12315
- * (record, session, field)
12316
- *
12317
- * Splitting them off the parent row is what makes offline edits mergeable:
12318
- * each (record, field) gets its own row, so concurrent offline writes don't
12319
- * collide on the parent's last-write-wins semantics.
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
- fields = /* @__PURE__ */ new Map();
12331
- liveByTable = /* @__PURE__ */ new Map();
12332
- pendingLive = /* @__PURE__ */ new Map();
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
- sessionId = "";
12338
- 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) {
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.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;
12345
12500
  }
12346
12501
  /**
12347
- * Re-establish table LIVEs after a socket drop.
12348
- *
12349
- * A LIVE subscription lives and dies with its WebSocket session, and
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
- subscribeToReconnect() {
12360
- const onDrop = () => {
12361
- this.connectionGeneration++;
12362
- for (const table of this.liveByTable.keys()) this.staleTables.add(table);
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.connectionUnsubscribes = [];
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 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}.
12397
12520
  *
12398
- * @param table - Table name
12399
- * @param recordId - Full record ID (e.g., "thread:abc")
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, fallbackText) {
12405
- this.assertCrdtField(table, field);
12406
- const cursorsEnabled = this.fieldHasCursor(table, field);
12407
- const key = this.makeKey(table, recordId, field);
12408
- let crdtField = this.fields.get(key);
12409
- if (crdtField) return crdtField;
12410
- let initialCrdtState;
12411
- try {
12412
- const [result] = await this.local.query(`SELECT VALUE ${field} FROM ONLY $id`, { id: parseRecordIdString(recordId) });
12413
- const snapshot = this.extractSnapshot(result, cursorsEnabled);
12414
- if (snapshot) initialCrdtState = snapshot;
12415
- } catch (e) {
12416
- this.logger.info({
12417
- error: String(e),
12418
- recordId,
12419
- field,
12420
- Category: "sp00ky-client::CrdtManager::open"
12421
- }, "No existing CRDT state found in local cache (continuing with empty doc)");
12422
- }
12423
- const { LoroDoc } = await loadLoro();
12424
- crdtField = new CrdtField(field, cursorsEnabled, LoroDoc, initialCrdtState, this.logger);
12425
- crdtField.startSync(this.local, this.remote, recordId, this.sessionId, this.debounceMs);
12426
- this.fields.set(key, crdtField);
12427
- 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({
12428
12644
  key,
12429
- hasInitialState: !!initialCrdtState,
12430
- hasFallback: !!fallbackText,
12645
+ peer: this.peer,
12431
12646
  Category: "sp00ky-client::CrdtManager::open"
12432
- }, "CrdtField opened");
12433
- this.ensureTableSubscription(table);
12434
- if (!initialCrdtState) this.fetchAndDispatchRow(table, recordId);
12435
- return crdtField;
12647
+ }, "CRDT field opened");
12648
+ return session;
12436
12649
  }
12437
- close(table, recordId, field) {
12438
- const key = this.makeKey(table, recordId, field);
12439
- const crdtField = this.fields.get(key);
12440
- if (crdtField) {
12441
- crdtField.stopSync();
12442
- 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);
12443
12689
  }
12444
- if (!this.hasOpenFieldFor(table)) {
12445
- this.killTableSubscription(table);
12446
- 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;
12447
12707
  }
12448
- this.logger.debug({
12449
- key,
12450
- Category: "sp00ky-client::CrdtManager::close"
12451
- }, "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);
12452
12739
  }
12453
12740
  /**
12454
- * Close every open field + table LIVE. Fields flush a final remote push by
12455
- * default; pass `{ flush: false }` on a bucket switch, where that flush
12456
- * 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.
12457
12746
  */
12458
- closeAll(options = {}) {
12459
- for (const [_, field] of this.fields) field.stopSync(options);
12460
- this.fields.clear();
12461
- for (const table of Array.from(this.liveByTable.keys())) this.killTableSubscription(table);
12462
- this.staleTables.clear();
12463
- }
12464
- /** Ensure a single `LIVE SELECT * FROM <table>` is running, shared across
12465
- * every open CrdtField on `table`. */
12466
- async ensureTableSubscription(table) {
12467
- if (this.liveByTable.has(table)) return;
12468
- const generation = this.connectionGeneration;
12469
- const pending = this.pendingLive.get(table);
12470
- if (pending) {
12471
- if (pending.generation === generation) return pending.promise;
12472
- await pending.promise.catch(() => {});
12473
- if (this.liveByTable.has(table)) return;
12474
- if (generation !== this.connectionGeneration) return;
12475
- const successor = this.pendingLive.get(table);
12476
- if (successor && successor.generation === generation) return successor.promise;
12477
- }
12478
- 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
+ });
12479
12839
  try {
12480
- const [uuid] = await this.remote.query(`LIVE SELECT * FROM ${table}`);
12481
- if (generation !== this.connectionGeneration) {
12482
- this.logger.debug({
12483
- table,
12484
- Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
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
- error: e,
12502
- table,
12503
- Category: "sp00ky-client::CrdtManager::ensureTableSubscription"
12504
- }, "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;
12505
12854
  }
12506
- })();
12507
- this.pendingLive.set(table, {
12508
- promise: start,
12509
- generation
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 start;
12513
- } finally {
12514
- this.pendingLive.delete(table);
12515
- }
12516
- }
12517
- /** Apply a parent-row payload from a non-LIVE source (e.g. the
12518
- * list_ref-driven sync engine, when the cross-user LIVE on the
12519
- * parent table is filtered out by the SurrealDB cross-session
12520
- * permission gap). Same semantics as the internal `dispatchRow`. */
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
- /** One-shot remote fetch for a row whose CRDT field hasn't synced
12549
- * locally yet (fresh device, memory-backed local DB after reload, …).
12550
- * Used by `open()` when the local read came up empty. Subsequent
12551
- * cross-browser updates ride `dispatchRow` via the parent LIVE feed. */
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 recordId = parseRecordIdString(id);
12555
- const [row] = await this.remote.query(`SELECT * FROM ONLY $id`, { id: recordId });
12556
- if (!row || typeof row !== "object") return;
12557
- 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
+ };
12558
12904
  } catch (e) {
12559
- this.logger.warn({
12560
- error: e,
12561
- table,
12562
- id,
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 asBytes(value);
12910
+ return {
12911
+ rev: 0,
12912
+ peers: {}
12913
+ };
12590
12914
  }
12591
- killTableSubscription(table) {
12592
- const uuid = this.liveByTable.get(table);
12593
- if (uuid) {
12594
- this.remote.query("KILL $uuid", { uuid }).catch((err) => {
12595
- this.logger.debug({
12596
- err,
12597
- table,
12598
- Category: "sp00ky-client::CrdtManager::killTableSubscription"
12599
- }, "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
12600
12971
  });
12601
- 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");
12602
12979
  }
12603
12980
  }
12604
- makeKey(table, recordId, field) {
12605
- 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
+ }
12606
12996
  }
12607
- /**
12608
- * Throws if `<table>.<field>` is not annotated `@crdt` in the schema. Catches
12609
- * typos, removed annotations, and stale schema codegen at the call site instead
12610
- * of silently producing a non-CRDT writer.
12611
- */
12612
- assertCrdtField(table, field) {
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 ?? 500);
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.322",
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
- async openCrdtField(table, recordId, field, fallbackText) {
15941
- 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);
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, {