@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.
@@ -2317,6 +2317,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2317
2317
  onAwarenessUpdate: () => null,
2318
2318
  onAwarenessChange: () => null,
2319
2319
  onStateless: () => null,
2320
+ onServerError: () => null,
2320
2321
  onUnsyncedChanges: () => null
2321
2322
  };
2322
2323
  this.isSynced = false;
@@ -2348,6 +2349,7 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2348
2349
  this.on("awarenessUpdate", this.configuration.onAwarenessUpdate);
2349
2350
  this.on("awarenessChange", this.configuration.onAwarenessChange);
2350
2351
  this.on("stateless", this.configuration.onStateless);
2352
+ this.on("serverError", this.configuration.onServerError);
2351
2353
  this.on("unsyncedChanges", this.configuration.onUnsyncedChanges);
2352
2354
  this.on("authenticated", this.configuration.onAuthenticated);
2353
2355
  this.on("authenticationFailed", this.configuration.onAuthenticationFailed);
@@ -2463,6 +2465,19 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2463
2465
  if (state) this.emit("synced", { state });
2464
2466
  }
2465
2467
  receiveStateless(payload) {
2468
+ try {
2469
+ const parsed = JSON.parse(payload);
2470
+ if (parsed?.type === "error" && parsed.source && parsed.code) {
2471
+ const { source, code, message } = parsed;
2472
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
2473
+ this.emit("serverError", {
2474
+ source,
2475
+ code,
2476
+ message: message ?? ""
2477
+ });
2478
+ return;
2479
+ }
2480
+ } catch {}
2466
2481
  this.emit("stateless", { payload });
2467
2482
  }
