@abraca/dabra 1.3.4 → 1.6.0

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.
@@ -2287,6 +2287,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2287
2287
  onAwarenessUpdate: () => null,
2288
2288
  onAwarenessChange: () => null,
2289
2289
  onStateless: () => null,
2290
+ onServerError: () => null,
2290
2291
  onUnsyncedChanges: () => null
2291
2292
  };
2292
2293
  this.isSynced = false;
@@ -2318,6 +2319,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2318
2319
  this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
2319
2320
  this.on("awarenessChange", this.configuration.onAwarenessChange);
2320
2321
  this.on("stateless", this.configuration.onStateless);
2322
+ this.on("serverError", this.configuration.onServerError);
2321
2323
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
2322
2324
  this.on("authenticated", this.configuration.onAuthenticated);
2323
2325
  this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
@@ -2433,6 +2435,19 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2433
2435
  if (state) this.emit("synced", { state });
2434
2436
  }
2435
2437
  receiveStateless(payload) {
2438
+ try {
2439
+ const parsed = JSON.parse(payload);
2440
+ if (parsed?.type === "error" && parsed.source && parsed.code) {
2441
+ const { source, code, message } = parsed;
2442
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
2443
+ this.emit("serverError", {
2444
+ source,
2445
+ code,
2446
+ message: message ?? ""
2447
+ });
2448
+ return;
2449
+ }
2450
+ } catch {}
2436
2451
  this.emit("stateless", { payload });
2437
2452
  }
