@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.
@@ -2468,12 +2468,13 @@ var AbracadabraBaseProvider = class extends EventEmitter {
2468
2468
  try {
2469
2469
  const parsed = JSON.parse(payload);
2470
2470
  if (parsed?.type === "error" && parsed.source && parsed.code) {
2471
- const { source, code, message } = parsed;
2472
- console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`);
2471
+ const { source, code, message, meta } = parsed;
2472
+ console.warn(`[Abracadabra] Server error: ${source} (${code}) — ${message}`, meta ?? "");
2473
2473
  this.emit("serverError", {
2474
2474
  source,
2475
2475
  code,
2476
- message: message ?? ""
2476
+ message: message ?? "",
2477
+ meta
2477
2478
  });
2478
2479
  return;
2479
2480
  }
@@ -10455,6 +10456,15 @@ var FileBlobStore = class FileBlobStore extends EventEmitter {
10455
10456
  this.objectUrls.delete(key);
10456
10457
  }
10457
10458
  }
10459
+ /**
10460
+ * Clear the 404 negative-cache entry for (docId, uploadId) so the next
10461
+ * getBlobUrl() re-fetches from the server instead of short-circuiting.
10462
+ * Use on explicit user retry or after reconnect, when the file may have
10463
+ * become available since the last 404.
10464
+ */
10465
+ clearNotFound(docId, uploadId) {
10466
+ this._notFound.delete(this.blobKey(docId, uploadId));
10467
+ }
10458
10468
  /** Revoke the object URL and remove the blob from cache. */
10459
10469
  async evictBlob(docId, uploadId) {
10460
10470
  const key = this.blobKey(docId, uploadId);
@@ -10855,6 +10865,19 @@ var E2EOfflineStore = class extends OfflineStore {
10855
10865
  * are fetched on subsequent connects.
10856
10866
  * - After sync, a fresh encrypted snapshot is saved.
10857
10867
  *
10868
+ * Client-side compaction:
10869
+ * - After `compactionThreshold` encrypted updates have been applied in this
10870
+ * session (local + remote), and the doc has been quiescent for
10871
+ * `compactionQuiescenceMs`, the provider merges the whole Y.Doc, encrypts it,
10872
+ * and sends `snapshot:compact` — the server atomically replaces the per-doc
10873
+ * update log with that single compacted blob. The server acknowledges by
10874
+ * broadcasting `snapshot:compacted`, which emits the `"compacted"` event.
10875
+ * - Requires Owner or above (server silently drops non-Owner requests).
10876
+ * - Callers that want a final compaction before teardown should
10877
+ * `await provider.compactNow()` before `destroy()`. `destroy()` does not
10878
+ * compact (it'd race with the socket teardown) — any pending debounce is
10879
+ * cancelled.
10880
+ *
10858
10881
  * Key availability limitation: if the user's WebAuthn key is not in
10859
10882
  * DocKeyManager's in-memory cache and there is no network, E2E docs show
10860
10883
  * empty — the key fetch requires either a cached in-memory key or network.
@@ -10862,6 +10885,11 @@ var E2EOfflineStore = class extends OfflineStore {
10862
10885
  function fromBase64(b64) {
10863
10886
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
10864
10887
  }
10888
+ function toBase64(bytes) {
10889
+ let bin = "";
10890
+ for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
10891
+ return btoa(bin);
10892
+ }
10865
10893
  var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraProvider {
10866
10894
  constructor(configuration) {
10867
10895
  super({
@@ -10871,9 +10899,17 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10871
10899
  this.docKey = null;
10872
10900
  this.lastSeq = -1;
10873
10901
  this.e2eStore = null;
10902
+ this.updatesSinceCompaction = 0;
10903
+ this.compactionInFlight = false;
10904
+ this.compactionInFlightTimeout = null;
10905
+ this.compactionDebounceTimer = null;
10906
+ this.destroyed = false;
10874
10907
  this.docKeyManager = configuration.docKeyManager;
10875
10908
  this.keystore = configuration.keystore;
10876
10909
  this.e2eClient = configuration.client;
10910
+ this.compactionEnabled = configuration.compactionEnabled !== false;
10911
+ this.compactionThreshold = Math.max(1, configuration.compactionThreshold ?? 50);
10912
+ this.compactionQuiescenceMs = Math.max(0, configuration.compactionQuiescenceMs ?? 2e3);
10877
10913
  this.e2eServerOrigin = E2EAbracadabraProvider.deriveServerOrigin(configuration, configuration.client);
10878
10914
  }
10879
10915
  /** Fetch the doc key from the server (requires WebAuthn if not cached). */
@@ -10885,6 +10921,10 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10885
10921
  }
10886
10922
  /** Handle stateless messages including e2e_ready and e2e_update. */
10887
10923
  receiveStateless(payload) {
10924
+ if (payload.startsWith("snapshot:compacted ")) {
10925
+ this._handleCompactedBroadcast(payload.slice(19));
10926
+ return;
10927
+ }
10888
10928
  let parsed;
10889
10929
  try {
10890
10930
  parsed = JSON.parse(payload);
@@ -10936,6 +10976,7 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10936
10976
  const plaintext = await decryptField(encryptedData, key);
10937
10977
  yjs.applyUpdate(this.document, plaintext, this);
10938
10978
  this.lastSeq = Math.max(this.lastSeq, seq);
10979
+ this._noteUpdateApplied();
10939
10980
  } catch (e) {
10940
10981
  console.error("[E2EAbracadabraProvider] decryption failed for seq", seq, e);
10941
10982
  }
@@ -10958,8 +10999,93 @@ var E2EAbracadabraProvider = class E2EAbracadabraProvider extends AbracadabraPro
10958
10999
  update: encrypted,
10959
11000
  documentName: this.configuration.name
10960
11001
  });
11002
+ this._noteUpdateApplied();
11003
+ }
11004
+ /**
11005
+ * Force an immediate compaction attempt, bypassing the threshold and
11006
+ * quiescence debounce. Resolves once the `snapshot:compact` frame has been
11007
+ * sent (or rejected locally for missing prerequisites — destroyed, no
11008
+ * doc key, or already in-flight). The server acknowledges via a
11009
+ * `snapshot:compacted` broadcast, which emits the `"compacted"` event.
11010
+ */
11011
+ async compactNow() {
11012
+ if (this.compactionDebounceTimer) {
11013
+ clearTimeout(this.compactionDebounceTimer);
11014
+ this.compactionDebounceTimer = null;
11015
+ }
11016
+ await this._performCompaction();
11017
+ }
11018
+ _noteUpdateApplied() {
11019
+ if (!this.compactionEnabled || this.destroyed) return;
11020
+ this.updatesSinceCompaction += 1;
11021
+ if (this.updatesSinceCompaction < this.compactionThreshold) return;
11022
+ if (this.compactionDebounceTimer) clearTimeout(this.compactionDebounceTimer);
11023
+ this.compactionDebounceTimer = setTimeout(() => {
11024
+ this.compactionDebounceTimer = null;
11025
+ this._performCompaction().catch((e) => {
11026
+ console.error("[E2EAbracadabraProvider] compaction failed:", e);
11027
+ });
11028
+ }, this.compactionQuiescenceMs);
11029
+ }
11030
+ async _performCompaction() {
11031
+ if (this.destroyed) return;
11032
+ if (this.compactionInFlight) return;
11033
+ if (this.updatesSinceCompaction === 0) return;
11034
+ if (!this.synced) return;
11035
+ const key = await this.ensureDocKey();
11036
+ if (!key) return;
11037
+ if (this.destroyed) return;
11038
+ this.compactionInFlight = true;
11039
+ try {
11040
+ const stateVector = yjs.encodeStateVector(this.document);
11041
+ const encrypted = await encryptField(yjs.encodeStateAsUpdate(this.document), key);
11042
+ if (this.destroyed) {
11043
+ this.compactionInFlight = false;
11044
+ return;
11045
+ }
11046
+ const payload = `snapshot:compact ${JSON.stringify({
11047
+ state_vector: toBase64(stateVector),
11048
+ compacted: toBase64(encrypted)
11049
+ })}`;
11050
+ this.sendStateless(payload);
11051
+ this.compactionInFlightTimeout = setTimeout(() => {
11052
+ this.compactionInFlight = false;
11053
+ this.compactionInFlightTimeout = null;
11054
+ }, 3e4);
11055
+ } catch (e) {
11056
+ this.compactionInFlight = false;
11057
+ throw e;
11058
+ }
11059
+ }
11060
+ _handleCompactedBroadcast(jsonStr) {
11061
+ let parsed = {};
11062
+ try {
11063
+ parsed = JSON.parse(jsonStr);
11064
+ } catch {}
11065
+ if (parsed.doc_id && parsed.doc_id !== this.configuration.name) return;
11066
+ if (this.compactionInFlightTimeout) {
11067
+ clearTimeout(this.compactionInFlightTimeout);
11068
+ this.compactionInFlightTimeout = null;
11069
+ }
11070
+ this.compactionInFlight = false;
11071
+ this.updatesSinceCompaction = 0;
11072
+ const event = {
11073
+ docId: parsed.doc_id ?? this.configuration.name,
11074
+ by: parsed.by
11075
+ };
11076
+ this.emit("compacted", event);
10961
11077
  }
10962
11078
  destroy() {
11079
+ if (this.destroyed) return;
11080
+ this.destroyed = true;
11081
+ if (this.compactionDebounceTimer) {
11082
+ clearTimeout(this.compactionDebounceTimer);
11083
+ this.compactionDebounceTimer = null;
11084
+ }
11085
+ if (this.compactionInFlightTimeout) {
11086
+ clearTimeout(this.compactionInFlightTimeout);
11087
+ this.compactionInFlightTimeout = null;
11088
+ }
10963
11089
  this.e2eStore?.destroy();
10964
11090
  this.e2eStore = null;
10965
11091
  super.destroy();