2468
2483
  async connect() {
@@ -2808,6 +2823,9 @@ function isValidDocId(id) {
2808
2823
  * refreshed from the server on every reconnect.
2809
2824
  */
2810
2825
  var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvider {
2826
+ static {
2827
+ this.MAX_CHILDREN = 20;
2828
+ }
2811
2829
  constructor(configuration) {
2812
2830
  const resolved = { ...configuration };
2813
2831
  const client = configuration.client ?? null;
@@ -2819,6 +2837,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
2819
2837
  this.effectiveRole = null;
2820
2838
  this.childProviders = /* @__PURE__ */ new Map();
2821
2839
  this.pendingLoads = /* @__PURE__ */ new Map();
2840
+ this.childAccessTimes = /* @__PURE__ */ new Map();
2841
+ this.pinnedChildren = /* @__PURE__ */ new Set();
2822
2842
  this.boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
2823
2843
  this._client = client;
2824
2844
  this.abracadabraConfig = configuration;
@@ -3013,7 +3033,10 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3013
3033
  */
3014
3034
  loadChild(childId) {
3015
3035
  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.`));
3016
- if (this.childProviders.has(childId)) return Promise.resolve(this.childProviders.get(childId));
3036
+ if (this.childProviders.has(childId)) {
3037
+ this.childAccessTimes.set(childId, Date.now());
3038
+ return Promise.resolve(this.childProviders.get(childId));
3039
+ }
3017
3040
  if (this.pendingLoads.has(childId)) return this.pendingLoads.get(childId);
3018
3041
  const load = this._doLoadChild(childId);
3019
3042
  this.pendingLoads.set(childId, load);
@@ -3038,6 +3061,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3038
3061
  });
3039
3062
  childProvider.attach();
3040
3063
  this.childProviders.set(childId, childProvider);
3064
+ this.childAccessTimes.set(childId, Date.now());
3065
+ this.evictLRU();
3041
3066
  this.emit("subdocLoaded", {
3042
3067
  childId,
3043
3068
  provider: childProvider
@@ -3049,6 +3074,44 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3049
3074
  if (provider) {
3050
3075
  provider.destroy();
3051
3076
  this.childProviders.delete(childId);
3077
+ this.childAccessTimes.delete(childId);
3078
+ this.pinnedChildren.delete(childId);
3079
+ }
3080
+ }
3081
+ /**
3082
+ * Mark a child as pinned so LRU eviction will not remove it.
3083
+ * Use this when a document is actively being viewed by the user.
3084
+ */
3085
+ pinChild(childId) {
3086
+ this.pinnedChildren.add(childId);
3087
+ }
3088
+ /**
3089
+ * Unpin a child, allowing LRU eviction to reclaim it when capacity is exceeded.
3090
+ */
3091
+ unpinChild(childId) {
3092
+ this.pinnedChildren.delete(childId);
3093
+ this.evictLRU();
3094
+ }
3095
+ /**
3096
+ * Evict least-recently-used unpinned child providers until the cache is
3097
+ * at or below MAX_CHILDREN.
3098
+ */
3099
+ evictLRU() {
3100
+ if (this.childProviders.size <= AbracadabraProvider.MAX_CHILDREN) return;
3101
+ const evictable = [];
3102
+ for (const [id] of this.childProviders) {
3103
+ if (this.pinnedChildren.has(id)) continue;
3104
+ evictable.push({
3105
+ id,
3106
+ accessTime: this.childAccessTimes.get(id) ?? 0
3107
+ });
3108
+ }
3109
+ evictable.sort((a, b) => a.accessTime - b.accessTime);
3110
+ let toEvict = this.childProviders.size - AbracadabraProvider.MAX_CHILDREN;
3111
+ for (const entry of evictable) {
3112
+ if (toEvict <= 0) break;
3113
+ this.unloadChild(entry.id);
3114
+ toEvict--;
3052
3115
  }
3053
3116
  }
3054
3117
  /** Return all currently-loaded child providers. */
@@ -3108,6 +3171,8 @@ var AbracadabraProvider = class AbracadabraProvider extends AbracadabraBaseProvi
3108
3171
  const childIds = [...this.childProviders.keys()];
3109
3172
  for (const provider of this.childProviders.values()) provider.destroy();
3110
3173
  this.childProviders.clear();
3174
+ this.childAccessTimes.clear();
3175
+ this.pinnedChildren.clear();
3111
3176
  const wsProviderMap = this.configuration.websocketProvider?.configuration?.providerMap;
3112
3177
  if (wsProviderMap) for (const childId of childIds) wsProviderMap.delete(childId);
3113
3178
  this.offlineStore?.destroy();
@@ -3550,6 +3615,34 @@ var AbracadabraClient = class {
3550
3615
  async adminStorageRepair() {
3551
3616
  return this.request("POST", "/admin/storage/repair");
3552
3617
  }
3618
+ /** List snapshot metadata for a document. */
3619
+ async listSnapshots(docId, opts) {
3620
+ const params = new URLSearchParams();
3621
+ if (opts?.limit != null) params.set("limit", String(opts.limit));
3622
+ if (opts?.offset != null) params.set("offset", String(opts.offset));
3623
+ const qs = params.toString();
3624
+ return (await this.request("GET", `/docs/${encodeURIComponent(docId)}/snapshots${qs ? `?${qs}` : ""}`)).snapshots;
3625
+ }
3626
+ /** Fetch a single snapshot including its base64-encoded data blob. */
3627
+ async getSnapshot(docId, version) {
3628
+ return this.request("GET", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`);
3629
+ }
3630
+ /** Create a manual snapshot of the current document state. */
3631
+ async createSnapshot(docId, opts) {
3632
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots`, { body: opts ?? {} });
3633
+ }
3634
+ /** Delete a specific snapshot version. Requires manage permission. */
3635
+ async deleteSnapshot(docId, version) {
3636
+ await this.request("DELETE", `/docs/${encodeURIComponent(docId)}/snapshots/${version}`);
3637
+ }
3638
+ /** Restore a snapshot by merging it forward into the current document state. */
3639
+ async restoreSnapshot(docId, version) {
3640
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/restore`, { body: {} });
3641
+ }
3642
+ /** Fork a snapshot into a new document. */
3643
+ async forkSnapshot(docId, version) {
3644
+ return this.request("POST", `/docs/${encodeURIComponent(docId)}/snapshots/${version}/fork`, { body: {} });
3645
+ }
3553
3646
  /** Health check — no auth required. */
3554
3647
  async health() {
3555
3648
  return this.request("GET", "/health", { auth: false });
@@ -10878,35 +10971,57 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10878
10971
  * This propagates "last edited" timestamps to all peers via the root CRDT,
10879
10972
  * without requiring any server-side changes.
10880
10973
  *
10881
- * Limitation: at least one client must have the child doc open after an edit
10882
- * for the timestamp to propagate (eventually consistent).
10974
+ * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
10975
+ * on the root doc during rapid typing.
10883
10976
  */
10884
10977
  /**
10885
- * Attach an observer that writes `updatedAt: Date.now()` to the root
10886
- * doc-tree entry for `childDocId` whenever the child doc receives a
10887
- * non-offline update.
10978
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
10979
+ * `childDocId` whenever the child doc receives a non-offline update.
10980
+ *
10981
+ * Writes are throttled: the first qualifying update records the timestamp;
10982
+ * a trailing-edge timer flushes it to the tree map after `throttleMs`.
10888
10983
  *
10889
- * @param treeMap The root doc's "doc-tree" Y.Map.
10890
- * @param childDocId The child document's UUID (key in treeMap).
10891
- * @param childDoc The child Y.Doc to observe.
10984
+ * @param treeMap The root doc's "doc-tree" Y.Map.
10985
+ * @param childDocId The child document's UUID (key in treeMap).
10986
+ * @param childDoc The child Y.Doc to observe.
10892
10987
  * @param offlineStore The child provider's OfflineStore (used to detect
10893
10988
  * offline-replay origins and skip them). Pass null when
10894
10989
  * the offline store is disabled.
10895
- * @returns Cleanup function call on provider destroy.
10896
- */
10897
- function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore) {
10898
- function handler(update, origin) {
10899
- if (offlineStore !== null && origin === offlineStore) return;
10990
+ * @param options Optional config. `throttleMs` controls the write
10991
+ * interval (default 5000).
10992
+ * @returns Cleanup function call on provider destroy. Flushes
10993
+ * any pending write before detaching.
10994
+ */
10995
+ function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore, options) {
10996
+ const throttleMs = options?.throttleMs ?? 5e3;
10997
+ let latestTs = 0;
10998
+ let timer = null;
10999
+ function flush() {
11000
+ if (latestTs === 0) return;
11001
+ const ts = latestTs;
11002
+ latestTs = 0;
11003
+ timer = null;
10900
11004
  const raw = treeMap.get(childDocId);
10901
11005
  if (!raw) return;
10902
11006
  const entry = raw instanceof yjs.Map ? raw.toJSON() : raw;
10903
11007
  treeMap.set(childDocId, {
10904
11008
  ...entry,
10905
- updatedAt: Date.now()
11009
+ updatedAt: ts
10906
11010
  });
10907
11011
  }
11012
+ function handler(_update, origin) {
11013
+ if (offlineStore !== null && origin === offlineStore) return;
11014
+ latestTs = Date.now();
11015
+ if (timer === null) timer = setTimeout(flush, throttleMs);
11016
+ }
10908
11017
  childDoc.on("update", handler);
10909
- return () => childDoc.off("update", handler);
11018
+ return () => {
11019
+ childDoc.off("update", handler);
11020
+ if (timer !== null) {
11021
+ clearTimeout(timer);
11022
+ flush();
11023
+ }
11024
+ };
10910
11025
  }
10911
11026
 
10912
11027
  //#endregion
@@ -11176,7 +11291,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11176
11291
  async _syncWithSemaphore(docId, updatedAt) {
11177
11292
  if (this._destroyed) return true;
11178
11293
  const existing = this.syncStates.get(docId);
11179
- if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
11294
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
11180
11295
  this.emit("stateChanged", {
11181
11296
  docId,
11182
11297
  state: existing
@@ -11228,6 +11343,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11228
11343
  }
11229
11344
  }
11230
11345
  async _syncNonE2EDoc(docId) {
11346
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11231
11347
  const alreadyCached = this.rootProvider.children.has(docId);
11232
11348
  if (!alreadyCached) {
11233
11349
  if ((this.rootProvider.configuration?.websocketProvider?.configuration?.providerMap)?.has(docId)) return {
@@ -11238,27 +11354,33 @@ var BackgroundSyncManager = class extends EventEmitter {
11238
11354
  };
11239
11355
  }
11240
11356
  const childProvider = await this.rootProvider.loadChild(docId);
11241
- await childProvider.ready;
11242
- await this._waitForSynced(childProvider);
11243
- {
11244
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11245
- this.emit("docSynced", {
11357
+ try {
11358
+ await childProvider.ready;
11359
+ await this._waitForSynced(childProvider);
11360
+ {
11361
+ const treeEntry = treeMap.get(docId);
11362
+ this.emit("docSynced", {
11363
+ docId,
11364
+ document: childProvider.document,
11365
+ label: treeEntry?.label ?? "",
11366
+ meta: treeEntry?.meta
11367
+ });
11368
+ }
11369
+ if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11370
+ const treeEntry = treeMap.get(docId);
11371
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11372
+ return {
11246
11373
  docId,
11247
- document: childProvider.document,
11248
- label: treeEntry?.label ?? "",
11249
- meta: treeEntry?.meta
11250
- });
11374
+ status: "synced",
11375
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11376
+ isE2E: false
11377
+ };
11378
+ } finally {
11379
+ if (!alreadyCached) this.rootProvider.unloadChild(docId);
11251
11380
  }
11252
- if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11253
- if (!alreadyCached) this.rootProvider.unloadChild(docId);
11254
- return {
11255
- docId,
11256
- status: "synced",
11257
- lastSynced: Date.now(),
11258
- isE2E: false
11259
- };
11260
11381
  }
11261
11382
  async _syncE2EDoc(docId) {
11383
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11262
11384
  const docKeyManager = this.rootProvider.abracadabraConfig?.docKeyManager;
11263
11385
  const keystore = this.rootProvider.abracadabraConfig?.keystore;
11264
11386
  if (!docKeyManager || !keystore) return {
@@ -11279,7 +11401,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11279
11401
  await childProvider.ready;
11280
11402
  await this._waitForSynced(childProvider);
11281
11403
  {
11282
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11404
+ const treeEntry = treeMap.get(docId);
11283
11405
  this.emit("docSynced", {
11284
11406
  docId,
11285
11407
  document: childDoc,
@@ -11288,10 +11410,12 @@ var BackgroundSyncManager = class extends EventEmitter {
11288
11410
  });
11289
11411
  }
11290
11412
  if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childDoc).catch(() => null);
11413
+ const treeEntry = treeMap.get(docId);
11414
+ const treeUpdatedAt = treeEntry?.updatedAt ?? treeEntry?.createdAt ?? 0;
11291
11415
  return {
11292
11416
  docId,
11293
11417
  status: "synced",
11294
- lastSynced: Date.now(),
11418
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11295
11419
  isE2E: true
11296
11420
  };
11297
11421
  } finally {