@abraca/dabra 1.8.1 → 1.9.1

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.
@@ -2438,12 +2438,13 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2438
2438
  try {
2439
2439
  const parsed = JSON.parse(payload);
2440
2440
  if (parsed?.type === "error" && parsed.source && parsed.code) {
2441
- const { source, code, message } = parsed;
2442
- console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
2441
+ const { source, code, message, meta } = parsed;
2442
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`, meta ?? "");
2443
2443
  this.emit("serverError", {
2444
2444
  source,
2445
2445
  code,
2446
- message: message ?? ""
2446
+ message: message ?? "",
2447
+ meta
2447
2448
  });
2448
2449
  return;
2449
2450
  }
@@ -10425,6 +10426,15 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
10425
10426
  this.objectUrls.delete(key);
10426
10427
  }
10427
10428
  }
10429
+ /**
10430
+ * Clear the 404 negative-cache entry for (docId, uploadId) so the next
10431
+ * getBlobUrl() re-fetches from the server instead of short-circuiting.
10432
+ * Use on explicit user retry or after reconnect, when the file may have
10433
+ * become available since the last 404.
10434
+ */
10435
+ clearNotFound(docId, uploadId) {
10436
+ this._notFound.delete(this.blobKey(docId, uploadId));
10437
+ }
10428
10438
  /** Revoke the object URL and remove the blob from cache. */
10429
10439
  async evictBlob(docId, uploadId) {
10430
10440
  const key = this.blobKey(docId, uploadId);
@@ -10816,6 +10826,19 @@ var E2EOfflineStore = class extends OfflineStore {
10816
10826
  * are fetched on subsequent connects.
10817
10827
  * - After sync, a fresh encrypted snapshot is saved.
10818
10828
  *
10829
+ * Client-side compaction:
10830
+ * - After `compactionThreshold` encrypted updates have been applied in this
10831
+ * session (local + remote), and the doc has been quiescent for
10832
+ * `compactionQuiescenceMs`, the provider merges the whole Y.Doc, encrypts it,
10833
+ * and sends `snapshot:compact` — the server atomically replaces the per-doc
10834
+ * update log with that single compacted blob. The server acknowledges by
10835
+ * broadcasting `snapshot:compacted`, which emits the `"compacted"` event.
10836
+ * - Requires Owner or above (server silently drops non-Owner requests).
10837
+ * - Callers that want a final compaction before teardown should
10838
+ * `await provider.compactNow()` before `destroy()`. `destroy()` does not
10839
+ * compact (it'd race with the socket teardown) — any pending debounce is
10840
+ * cancelled.
10841
+ *
10819
10842
  * Key availability limitation: if the user's WebAuthn key is not in
10820
10843
  * DocKeyManager's in-memory cache and there is no network, E2E docs show
10821
10844
  * empty — the key fetch requires either a cached in-memory key or network.
@@ -10823,6 +10846,11 @@ var E2EOfflineStore = class extends OfflineStore {
10823
10846
  function fromBase64(b64) {
10824
10847
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
10825
10848
  }
10849
+ function toBase64(bytes) {
10850
+ let bin = "";
10851
+ for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
10852
+ return btoa(bin);
10853
+ }
10826
10854
  var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraProvider {
10827
10855
  constructor(configuration) {
10828
10856
  super({
@@ -10832,9 +10860,17 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10832
10860
  this.docKey = null;
10833
10861
  this.lastSeq = -1;
10834
10862
  this.e2eStore = null;
10863
+ this.updatesSinceCompaction = 0;
10864
+ this.compactionInFlight = false;
10865
+ this.compactionInFlightTimeout = null;
10866
+ this.compactionDebounceTimer = null;
10867
+ this.destroyed = false;
10835
10868
  this.docKeyManager = configuration.docKeyManager;
10836
10869
  this.keystore = configuration.keystore;
10837
10870
  this.e2eClient = configuration.client;
10871
+ this.compactionEnabled = configuration.compactionEnabled !== false;
10872
+ this.compactionThreshold = Math.max(1, configuration.compactionThreshold ?? 50);
10873
+ this.compactionQuiescenceMs = Math.max(0, configuration.compactionQuiescenceMs ?? 2e3);
10838
10874
  this.e2eServerOrigin = E2EAbracadabraProvider.deriveServerOrigin(configuration, configuration.client);
10839
10875
  }
10840
10876
  /** Fetch the doc key from the server (requires WebAuthn if not cached). */
@@ -10846,6 +10882,10 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10846
10882
  }
10847
10883
  /** Handle stateless messages including e2e_ready and e2e_update. */
10848
10884
  receiveStateless(payload) {
10885
+ if (payload.startsWith("snapshot:compacted ")) {
10886
+ this._handleCompactedBroadcast(payload.slice(19));
10887
+ return;
10888
+ }
10849
10889
  let parsed;
10850
10890
  try {
10851
10891
  parsed = JSON.parse(payload);
@@ -10897,6 +10937,7 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10897
10937
  const plaintext = await decryptField(encryptedData, key);
10898
10938
  Y.applyUpdate(this.document, plaintext, this);
10899
10939
  this.lastSeq = Math.max(this.lastSeq, seq);
10940
+ this._noteUpdateApplied();
10900
10941
  } catch (e) {
10901
10942
  console.error("[E2EAbracadabraProvider] decryption failed for seq", seq, e);
10902
10943
  }
@@ -10919,8 +10960,93 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10919
10960
  update: encrypted,
10920
10961
  documentName: this.configuration.name
10921
10962
  });
10963
+ this._noteUpdateApplied();
10964
+ }
10965
+ /**
10966
+ * Force an immediate compaction attempt, bypassing the threshold and
10967
+ * quiescence debounce. Resolves once the `snapshot:compact` frame has been
10968
+ * sent (or rejected locally for missing prerequisites — destroyed, no
10969
+ * doc key, or already in-flight). The server acknowledges via a
10970
+ * `snapshot:compacted` broadcast, which emits the `"compacted"` event.
10971
+ */
10972
+ async compactNow() {
10973
+ if (this.compactionDebounceTimer) {
10974
+ clearTimeout(this.compactionDebounceTimer);
10975
+ this.compactionDebounceTimer = null;
10976
+ }
10977
+ await this._performCompaction();
10978
+ }
10979
+ _noteUpdateApplied() {
10980
+ if (!this.compactionEnabled || this.destroyed) return;
10981
+ this.updatesSinceCompaction += 1;
10982
+ if (this.updatesSinceCompaction < this.compactionThreshold) return;
10983
+ if (this.compactionDebounceTimer) clearTimeout(this.compactionDebounceTimer);
10984
+ this.compactionDebounceTimer = setTimeout(() => {
10985
+ this.compactionDebounceTimer = null;
10986
+ this._performCompaction().catch((e) => {
10987
+ console.error("[E2EAbracadabraProvider] compaction failed:", e);
10988
+ });
10989
+ }, this.compactionQuiescenceMs);
10990
+ }
10991
+ async _performCompaction() {
10992
+ if (this.destroyed) return;
10993
+ if (this.compactionInFlight) return;
10994
+ if (this.updatesSinceCompaction === 0) return;
10995
+ if (!this.synced) return;
10996
+ const key = await this.ensureDocKey();
10997
+ if (!key) return;
10998
+ if (this.destroyed) return;
10999
+ this.compactionInFlight = true;
11000
+ try {
11001
+ const stateVector = Y.encodeStateVector(this.document);
11002
+ const encrypted = await encryptField(Y.encodeStateAsUpdate(this.document), key);
11003
+ if (this.destroyed) {
11004
+ this.compactionInFlight = false;
11005
+ return;
11006
+ }
11007
+ const payload = `snapshot:compact ${JSON.stringify({
11008
+ state_vector: toBase64(stateVector),
11009
+ compacted: toBase64(encrypted)
11010
+ })}`;
11011
+ this.sendStateless(payload);
11012
+ this.compactionInFlightTimeout = setTimeout(() => {
11013
+ this.compactionInFlight = false;
11014
+ this.compactionInFlightTimeout = null;
11015
+ }, 3e4);
11016
+ } catch (e) {
11017
+ this.compactionInFlight = false;
11018
+ throw e;
11019
+ }
11020
+ }
11021
+ _handleCompactedBroadcast(jsonStr) {
11022
+ let parsed = {};
11023
+ try {
11024
+ parsed = JSON.parse(jsonStr);
11025
+ } catch {}
11026
+ if (parsed.doc_id && parsed.doc_id !== this.configuration.name) return;
11027
+ if (this.compactionInFlightTimeout) {
11028
+ clearTimeout(this.compactionInFlightTimeout);
11029
+ this.compactionInFlightTimeout = null;
11030
+ }
11031
+ this.compactionInFlight = false;
11032
+ this.updatesSinceCompaction = 0;
11033
+ const event = {
11034
+ docId: parsed.doc_id ?? this.configuration.name,
11035
+ by: parsed.by
11036
+ };
11037
+ this.emit("compacted", event);
10922
11038
  }
10923
11039
  destroy() {
11040
+ if (this.destroyed) return;
11041
+ this.destroyed = true;
11042
+ if (this.compactionDebounceTimer) {
11043
+ clearTimeout(this.compactionDebounceTimer);
11044
+ this.compactionDebounceTimer = null;
11045
+ }
11046
+ if (this.compactionInFlightTimeout) {
11047
+ clearTimeout(this.compactionInFlightTimeout);
11048
+ this.compactionInFlightTimeout = null;
11049
+ }
10924
11050
  this.e2eStore?.destroy();
10925
11051
  this.e2eStore = null;
10926
11052
  super.destroy();