@abraca/dabra 1.5.0 → 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();
@@ -10906,35 +10971,57 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10906
10971
  * This propagates "last edited" timestamps to all peers via the root CRDT,
10907
10972
  * without requiring any server-side changes.
10908
10973
  *
10909
- * Limitation: at least one client must have the child doc open after an edit
10910
- * 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.
10911
10976
  */
10912
10977
  /**
10913
- * Attach an observer that writes `updatedAt: Date.now()` to the root
10914
- * doc-tree entry for `childDocId` whenever the child doc receives a
10915
- * 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`.
10916
10983
  *
10917
- * @param treeMap The root doc's "doc-tree" Y.Map.
10918
- * @param childDocId The child document's UUID (key in treeMap).
10919
- * @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.
10920
10987
  * @param offlineStore The child provider's OfflineStore (used to detect
10921
10988
  * offline-replay origins and skip them). Pass null when
10922
10989
  * the offline store is disabled.
10923
- * @returns Cleanup function call on provider destroy.
10924
- */
10925
- function attachUpdatedAtObserver(treeMap, childDocId, childDoc, offlineStore) {
10926
- function handler(update, origin) {
10927
- 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;
10928
11004
  const raw = treeMap.get(childDocId);
10929
11005
  if (!raw) return;
10930
11006
  const entry = raw instanceof yjs.Map ? raw.toJSON() : raw;
10931
11007
  treeMap.set(childDocId, {
10932
11008
  ...entry,
10933
- updatedAt: Date.now()
11009
+ updatedAt: ts
10934
11010
  });
10935
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
+ }
10936
11017
  childDoc.on("update", handler);
10937
- return () => childDoc.off("update", handler);
11018
+ return () => {
11019
+ childDoc.off("update", handler);
11020
+ if (timer !== null) {
11021
+ clearTimeout(timer);
11022
+ flush();
11023
+ }
11024
+ };
10938
11025
  }
10939
11026
 
10940
11027
  //#endregion
@@ -11204,7 +11291,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11204
11291
  async _syncWithSemaphore(docId, updatedAt) {
11205
11292
  if (this._destroyed) return true;
11206
11293
  const existing = this.syncStates.get(docId);
11207
- if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced >= updatedAt) {
11294
+ if (existing && existing.status === "synced" && existing.lastSynced !== null && existing.lastSynced + 200 >= updatedAt) {
11208
11295
  this.emit("stateChanged", {
11209
11296
  docId,
11210
11297
  state: existing
@@ -11256,6 +11343,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11256
11343
  }
11257
11344
  }
11258
11345
  async _syncNonE2EDoc(docId) {
11346
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11259
11347
  const alreadyCached = this.rootProvider.children.has(docId);
11260
11348
  if (!alreadyCached) {
11261
11349
  if ((this.rootProvider.configuration?.websocketProvider?.configuration?.providerMap)?.has(docId)) return {
@@ -11266,27 +11354,33 @@ var BackgroundSyncManager = class extends EventEmitter {
11266
11354
  };
11267
11355
  }
11268
11356
  const childProvider = await this.rootProvider.loadChild(docId);
11269
- await childProvider.ready;
11270
- await this._waitForSynced(childProvider);
11271
- {
11272
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11273
- 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 {
11274
11373
  docId,
11275
- document: childProvider.document,
11276
- label: treeEntry?.label ?? "",
11277
- meta: treeEntry?.meta
11278
- });
11374
+ status: "synced",
11375
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11376
+ isE2E: false
11377
+ };
11378
+ } finally {
11379
+ if (!alreadyCached) this.rootProvider.unloadChild(docId);
11279
11380
  }
11280
- if (this.opts.prefetchFiles && this.fileBlobStore) this._prefetchDocFiles(docId, childProvider.document).catch(() => null);
11281
- if (!alreadyCached) this.rootProvider.unloadChild(docId);
11282
- return {
11283
- docId,
11284
- status: "synced",
11285
- lastSynced: Date.now(),
11286
- isE2E: false
11287
- };
11288
11381
  }
11289
11382
  async _syncE2EDoc(docId) {
11383
+ const treeMap = this.rootProvider.document.getMap("doc-tree");
11290
11384
  const docKeyManager = this.rootProvider.abracadabraConfig?.docKeyManager;
11291
11385
  const keystore = this.rootProvider.abracadabraConfig?.keystore;
11292
11386
  if (!docKeyManager || !keystore) return {
@@ -11307,7 +11401,7 @@ var BackgroundSyncManager = class extends EventEmitter {
11307
11401
  await childProvider.ready;
11308
11402
  await this._waitForSynced(childProvider);
11309
11403
  {
11310
- const treeEntry = this.rootProvider.document.getMap("doc-tree").get(docId);
11404
+ const treeEntry = treeMap.get(docId);
11311
11405
  this.emit("docSynced", {
11312
11406
  docId,
11313
11407
  document: childDoc,
@@ -11316,10 +11410,12 @@ var BackgroundSyncManager = class extends EventEmitter {
11316
11410
  });
11317
11411
  }
11318
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;
11319
11415
  return {
11320
11416
  docId,
11321
11417
  status: "synced",
11322
- lastSynced: Date.now(),
11418
+ lastSynced: Math.max(Date.now(), treeUpdatedAt),
11323
11419
  isE2E: true
11324
11420
  };
11325
11421
  } finally {