@korajs/store 1.0.0-beta.13 → 1.0.0-beta.14

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 (53) hide show
  1. package/dist/adapters/better-sqlite3.cjs.map +1 -1
  2. package/dist/adapters/better-sqlite3.js +1 -1
  3. package/dist/adapters/indexeddb.cjs.map +1 -1
  4. package/dist/adapters/indexeddb.js +4 -4
  5. package/dist/adapters/sqlite-wasm-worker.cjs +67 -15
  6. package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
  7. package/dist/adapters/sqlite-wasm-worker.js +65 -17
  8. package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
  9. package/dist/adapters/sqlite-wasm.cjs.map +1 -1
  10. package/dist/adapters/sqlite-wasm.js +5 -5
  11. package/dist/{backup-4NUPDEMB.js → backup-IHKVMUAM.js} +5 -5
  12. package/dist/{chunk-Y5LRH7KI.js → chunk-5AQSGMJV.js} +151 -21
  13. package/dist/chunk-5AQSGMJV.js.map +1 -0
  14. package/dist/{chunk-W5F2LWG6.js → chunk-5JMGMPUE.js} +2 -2
  15. package/dist/{chunk-HZTDBLDB.js → chunk-BILSA6XZ.js} +4 -3
  16. package/dist/{chunk-HZTDBLDB.js.map → chunk-BILSA6XZ.js.map} +1 -1
  17. package/dist/{chunk-QDUSJMFV.js → chunk-BQSIL6TT.js} +5 -5
  18. package/dist/{chunk-MCQKEHOD.js → chunk-KK3GBONL.js} +2 -2
  19. package/dist/{chunk-32EDKWMI.js → chunk-KTVNANUH.js} +2 -2
  20. package/dist/{chunk-SJP2HTYZ.js → chunk-QW4UU7NG.js} +2 -2
  21. package/dist/{chunk-DUKTWVZW.js → chunk-SRHDJF5B.js} +13 -2
  22. package/dist/{chunk-DUKTWVZW.js.map → chunk-SRHDJF5B.js.map} +1 -1
  23. package/dist/{chunk-P4V375FY.js → chunk-UC2LGFPV.js} +4 -4
  24. package/dist/index.cjs +203 -47
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +96 -6
  27. package/dist/index.d.ts +96 -6
  28. package/dist/index.js +55 -39
  29. package/dist/index.js.map +1 -1
  30. package/dist/internal.cjs +1 -0
  31. package/dist/internal.cjs.map +1 -1
  32. package/dist/internal.d.cts +2 -2
  33. package/dist/internal.d.ts +2 -2
  34. package/dist/internal.js +3 -3
  35. package/dist/{restore-FR7XIKT2.js → restore-MNDPR557.js} +4 -4
  36. package/dist/{sql-builder-C4u55jKW.d.cts → sql-builder-D0tC3sv9.d.cts} +8 -1
  37. package/dist/{sql-builder-CGckdBDL.d.ts → sql-builder-D5UMzHEo.d.ts} +8 -1
  38. package/dist/sqlite-wasm-channel-QCCNBYCU.js +12 -0
  39. package/dist/store-HNGX4KTT.js +16 -0
  40. package/package.json +2 -2
  41. package/dist/chunk-Y5LRH7KI.js.map +0 -1
  42. package/dist/sqlite-wasm-channel-MLHLKEED.js +0 -12
  43. package/dist/store-IMWKZLWB.js +0 -16
  44. /package/dist/{backup-4NUPDEMB.js.map → backup-IHKVMUAM.js.map} +0 -0
  45. /package/dist/{chunk-W5F2LWG6.js.map → chunk-5JMGMPUE.js.map} +0 -0
  46. /package/dist/{chunk-QDUSJMFV.js.map → chunk-BQSIL6TT.js.map} +0 -0
  47. /package/dist/{chunk-MCQKEHOD.js.map → chunk-KK3GBONL.js.map} +0 -0
  48. /package/dist/{chunk-32EDKWMI.js.map → chunk-KTVNANUH.js.map} +0 -0
  49. /package/dist/{chunk-SJP2HTYZ.js.map → chunk-QW4UU7NG.js.map} +0 -0
  50. /package/dist/{chunk-P4V375FY.js.map → chunk-UC2LGFPV.js.map} +0 -0
  51. /package/dist/{restore-FR7XIKT2.js.map → restore-MNDPR557.js.map} +0 -0
  52. /package/dist/{sqlite-wasm-channel-MLHLKEED.js.map → sqlite-wasm-channel-QCCNBYCU.js.map} +0 -0
  53. /package/dist/{store-IMWKZLWB.js.map → store-HNGX4KTT.js.map} +0 -0