2438
2453
  async connect() {
@@ -2778,6 +2793,9 @@ function isValidDocId(id) {
2778
2793
  * refreshed from the server on every reconnect.
2779
2794
  */
2780
2795
  var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvider {
2796
+ static {
2797
+ this.MAX_CHILDREN = 20;
2798
+ }
2781
2799
  constructor(configuration) {
2782
2800
  const resolved = { ...configuration };
2783
2801
  const client = configuration.client ?? null;
@@ -2789,6 +2807,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2789
2807
  this.effectiveRole = null;
2790
2808
  this.childProviders = /* @__PURE__ */ new Map();
2791
2809
  this.pendingLoads = /* @__PURE__ */ new Map();
2810
+ this.childAccessTimes = /* @__PURE__ */ new Map();
2811
+ this.pinnedChildren = /* @__PURE__ */ new Set();
2792
2812
  this.boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
2793
2813
  this._client = client;
2794
2814
  this.abracadabraConfig = configuration;
@@ -2983,7 +3003,10 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2983
3003
  */
2984
3004
  loadChild(childId) {
2985
3005
  if (!isValidDocId(childId)) return Promise.reject(/* @__PURE__ */ new Error(`loadChild: "${childId}" is not a valid document ID (must be a UUID). If this node was created with an older version of the app, delete it and recreate it.`));
2986
- if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
3006
+ if (this.childProviders.has(childId)) {
3007
+ this.childAccessTimes.set(childId, Date.now());
3008
+ return Promise.resolve(this.childProviders.get(childId));
3009
+ }
2987
3010
  if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
2988
3011
  const load = this._doLoadChild(childId);
2989
3012
  this.pendingLoads.set(childId, load);
@@ -3008,6 +3031,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3008
3031
  });
3009
3032
  childProvider.attach();
3010
3033
  this.childProviders.set(childId, childProvider);
3034
+ this.childAccessTimes.set(childId, Date.now());
3035
+ this.evictLRU();
3011
3036
  this.emit("subdocLoaded", {
3012
3037
  childId,
3013
3038
  provider: childProvider
@@ -3019,6 +3044,44 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3019
3044
  if (provider) {
3020
3045
  provider.destroy();
3021
3046
  this.childProviders.delete(childId);
3047
+ this.childAccessTimes.delete(childId);
3048
+ this.pinnedChildren.delete(childId);
3049
+ }
3050
+ }
3051
+ /**
3052
+ * Mark a child as pinned so LRU eviction will not remove it.
3053
+ * Use this when a document is actively being viewed by the user.
3054
+ */
3055
+ pinChild(childId) {
3056
+ this.pinnedChildren.add(childId);
3057
+ }
3058
+ /**
3059
+ * Unpin a child, allowing LRU eviction to reclaim it when capacity is exceeded.
3060
+ */
3061
+ unpinChild(childId) {
3062
+ this.pinnedChildren.delete(childId);
3063
+ this.evictLRU();
3064
+ }
3065
+ /**
3066
+ * Evict least-recently-used unpinned child providers until the cache is
3067
+ * at or below MAX_CHILDREN.
3068
+ */
3069
+ evictLRU() {
3070
+ if (this.childProviders.size <= AbracadabraProvider.MAX_CHILDREN) return;
3071
+ const evictable = [];
3072
+ for (const [id] of this.childProviders) {
3073
+ if (this.pinnedChildren.has(id)) continue;
3074
+ evictable.push({
3075
+ id,
3076
+ accessTime: this.childAccessTimes.get(id) ?? 0
3077
+ });
3078
+ }
3079
+ evictable.sort((a, b) => a.accessTime - b.accessTime);
3080
+ let toEvict = this.childProviders.size - AbracadabraProvider.MAX_CHILDREN;
3081
+ for (const entry of evictable) {
3082
+ if (toEvict <= 0) break;
3083
+ this.unloadChild(entry.id);
3084
+ toEvict--;
3022
3085
  }
3023
3086
  }
3024
3087
  /** Return all currently-loaded child providers. */
@@ -3078,6 +3141,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3078
3141
  const childIds = [...this.childProviders.keys()];
3079
3142
  for (const provider of this.childProviders.values()) provider.destroy();
3080
3143
  this.childProviders.clear();
3144
+ this.childAccessTimes.clear();
3145
+ this.pinnedChildren.clear();
3081
3146
  const wsProviderMap = this.configuration.websocketProvider?.configuration?.providerMap;
3082
3147
  if (wsProviderMap) for (const childId of childIds) wsProviderMap.delete(childId);
3083
3148
  this.offlineStore?.destroy();
@@ -3520,6 +3585,34 @@ var AbracadabraClient = class {
3520
3585
  async adminStorageRepair() {
3521
3586
  return this.request("POST", "/admin/storage/repair");
3522
3587
  }
3588
+ /** List snapshot metadata for a document. */
3589
+ async listSnapshots(docId, opts) {
3590
+ const params = new URLSearchParams();
3591
+ if (opts?.limit != null) params.set("limit", String(opts.limit));
3592
+ if (opts?.offset != null) params.set("offset", String(opts.offset));
3593
+ const qs = params.toString();
3594
+ return (await this.request("GET", `/docs/${encodeURIComponent(docId)}/snapshots${qs ? `?${qs}` : ""}`)).snapshots;
3595
+ }
3596
+ /** Fetch a single snapshot including its base64-encoded data blob. */
3597
+ async getSnapshot(docId, version) {
3598
+ return this.request("GET", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`);
3599
+ }
3600
+ /** Create a manual snapshot of the current document state. */
3601
+ async createSnapshot(docId, opts) {
3602
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots`, { body: opts ?? {} });
3603
+ }
3604
+ /** Delete a specific snapshot version. Requires manage permission. */
3605
+ async deleteSnapshot(docId, version) {
3606
+ await this.request("DELETE", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`);
3607
+ }
3608
+ /** Restore a snapshot by merging it forward into the current document state. */
3609
+ async restoreSnapshot(docId, version) {
3610
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/restore`, { body: {} });
3611
+ }
3612
+ /** Fork a snapshot into a new document. */
3613
+ async forkSnapshot(docId, version) {
3614
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/fork`, { body: {} });
3615
+ }
3523
3616
  /** Health check — no auth required. */
3524
3617
  async health() {
3525
3618
  return this.request("GET", "/health", { auth: false });
@@ -10839,35 +10932,57 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10839
10932
  * This propagates "last edited" timestamps to all peers via the root CRDT,
10840
10933
  * without requiring any server-side changes.
10841
10934
  *
10842
- * Limitation: at least one client must have the child doc open after an edit
10843
- * for the timestamp to propagate (eventually consistent).
10935
+ * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
10936
+ * on the root doc during rapid typing.
10844
10937
  */
10845
10938
  /**
10846
- * Attach an observer that writes `updatedAt: Date.now()` to the root
10847
- * doc-tree entry for `childDocId` whenever the child doc receives a
10848
- * non-offline update.
10939
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
10940
+ * `childDocId` whenever the child doc receives a non-offline update.
10941
+ *
10942
+ * Writes are throttled: the first qualifying update records the timestamp;
10943
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
10849
10944
  *
10850
- * @param treeMap The root doc's "doc-tree" Y.Map.
10851
- * @param childDocId The child document's UUID (key in treeMap).
10852
- * @param childDoc The child Y.Doc to observe.
10945
+ * @param treeMap The root doc's "doc-tree" Y.Map.
10946
+ * @param childDocId The child document's UUID (key in treeMap).
10947
+ * @param childDoc The child Y.Doc to observe.
10853
10948
  * @param offlineStore The child provider's OfflineStore (used to detect
10854
10949
  * offline-replay origins and skip them). Pass null when
10855
10950
  * the offline store is disabled.
10856
- * @returns Cleanup function call on provider destroy.
10857
- */
10858
- function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore) {
10859
- function handler(update, origin) {
10860
- if (offlineStore !== null && origin === offlineStore) return;
10951
+ * @param options Optional config. `throttleMs` controls the write
10952
+ * interval (default 5000).
10953
+ * @returns Cleanup function call on provider destroy. Flushes
10954
+ * any pending write before detaching.
10955
+ */
10956
+ function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore, options) {
10957
+ const throttleMs = options?.throttleMs ?? 5e3;
10958
+ let latestTs = 0;
10959
+ let timer = null;
10960
+ function flush() {
10961
+ if (latestTs === 0) return;
10962
+ const ts = latestTs;
10963
+ latestTs = 0;
10964
+ timer = null;
10861
10965
  const raw = treeMap.get(childDocId);
10862
10966
  if (!raw) return;
10863
10967
  const entry = raw instanceof Y.Map ? raw.toJSON() : raw;
10864
10968
  treeMap.set(childDocId, {
10865
10969
  ...entry,
10866
- updatedAt: Date.now()
10970
+ updatedAt: ts
10867
10971
  });
10868
10972
  }
10973
+ function handler(_update, origin) {
10974
+ if (offlineStore !== null && origin === offlineStore) return;
10975
+ latestTs = Date.now();
10976
+ if (timer === null) timer = setTimeout(flush, throttleMs);
10977
+ }
10869
10978
  childDoc.on("update", handler);
10870
- return () => childDoc.off("update", handler);
10979
+ return () => {
10980
+ childDoc.off("update", handler);
10981
+ if (timer !== null) {
10982
+ clearTimeout(timer);
10983
+ flush();
10984
+ }
10985
+ };
10871
10986
  }
10872
10987
 
10873
10988
  //#endregion
@@ -11137,7 +11252,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11137
11252
  async _syncWithSemaphore(docId, updatedAt) {
11138
11253
  if (this._destroyed) return true;
11139
11254
  const existing = this.syncStates.get(docId);
11140
- if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
11255
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
11141
11256
  this.emit("stateChanged", {
11142
11257
  docId,
11143
11258
  state: existing
@@ -11189,6 +11304,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11189
11304
  }
11190
11305
  }
11191
11306
  async _syncNonE2EDoc(docId) {
11307
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11192
11308
  const alreadyCached = this.rootProvider.children.has(docId);
11193
11309
  if (!alreadyCached) {
11194
11310
  if ((this.rootProvider.configuration?.websocketProvider?.configuration?.providerMap)?.has(docId)) return {
@@ -11199,27 +11315,33 @@ var BackgroundSyncManager = class extends EventEmitter {
11199
11315
  };
11200
11316
  }
11201
11317
  const childProvider = await this.rootProvider.loadChild(docId);
11202
- await childProvider.ready;
11203
- await this._waitForSynced(childProvider);
11204
- {
11205
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11206
- this.emit("docSynced", {
11318
+ try {
11319
+ await childProvider.ready;
11320
+ await this._waitForSynced(childProvider);
11321
+ {
11322
+ const treeEntry = treeMap.get(docId);
11323
+ this.emit("docSynced", {
11324
+ docId,
11325
+ document: childProvider.document,
11326
+ label: treeEntry?.label ?? "",
11327
+ meta: treeEntry?.meta
11328
+ });
11329
+ }
11330
+ if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11331
+ const treeEntry = treeMap.get(docId);
11332
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11333
+ return {
11207
11334
  docId,
11208
- document: childProvider.document,
11209
- label: treeEntry?.label ?? "",
11210
- meta: treeEntry?.meta
11211
- });
11335
+ status: "synced",
11336
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11337
+ isE2E: false
11338
+ };
11339
+ } finally {
11340
+ if (!alreadyCached) this.rootProvider.unloadChild(docId);
11212
11341
  }
11213
- if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11214
- if (!alreadyCached) this.rootProvider.unloadChild(docId);
11215
- return {
11216
- docId,
11217
- status: "synced",
11218
- lastSynced: Date.now(),
11219
- isE2E: false
11220
- };
11221
11342
  }
11222
11343
  async _syncE2EDoc(docId) {
11344
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11223
11345
  const docKeyManager = this.rootProvider.abracadabraConfig?.docKeyManager;
11224
11346
  const keystore = this.rootProvider.abracadabraConfig?.keystore;
11225
11347
  if (!docKeyManager || !keystore) return {
@@ -11240,7 +11362,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11240
11362
  await childProvider.ready;
11241
11363
  await this._waitForSynced(childProvider);
11242
11364
  {
11243
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11365
+ const treeEntry = treeMap.get(docId);
11244
11366
  this.emit("docSynced", {
11245
11367
  docId,
11246
11368
  document: childDoc,
@@ -11249,10 +11371,12 @@ var BackgroundSyncManager = class extends EventEmitter {
11249
11371
  });
11250
11372
  }
11251
11373
  if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childDoc).catch(() => null);
11374
+ const treeEntry = treeMap.get(docId);
11375
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11252
11376
  return {
11253
11377
  docId,
11254
11378
  status: "synced",
11255
- lastSynced: Date.now(),
11379
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11256
11380
  isE2E: true
11257
11381
  };
11258
11382
  } finally {