package/dist/index.cjs CHANGED
@@ -31,7 +31,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
32
 
33
33
  // src/errors.ts
34
- var import_core, QueryError, RecordNotFoundError, OptimisticLockError, AdapterError, StorageDurabilityError, SchemaVersionAheadError, StoreNotOpenError, WorkerInitError, WorkerTimeoutError, NoLeaderError, LeaderUnresponsiveError, BridgeTerminatedError, RequestAbortedError, StorageInUseError, UnsyncedDataError, StorageBackendMismatchError, PersistenceError;
34
+ var import_core, QueryError, RecordNotFoundError, OptimisticLockError, AdapterError, StorageDurabilityError, SchemaVersionAheadError, StoreNotOpenError, WorkerInitError, WorkerTimeoutError, NoLeaderError, LeaderUnresponsiveError, BridgeTerminatedError, RequestAbortedError, StorageInUseError, UnsyncedDataError, StorageBackendMismatchError, PersistenceError, NodeOwnedByAnotherUserError;
35
35
  var init_errors = __esm({
36
36
  "src/errors.ts"() {
37
37
  "use strict";
@@ -188,6 +188,16 @@ var init_errors = __esm({
188
188
  this.name = "PersistenceError";
189
189
  }
190
190
  };
191
+ NodeOwnedByAnotherUserError = class extends import_core.KoraError {
192
+ constructor(nodeId, principal) {
193
+ super(
194
+ "This device belongs to another account, so changes made while this account is signed in here cannot be saved: they would be uploaded as the device owner's. Sign in as the device's owner, or use this account on its own device.",
195
+ "NODE_OWNED_BY_ANOTHER_USER",
196
+ { nodeId, principal }
197
+ );
198
+ this.name = "NodeOwnedByAnotherUserError";
199
+ }
200
+ };
191
201
  }
192
202
  });
193
203
 
@@ -3628,6 +3638,7 @@ async function writeDeleteInTx(env, scope, collection, id, options = {}) {
3628
3638
  };
3629
3639
  }
3630
3640
  async function buildLocalOperation(env, scope, input) {
3641
+ env.assertLocalWriteAllowed?.();
3631
3642
  const sequenceNumber = await scope.sequence.take();
3632
3643
  const causalDeps = scope.causal.depsFor(input.collection, input.extraCausalDeps);
3633
3644
  const operation = await (0, import_core17.createOperation)(
@@ -3762,7 +3773,7 @@ var init_collection = __esm({
3762
3773
  init_execute_update();
3763
3774
  init_serializer();
3764
3775
  Collection = class {
3765
- constructor(name, definition, schema, adapter, clock, nodeId, onMutation, relationEnforcer, mutationHandler, causalTracker, secretKeyProvider, onStorageError, fold, maxOperationBytes) {
3776
+ constructor(name, definition, schema, adapter, clock, nodeId, onMutation, relationEnforcer, mutationHandler, causalTracker, secretKeyProvider, onStorageError, fold, maxOperationBytes, assertLocalWriteAllowed) {
3766
3777
  this.name = name;
3767
3778
  this.definition = definition;
3768
3779
  this.schema = schema;
@@ -3777,6 +3788,7 @@ var init_collection = __esm({
3777
3788
  this.onStorageError = onStorageError;
3778
3789
  this.fold = fold;
3779
3790
  this.maxOperationBytes = maxOperationBytes;
3791
+ this.assertLocalWriteAllowed = assertLocalWriteAllowed;
3780
3792
  }
3781
3793
  name;
3782
3794
  definition;
@@ -3792,6 +3804,7 @@ var init_collection = __esm({
3792
3804
  onStorageError;
3793
3805
  fold;
3794
3806
  maxOperationBytes;
3807
+ assertLocalWriteAllowed;
3795
3808
  /**
3796
3809
  * Point this collection at the store's new device identity after a node-id
3797
3810
  * rotation (RT-21). Internal: called by the owning Store only.
@@ -3819,7 +3832,8 @@ var init_collection = __esm({
3819
3832
  causalTracker: this.causalTracker,
3820
3833
  ...this.secretKeyProvider ? { secretKeyProvider: this.secretKeyProvider } : {},
3821
3834
  ...this.onStorageError ? { onStorageError: this.onStorageError } : {},
3822
- ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {}
3835
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
3836
+ ...this.assertLocalWriteAllowed ? { assertLocalWriteAllowed: this.assertLocalWriteAllowed } : {}
3823
3837
  };
3824
3838
  }
3825
3839
  async insert(data) {
@@ -5938,7 +5952,8 @@ var init_rebase_unsynced_operations = __esm({
5938
5952
  });
5939
5953
 
5940
5954
  // src/sync/rotate-node-id.ts
5941
- async function rotateUnsyncedOperationsInLog(adapter, schema, unsyncedOpIds, oldNodeId, newNodeId) {
5955
+ async function rotateUnsyncedOperationsInLog(adapter, schema, unsyncedOpIds, oldNodeId, newNodeId, options = {}) {
5956
+ const moveDatabaseNode = options.moveDatabaseNode !== false;
5942
5957
  const rotateIds = new Set(unsyncedOpIds);
5943
5958
  const idMapping = {};
5944
5959
  const rewritten = [];
@@ -6008,10 +6023,12 @@ async function rotateUnsyncedOperationsInLog(adapter, schema, unsyncedOpIds, old
6008
6023
  );
6009
6024
  }
6010
6025
  }
6011
- await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
6012
- newNodeId
6013
- ]);
6014
- await tx.execute("DELETE FROM _kora_meta WHERE key = ?", [NODE_TOKEN_META_KEY]);
6026
+ if (moveDatabaseNode) {
6027
+ await tx.execute("INSERT OR REPLACE INTO _kora_meta (key, value) VALUES ('node_id', ?)", [
6028
+ newNodeId
6029
+ ]);
6030
+ await tx.execute("DELETE FROM _kora_meta WHERE key = ?", [NODE_TOKEN_META_KEY]);
6031
+ }
6015
6032
  if (rewritten.length > 0) {
6016
6033
  await tx.execute(
6017
6034
  `INSERT INTO _kora_version_vector (node_id, sequence_number) VALUES (?, ?)
@@ -6173,7 +6190,9 @@ var init_transaction_context = __esm({
6173
6190
  ...this.config.secretKeyProvider ? { secretKeyProvider: this.config.secretKeyProvider } : {},
6174
6191
  ...this.mutationName !== void 0 ? { mutationName: this.mutationName } : {},
6175
6192
  ...this.config.beforeLocalDelete ? { beforeLocalDelete: this.config.beforeLocalDelete } : {},
6176
- ...this.config.fold ? { fold: this.config.fold } : {}
6193
+ ...this.config.fold ? { fold: this.config.fold } : {},
6194
+ ...this.config.maxOperationBytes !== void 0 ? { maxOperationBytes: this.config.maxOperationBytes } : {},
6195
+ ...this.config.assertLocalWriteAllowed ? { assertLocalWriteAllowed: this.config.assertLocalWriteAllowed } : {}
6177
6196
  };
6178
6197
  }
6179
6198
  ensureActive() {
@@ -7214,6 +7233,12 @@ var init_store = __esm({
7214
7233
  schema;
7215
7234
  adapter;
7216
7235
  configNodeId;
7236
+ /** The owner of the pinned node while it belongs to another user than the signed-in one (F9). */
7237
+ pinnedNodeOwnedBy = null;
7238
+ /** The sync server refused the pinned node as another user's (F9). */
7239
+ pinnedNodeRefusedByServer = false;
7240
+ /** The user of the last {@link bindPrincipal}. */
7241
+ signedInPrincipal = null;
7217
7242
  dbName;
7218
7243
  isolation;
7219
7244
  emitter;
@@ -7288,8 +7313,10 @@ var init_store = __esm({
7288
7313
  this.foldActive = false;
7289
7314
  await relaxValueDomainConstraints(this.adapter, this.schema);
7290
7315
  await dropLegacyIndexes(this.adapter, this.schema);
7316
+ const persistedNodeId = await this.readPersistedNodeId();
7291
7317
  this.nodeId = await this.loadOrGenerateNodeId();
7292
7318
  await registerLocalNode(this.adapter, this.nodeId);
7319
+ await this.registerUnregisteredAuthors(persistedNodeId);
7293
7320
  await this.scanLog("quick");
7294
7321
  await repairSequenceUniqueness(this.adapter, this.schema, this.nodeId);
7295
7322
  await seedTerminalRejectionsOnce(this.adapter);
@@ -7333,7 +7360,8 @@ var init_store = __esm({
7333
7360
  this.secretKeyProvider,
7334
7361
  (error) => this.reportStorageError(error),
7335
7362
  () => this.activeFold(),
7336
- this.maxOperationBytes
7363
+ this.maxOperationBytes,
7364
+ () => this.assertLocalWriteAllowed()
7337
7365
  );
7338
7366
  this.collections.set(name, col);
7339
7367
  }
@@ -8625,6 +8653,7 @@ var init_store = __esm({
8625
8653
  ...options?.extraCausalDeps ? { extraCausalDeps: options.extraCausalDeps } : {},
8626
8654
  ...this.secretKeyProvider ? { secretKeyProvider: this.secretKeyProvider } : {},
8627
8655
  ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
8656
+ assertLocalWriteAllowed: () => this.assertLocalWriteAllowed(),
8628
8657
  ...beforeLocalDelete ? { beforeLocalDelete: beforeLocalDelete.bind(this.localMutationHandler) } : {},
8629
8658
  onStorageError: (error) => this.reportStorageError(error)
8630
8659
  };
@@ -8894,6 +8923,46 @@ var init_store = __esm({
8894
8923
  await this.refoldRecordsOf(result.operations.map((op) => op.id));
8895
8924
  return result;
8896
8925
  }
8926
+ /**
8927
+ * Re-author another local node's never-acknowledged operations under a fresh node id
8928
+ * bound to `principal` (the user they belong to). Used when the sync server refuses an
8929
+ * adopted node whose writes belong to the signed-in user (the app assigned them, or
8930
+ * the node was created for them) but which no claims-aware server ever accepted: a
8931
+ * beta.12 database's node with history the server recorded no owner for. The store's
8932
+ * own node id, clock and counters are untouched; the fresh node is registered, so the
8933
+ * sync engine adopts it like any other local node.
8934
+ *
8935
+ * @param fromNodeId - The refused local node (never the store's own)
8936
+ * @param unsyncedOpIds - Ids of its operations the server never acknowledged
8937
+ * @param principal - The user the writes belong to (the fresh node's binding)
8938
+ * @returns The fresh node id and the rewritten operations (for the outbound queue)
8939
+ * @throws {KoraError} `NODE_ID_UNKNOWN` when `fromNodeId` is the store's own node or
8940
+ * not a local node
8941
+ */
8942
+ async reauthorLocalNode(fromNodeId, unsyncedOpIds, principal) {
8943
+ this.ensureOpen();
8944
+ const from = (await listLocalNodes(this.adapter)).find((node) => node.nodeId === fromNodeId);
8945
+ if (fromNodeId === this.nodeId || !from) {
8946
+ throw new import_core37.KoraError(
8947
+ `Node id "${fromNodeId}" is not another local node of this database, so its writes cannot be re-authored.`,
8948
+ "NODE_ID_UNKNOWN",
8949
+ { nodeId: fromNodeId, storeNodeId: this.nodeId }
8950
+ );
8951
+ }
8952
+ const result = await rotateUnsyncedOperationsInLog(
8953
+ this.adapter,
8954
+ this.schema,
8955
+ unsyncedOpIds,
8956
+ fromNodeId,
8957
+ (0, import_core37.generateUUIDv7)(),
8958
+ { moveDatabaseNode: false }
8959
+ );
8960
+ await registerLocalNode(this.adapter, result.nodeId);
8961
+ await setLocalNodePrincipal(this.adapter, result.nodeId, principal, "fresh");
8962
+ this.versionVector = await this.loadVersionVector();
8963
+ await this.refoldRecordsOf(result.operations.map((op) => op.id));
8964
+ return result;
8965
+ }
8897
8966
  /**
8898
8967
  * Move this database back to a node id it authored under before (RT-38): after the
8899
8968
  * sync server refused the current node, the device tries a node a returning user
@@ -8973,6 +9042,31 @@ var init_store = __esm({
8973
9042
  * @returns The node now in use and whether it changed
8974
9043
  */
8975
9044
  async bindPrincipal(principal) {
9045
+ const binding = await this.bindPrincipalToNode(principal);
9046
+ if (principal !== this.signedInPrincipal) this.pinnedNodeRefusedByServer = false;
9047
+ this.pinnedNodeOwnedBy = binding.conflict ? (await listLocalNodes(this.adapter)).find((node) => node.nodeId === this.nodeId)?.principal ?? null : null;
9048
+ this.signedInPrincipal = principal;
9049
+ return binding;
9050
+ }
9051
+ /**
9052
+ * The sync server refused this store's pinned node id because another user owns it
9053
+ * (`owned: true`), or later accepted it (`false`). A pinned node cannot move to a fresh
9054
+ * id, so writes made under it could only ever upload as its owner: while the server
9055
+ * says so, local writes are refused (F9). A no-op for a store whose node is not pinned
9056
+ * (it moves to a fresh node instead).
9057
+ *
9058
+ * @param owned - Whether the server reported the node owned by another user
9059
+ */
9060
+ setPinnedNodeOwnedElsewhere(owned) {
9061
+ if (!this.configNodeId) return;
9062
+ this.pinnedNodeRefusedByServer = owned;
9063
+ }
9064
+ /** Refuse a local write while the pinned node belongs to another user (F9). */
9065
+ assertLocalWriteAllowed() {
9066
+ if (this.pinnedNodeOwnedBy === null && !this.pinnedNodeRefusedByServer) return;
9067
+ throw new NodeOwnedByAnotherUserError(this.nodeId, this.signedInPrincipal);
9068
+ }
9069
+ async bindPrincipalToNode(principal) {
8976
9070
  this.ensureOpen();
8977
9071
  const previousNodeId = this.nodeId;
8978
9072
  const nodes = await listLocalNodes(this.adapter);
@@ -9352,6 +9446,8 @@ var init_store = __esm({
9352
9446
  const beforeLocalDelete = this.localMutationHandler?.beforeLocalDelete;
9353
9447
  const fold = this.activeFold();
9354
9448
  return new TransactionContext({
9449
+ ...this.maxOperationBytes !== void 0 ? { maxOperationBytes: this.maxOperationBytes } : {},
9450
+ assertLocalWriteAllowed: () => this.assertLocalWriteAllowed(),
9355
9451
  ...fold ? { fold } : {},
9356
9452
  schema: this.schema,
9357
9453
  adapter: this.adapter,
@@ -9411,8 +9507,9 @@ var init_store = __esm({
9411
9507
  * move backwards, and unsynced own writes are kept (see
9412
9508
  * `RestoreOptions.keepUnsyncedWrites`). Then {@link reloadFromDisk} runs.
9413
9509
  *
9414
- * A version-1 file is refused (`errorCode: 'BACKUP_FORMAT_OUTDATED'`); convert it with
9415
- * `convertBackupV1`.
9510
+ * A version-1 file (Kora 1.0.0-beta.12 and earlier) is converted first, exactly as
9511
+ * `convertBackupV1` does (`result.convertedFromVersion: 1`); one holding an operation
9512
+ * whose timestamp cannot be recovered is refused (`BACKUP_OPERATION_INVALID`).
9416
9513
  *
9417
9514
  * @param data - The backup data
9418
9515
  * @param options - Restore options (merge, collections, keepUnsyncedWrites, onProgress)
@@ -9425,13 +9522,19 @@ var init_store = __esm({
9425
9522
  const started = Date.now();
9426
9523
  const onProgress = options?.onProgress ?? (() => {
9427
9524
  });
9428
- const { BackupFormatError: BackupFormatError2, parseBackup: parseBackup2 } = await Promise.resolve().then(() => (init_backup(), backup_exports));
9525
+ const { BackupFormatError: BackupFormatError2, convertBackupV1: convertBackupV12, parseBackup: parseBackup2, readBackupManifest: readBackupManifest2 } = await Promise.resolve().then(() => (init_backup(), backup_exports));
9429
9526
  const { restoreMerge: restoreMerge2, restoreReplace: restoreReplace2 } = await Promise.resolve().then(() => (init_restore(), restore_exports));
9430
9527
  onProgress({ phase: "verifying", progress: 0, message: "Verifying backup" });
9431
9528
  let parsed;
9529
+ let convertedFromVersion;
9432
9530
  try {
9531
+ let bytes = data;
9532
+ if (readBackupManifest2(data).version === 1) {
9533
+ bytes = await convertBackupV12(data);
9534
+ convertedFromVersion = 1;
9535
+ }
9433
9536
  parsed = await parseBackup2(
9434
- data,
9537
+ bytes,
9435
9538
  options?.collections ? { collections: options.collections } : void 0
9436
9539
  );
9437
9540
  } catch (error) {
@@ -9483,6 +9586,7 @@ var init_store = __esm({
9483
9586
  recordsRestored: counts.recordsRestored,
9484
9587
  ...options?.merge ? {} : { unsyncedWritesKept: counts.unsyncedWritesKept },
9485
9588
  ...counts.serverOperationsSkipped ? { serverOperationsSkipped: counts.serverOperationsSkipped } : {},
9589
+ ...convertedFromVersion ? { convertedFromVersion } : {},
9486
9590
  success: true,
9487
9591
  duration: Date.now() - started
9488
9592
  };
@@ -9645,6 +9749,42 @@ var init_store = __esm({
9645
9749
  onOperation: (operation) => this.publishLocalOperation(operation.collection, operation)
9646
9750
  });
9647
9751
  }
9752
+ /** The node id the database persisted before this open, if any. */
9753
+ async readPersistedNodeId() {
9754
+ const rows = await this.adapter.query(
9755
+ "SELECT value FROM _kora_meta WHERE key = 'node_id'"
9756
+ );
9757
+ return rows[0]?.value ?? null;
9758
+ }
9759
+ /**
9760
+ * Register node ids this database authored under that the registry does not know
9761
+ * yet: the node id a pinned `nodeId` (the auth device id) just replaced, and the
9762
+ * authors of queued operations. A beta.12 database had no registry, and a template
9763
+ * app created before its user signed in authored under a random node; once the
9764
+ * store is pinned to the device id, that node's queued writes would otherwise sit in
9765
+ * the queue forever, neither uploaded nor reported. Registered, they are adopted or
9766
+ * held (`status.heldNodes`) like any other local node's writes. Only the outbound
9767
+ * queue is read: received operations never enter it.
9768
+ */
9769
+ async registerUnregisteredAuthors(persistedNodeId) {
9770
+ const authors = /* @__PURE__ */ new Set();
9771
+ if (persistedNodeId !== null && persistedNodeId !== this.nodeId) authors.add(persistedNodeId);
9772
+ const queued = await this.adapter.query(
9773
+ "SELECT payload FROM _kora_sync_queue"
9774
+ );
9775
+ for (const row of queued) {
9776
+ try {
9777
+ const nodeId = JSON.parse(row.payload).node_id;
9778
+ if (typeof nodeId === "string" && nodeId.length > 0) authors.add(nodeId);
9779
+ } catch {
9780
+ }
9781
+ }
9782
+ authors.delete(this.nodeId);
9783
+ for (const nodeId of authors) {
9784
+ if ((0, import_core37.isReservedNodeId)(nodeId)) continue;
9785
+ await registerLocalNode(this.adapter, nodeId);
9786
+ }
9787
+ }
9648
9788
  async loadOrGenerateNodeId() {
9649
9789
  if (this.configNodeId && (0, import_core37.isReservedNodeId)(this.configNodeId)) {
9650
9790
  throw new ReservedNodeIdError(this.configNodeId, "StoreConfig.nodeId");
@@ -9717,6 +9857,7 @@ __export(index_exports, {
9717
9857
  LeaderUnresponsiveError: () => LeaderUnresponsiveError,
9718
9858
  MemoryBlobStore: () => MemoryBlobStore,
9719
9859
  NoLeaderError: () => NoLeaderError,
9860
+ NodeOwnedByAnotherUserError: () => NodeOwnedByAnotherUserError,
9720
9861
  OpfsBlobStore: () => OpfsBlobStore,
9721
9862
  OptimisticLockError: () => OptimisticLockError,
9722
9863
  PersistenceError: () => PersistenceError,
@@ -10087,7 +10228,6 @@ var LOAD_ORIGIN = "kora-load";
10087
10228
  var REMOTE_ORIGIN = "kora-remote";
10088
10229
  var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
10089
10230
  var TEXT_KEY2 = "content";
10090
- var COMPACT_AFTER_DELTAS = 20;
10091
10231
  var PERSIST_DEBOUNCE_MS = 400;
10092
10232
  function createRichTextController(options) {
10093
10233
  const {
@@ -10109,16 +10249,19 @@ function createRichTextController(options) {
10109
10249
  let canUndo = false;
10110
10250
  let canRedo = false;
10111
10251
  let cursors = [];
10252
+ let editGeneration = 0;
10253
+ let savedGeneration = 0;
10254
+ let saving = null;
10255
+ let saveAgain = false;
10112
10256
  const listeners = /* @__PURE__ */ new Set();
10113
10257
  let snapshot = {
10114
10258
  ready: false,
10115
10259
  error: null,
10116
10260
  canUndo: false,
10117
10261
  canRedo: false,
10118
- cursors: []
10262
+ cursors: [],
10263
+ hasUnsavedChanges: false
10119
10264
  };
10120
- const baseUpdateRef = { current: null };
10121
- const pendingDeltasRef = { current: [] };
10122
10265
  const docChannelActiveRef = { current: false };
10123
10266
  let persistTimer = null;
10124
10267
  let recordUnsubscribe = null;
@@ -10130,7 +10273,8 @@ function createRichTextController(options) {
10130
10273
  error,
10131
10274
  canUndo,
10132
10275
  canRedo,
10133
- cursors: [...cursors]
10276
+ cursors: [...cursors],
10277
+ hasUnsavedChanges: editGeneration > savedGeneration
10134
10278
  };
10135
10279
  };
10136
10280
  const notify = () => {
@@ -10201,26 +10345,38 @@ function createRichTextController(options) {
10201
10345
  return;
10202
10346
  }
10203
10347
  Y3.applyUpdate(doc, encoded, REMOTE_ORIGIN);
10204
- baseUpdateRef.current = encoded;
10205
- pendingDeltasRef.current = [];
10206
10348
  syncHistoryState();
10207
10349
  };
10208
- const flushPersist = async () => {
10209
- const snapshot2 = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
10210
- if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
10211
- baseUpdateRef.current = snapshot2;
10212
- pendingDeltasRef.current = [];
10213
- }
10350
+ const persistOnce = async () => {
10351
+ const generation = editGeneration;
10352
+ const state = Y3.encodeStateAsUpdate(doc);
10214
10353
  try {
10215
10354
  await collection.update(recordId, {
10216
- [fieldName]: snapshot2
10355
+ [fieldName]: state
10217
10356
  });
10357
+ if (generation > savedGeneration) savedGeneration = generation;
10358
+ error = null;
10218
10359
  } catch (cause) {
10219
- if (!disposed) {
10220
- error = cause instanceof Error ? cause : new Error(String(cause));
10221
- notify();
10222
- }
10360
+ error = cause instanceof Error ? cause : new Error(String(cause));
10223
10361
  }
10362
+ if (!disposed) notify();
10363
+ };
10364
+ const flushPersist = () => {
10365
+ if (saving) {
10366
+ saveAgain = true;
10367
+ return saving;
10368
+ }
10369
+ const run = (async () => {
10370
+ do {
10371
+ saveAgain = false;
10372
+ await persistOnce();
10373
+ } while (saveAgain);
10374
+ })();
10375
+ saving = run;
10376
+ void run.finally(() => {
10377
+ if (saving === run) saving = null;
10378
+ });
10379
+ return run;
10224
10380
  };
10225
10381
  const schedulePersist = () => {
10226
10382
  if (persistTimer) {
@@ -10236,7 +10392,7 @@ function createRichTextController(options) {
10236
10392
  if (origin === LOAD_ORIGIN || origin === REMOTE_ORIGIN || origin === DOC_CHANNEL_ORIGIN) {
10237
10393
  return;
10238
10394
  }
10239
- pendingDeltasRef.current.push(update);
10395
+ editGeneration += 1;
10240
10396
  if (docChannelActiveRef.current && syncEngine) {
10241
10397
  syncEngine.getRichtextDocChannel?.().send(collectionName, recordId, fieldName, update);
10242
10398
  schedulePersist();
@@ -10320,8 +10476,6 @@ function createRichTextController(options) {
10320
10476
  target.delete(0, target.length);
10321
10477
  }, LOAD_ORIGIN);
10322
10478
  const encoded = encodeRichtextInput(record?.[fieldName]);
10323
- baseUpdateRef.current = encoded;
10324
- pendingDeltasRef.current = [];
10325
10479
  if (encoded) {
10326
10480
  Y3.applyUpdate(doc, encoded, LOAD_ORIGIN);
10327
10481
  }
@@ -10364,6 +10518,16 @@ function createRichTextController(options) {
10364
10518
  setUser(nextUser) {
10365
10519
  user = nextUser;
10366
10520
  },
10521
+ retrySave() {
10522
+ if (persistTimer) {
10523
+ clearTimeout(persistTimer);
10524
+ persistTimer = null;
10525
+ }
10526
+ return flushPersist();
10527
+ },
10528
+ getUnsavedState() {
10529
+ return editGeneration > savedGeneration ? Y3.encodeStateAsUpdate(doc) : null;
10530
+ },
10367
10531
  destroy() {
10368
10532
  if (disposed) {
10369
10533
  return;
@@ -10373,14 +10537,15 @@ function createRichTextController(options) {
10373
10537
  clearTimeout(persistTimer);
10374
10538
  persistTimer = null;
10375
10539
  }
10540
+ if (editGeneration > savedGeneration) {
10541
+ void flushPersist();
10542
+ }
10376
10543
  doc.off("update", onDocUpdate);
10377
10544
  recordUnsubscribe?.();
10378
10545
  docChannelUnsubscribe?.();
10379
10546
  awarenessUnsubscribe?.();
10380
10547
  clearCursor();
10381
10548
  undoManager.destroy();
10382
- baseUpdateRef.current = null;
10383
- pendingDeltasRef.current = [];
10384
10549
  docChannelActiveRef.current = false;
10385
10550
  listeners.clear();
10386
10551
  }
@@ -10401,16 +10566,6 @@ function encodeRichtextInput(value) {
10401
10566
  throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
10402
10567
  }
10403
10568
  }
10404
- function composeRichtextSnapshot(base, deltas) {
10405
- const mergedDoc = new Y3.Doc();
10406
- if (base) {
10407
- Y3.applyUpdate(mergedDoc, base);
10408
- }
10409
- for (const delta of deltas) {
10410
- Y3.applyUpdate(mergedDoc, delta);
10411
- }
10412
- return Y3.encodeStateAsUpdate(mergedDoc);
10413
- }
10414
10569
  function updatesEqual(left, right) {
10415
10570
  if (left.length !== right.length) {
10416
10571
  return false;
@@ -11039,6 +11194,7 @@ function createChunkPortPair() {
11039
11194
  LeaderUnresponsiveError,
11040
11195
  MemoryBlobStore,
11041
11196
  NoLeaderError,
11197
+ NodeOwnedByAnotherUserError,
11042
11198
  OpfsBlobStore,
11043
11199
  OptimisticLockError,
11044
11200
  PersistenceError,