@abraca/dabra 1.8.2 → 2.0.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.
Files changed (37) hide show
  1. package/dist/abracadabra-provider.cjs +12722 -9050
  2. package/dist/abracadabra-provider.cjs.map +1 -1
  3. package/dist/abracadabra-provider.esm.js +12683 -9061
  4. package/dist/abracadabra-provider.esm.js.map +1 -1
  5. package/dist/index.d.ts +1485 -118
  6. package/package.json +1 -1
  7. package/src/AbracadabraBaseProvider.ts +51 -2
  8. package/src/AbracadabraClient.ts +516 -66
  9. package/src/AbracadabraProvider.ts +22 -7
  10. package/src/AbracadabraWS.ts +1 -1
  11. package/src/ChatClient.ts +193 -113
  12. package/src/ContentManager.ts +228 -0
  13. package/src/CryptoIdentityKeystore.ts +3 -3
  14. package/src/DocConverters.ts +1862 -0
  15. package/src/DocKeyManager.ts +60 -12
  16. package/src/DocTypes.ts +628 -0
  17. package/src/DocUtils.ts +89 -0
  18. package/src/DocumentManager.ts +319 -0
  19. package/src/E2EAbracadabraProvider.ts +189 -0
  20. package/src/EncryptedChatClient.ts +173 -0
  21. package/src/EncryptedY.ts +2 -2
  22. package/src/FileBlobStore.ts +10 -0
  23. package/src/IdentityDoc.ts +25 -0
  24. package/src/MetaManager.ts +100 -0
  25. package/src/MnemonicKeyDerivation.ts +4 -4
  26. package/src/NotificationsClient.ts +120 -98
  27. package/src/OutgoingMessages/SubdocMessage.ts +2 -2
  28. package/src/RpcClient.ts +659 -0
  29. package/src/TreeManager.ts +473 -0
  30. package/src/TreeTimestamps.ts +28 -25
  31. package/src/index.ts +71 -1
  32. package/src/messageRecord.ts +121 -0
  33. package/src/types.ts +174 -16
  34. package/src/webrtc/AbracadabraWebRTC.ts +2 -2
  35. package/src/webrtc/DataChannelRouter.ts +2 -2
  36. package/src/webrtc/E2EEChannel.ts +3 -3
  37. package/src/webrtc/FileTransferChannel.ts +9 -2
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared utilities for the DocumentManager ORM layer.
3
+ *
4
+ * These functions were previously duplicated across `mcp/src/utils.ts`,
5
+ * `mcp/src/server.ts`, `cli/src/connection.ts`, and `mcp/src/tools/tree.ts`.
6
+ */
7
+ import * as Y from "yjs";
8
+
9
+ /**
10
+ * Wait for a provider's `synced` event with a timeout.
11
+ * Resolves immediately if the provider is already synced.
12
+ */
13
+ export function waitForSync(
14
+ provider: {
15
+ isSynced?: boolean;
16
+ on(event: string, cb: () => void): void;
17
+ off(event: string, cb: () => void): void;
18
+ },
19
+ timeoutMs = 15000,
20
+ ): Promise<void> {
21
+ // Already synced — resolve immediately.
22
+ if (provider.isSynced) return Promise.resolve();
23
+
24
+ return new Promise<void>((resolve, reject) => {
25
+ const timer = setTimeout(() => {
26
+ provider.off("synced", handler);
27
+ reject(new Error(`Sync timed out after ${timeoutMs}ms`));
28
+ }, timeoutMs);
29
+
30
+ function handler() {
31
+ clearTimeout(timer);
32
+ resolve();
33
+ }
34
+
35
+ provider.on("synced", handler);
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Wrap a promise with a timeout.
41
+ */
42
+ export function withTimeout<T>(
43
+ promise: Promise<T>,
44
+ timeoutMs: number,
45
+ message?: string,
46
+ ): Promise<T> {
47
+ return new Promise<T>((resolve, reject) => {
48
+ const timer = setTimeout(
49
+ () =>
50
+ reject(
51
+ new Error(
52
+ message ?? `Operation timed out after ${timeoutMs}ms`,
53
+ ),
54
+ ),
55
+ timeoutMs,
56
+ );
57
+ promise.then(
58
+ (val) => {
59
+ clearTimeout(timer);
60
+ resolve(val);
61
+ },
62
+ (err) => {
63
+ clearTimeout(timer);
64
+ reject(err);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Normalize a document ID so the hub/root doc ID is treated as the tree root
72
+ * (null). This lets callers pass the hub doc_id from list_spaces as
73
+ * parentId/rootId and get the expected root-level results instead of an empty
74
+ * set.
75
+ */
76
+ export function normalizeRootId(
77
+ id: string | null | undefined,
78
+ rootDocId: string | null,
79
+ ): string | null {
80
+ if (id == null) return null;
81
+ return id === rootDocId ? null : id;
82
+ }
83
+
84
+ /**
85
+ * Safely read a tree map value, converting Y.Map to plain object if needed.
86
+ */
87
+ export function toPlain(val: unknown): unknown {
88
+ return val instanceof Y.Map ? val.toJSON() : val;
89
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * DocumentManager — high-level ORM entry point for Abracadabra CRDT documents.
3
+ *
4
+ * Consolidates the shared connection lifecycle, authentication, space discovery,
5
+ * provider caching, and Y.Map access patterns that were previously duplicated
6
+ * across `mcp/src/server.ts` and `cli/src/connection.ts`.
7
+ *
8
+ * Usage:
9
+ * ```ts
10
+ * const dm = new DocumentManager({
11
+ * url: 'https://my-server.example.com',
12
+ * name: 'My Agent',
13
+ * })
14
+ * // Authenticate before connecting (consumer handles crypto):
15
+ * await dm.client.loginWithKey(publicKey, signFn)
16
+ * await dm.connect()
17
+ *
18
+ * // Use the ORM:
19
+ * const docs = dm.tree.childrenOf(null) // root-level docs
20
+ * const content = await dm.content.read(docId) // markdown + children
21
+ * dm.meta.update(docId, { color: '#ff0000' }) // update metadata
22
+ * ```
23
+ */
24
+ import * as Y from "yjs";
25
+ import { AbracadabraProvider } from "./AbracadabraProvider.ts";
26
+ import { AbracadabraClient } from "./AbracadabraClient.ts";
27
+ import type { AbracadabraWS } from "./AbracadabraWS.ts";
28
+ import type { ServerInfo, DocumentMeta } from "./types.ts";
29
+ import { Kind, SERVER_ROOT_ID } from "./types.ts";
30
+ import { TreeManager } from "./TreeManager.ts";
31
+ import { ContentManager } from "./ContentManager.ts";
32
+ import { MetaManager } from "./MetaManager.ts";
33
+ import { waitForSync } from "./DocUtils.ts";
34
+
35
+ export interface DocumentManagerConfig {
36
+ /** Server base URL (http or https). */
37
+ url: string;
38
+ /** Display name for awareness (cursor labels, presence). */
39
+ name?: string;
40
+ /** Cursor / awareness color. */
41
+ color?: string;
42
+ /** Invite code for first-time registration. */
43
+ inviteCode?: string;
44
+ /** Suppress stderr logging. */
45
+ quiet?: boolean;
46
+ /** Disable IndexedDB offline persistence. */
47
+ disableOfflineStore?: boolean;
48
+ /** Custom fetch for Node.js environments. */
49
+ fetch?: typeof globalThis.fetch;
50
+ /**
51
+ * Pre-configured AbracadabraClient instance.
52
+ * When provided, the `url` and `fetch` fields are ignored — the client's
53
+ * configuration is used instead. This is useful when the consumer has
54
+ * already authenticated or configured the client externally.
55
+ */
56
+ client?: AbracadabraClient;
57
+ /**
58
+ * Shared WebSocket connection. Required in Node.js environments — pass an
59
+ * AbracadabraWS constructed with WebSocketPolyfill set to the `ws` package.
60
+ * When omitted, each provider creates its own connection from client.wsUrl.
61
+ * The caller owns this instance and must destroy it after dm.destroy().
62
+ */
63
+ websocketProvider?: AbracadabraWS;
64
+ /**
65
+ * Known root document ID. When provided, connect() skips server discovery
66
+ * and connects directly to this document. Useful in tests or CLIs where
67
+ * the entry-point docId is already known.
68
+ */
69
+ rootDocId?: string;
70
+ }
71
+
72
+ interface CachedProvider {
73
+ provider: AbracadabraProvider;
74
+ lastAccessed: number;
75
+ }
76
+
77
+ export class DocumentManager {
78
+ readonly client: AbracadabraClient;
79
+
80
+ /** Tree CRUD operations. */
81
+ readonly tree: TreeManager;
82
+ /** Document content read/write. */
83
+ readonly content: ContentManager;
84
+ /** Document metadata read/write. */
85
+ readonly meta: MetaManager;
86
+
87
+ private _config: DocumentManagerConfig;
88
+ private _serverInfo: ServerInfo | null = null;
89
+ private _rootDocId: string | null = null;
90
+ private _spaces: DocumentMeta[] = [];
91
+ private _rootDoc: Y.Doc | null = null;
92
+ private _rootProvider: AbracadabraProvider | null = null;
93
+ private childCache = new Map<string, CachedProvider>();
94
+
95
+ constructor(config: DocumentManagerConfig) {
96
+ this._config = config;
97
+ this.client =
98
+ config.client ??
99
+ new AbracadabraClient({
100
+ url: config.url,
101
+ persistAuth: false,
102
+ fetch: config.fetch,
103
+ });
104
+
105
+ this.tree = new TreeManager(this);
106
+ this.content = new ContentManager(this);
107
+ this.meta = new MetaManager(this);
108
+ }
109
+
110
+ // ── Accessors ─────────────────────────────────────────────────────────────
111
+
112
+ get displayName(): string {
113
+ return this._config.name || "DocumentManager";
114
+ }
115
+
116
+ get displayColor(): string {
117
+ return this._config.color || "hsl(45, 90%, 55%)";
118
+ }
119
+
120
+ get serverInfo(): ServerInfo | null {
121
+ return this._serverInfo;
122
+ }
123
+
124
+ get rootDocId(): string | null {
125
+ return this._rootDocId;
126
+ }
127
+
128
+ get rootDocument(): Y.Doc | null {
129
+ return this._rootDoc;
130
+ }
131
+
132
+ get rootProvider(): AbracadabraProvider | null {
133
+ return this._rootProvider;
134
+ }
135
+
136
+ /**
137
+ * Spaces visible to the caller — direct children of the server root with
138
+ * `kind === "space"`. Populated by {@link connect}.
139
+ */
140
+ get spaces(): DocumentMeta[] {
141
+ return this._spaces;
142
+ }
143
+
144
+ get isConnected(): boolean {
145
+ return this._rootProvider?.isConnected ?? false;
146
+ }
147
+
148
+ // ── Y.Map access ──────────────────────────────────────────────────────────
149
+
150
+ /** Get the root doc-tree Y.Map. */
151
+ getTreeMap(): Y.Map<any> | null {
152
+ return this._rootDoc?.getMap("doc-tree") ?? null;
153
+ }
154
+
155
+ /** Get the root doc-trash Y.Map. */
156
+ getTrashMap(): Y.Map<any> | null {
157
+ return this._rootDoc?.getMap("doc-trash") ?? null;
158
+ }
159
+
160
+ /** Get the root space-plugins Y.Map. */
161
+ getPluginsMap(): Y.Map<any> | null {
162
+ return this._rootDoc?.getMap("space-plugins") ?? null;
163
+ }
164
+
165
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Connect to the server: discover the entry-point document, sync the root
169
+ * Y.Doc, and set awareness.
170
+ *
171
+ * **Authentication must be done before calling connect().**
172
+ * Call `dm.client.loginWithKey(publicKey, signFn)` or `dm.client.login()`
173
+ * to authenticate first.
174
+ *
175
+ * When `rootDocId` is set in the config, server discovery is skipped and
176
+ * the manager connects directly to that document.
177
+ */
178
+ async connect(): Promise<void> {
179
+ // Step 1: Discover server info (used for awareness colour, name, etc.).
180
+ this._serverInfo = await this.client.serverInfo();
181
+
182
+ // Step 2: Pick an entry-point doc.
183
+ //
184
+ // In the new model the dashboard's notion of "open the hub" maps to
185
+ // "open the first Space". A Space is a top-level doc with
186
+ // `kind === "space"`. If the caller pinned a `rootDocId`, honour it
187
+ // directly — useful in tests/CLIs where the entry doc is known.
188
+ let initialDocId: string | null = this._config.rootDocId ?? null;
189
+
190
+ if (!initialDocId) {
191
+ const roots = await this.client.listChildren();
192
+ this._spaces = roots.filter((d) => d.kind === Kind.Space);
193
+ const first = this._spaces[0] ?? roots[0];
194
+ if (first) {
195
+ initialDocId = first.id;
196
+ this.log(`Entry document: ${first.label ?? first.id} (${first.id})`);
197
+ }
198
+ }
199
+
200
+ if (!initialDocId) {
201
+ throw new Error(
202
+ `No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
203
+ );
204
+ }
205
+
206
+ this._rootDocId = initialDocId;
207
+
208
+ // Step 3: Connect provider and sync
209
+ await this._connectToRoot(initialDocId);
210
+ this.log("Connected and synced");
211
+ }
212
+
213
+ /** Switch active space to a different root document. */
214
+ async switchSpace(docId: string): Promise<void> {
215
+ // Destroy existing child providers
216
+ for (const [, cached] of this.childCache) {
217
+ cached.provider.destroy();
218
+ }
219
+ this.childCache.clear();
220
+
221
+ // Destroy current root provider (not the wsp — caller owns it)
222
+ if (this._rootProvider) {
223
+ this._rootProvider.destroy();
224
+ this._rootProvider = null;
225
+ }
226
+ this._rootDoc = null;
227
+
228
+ await this._connectToRoot(docId);
229
+ this._rootDocId = docId;
230
+ this.log(`Switched to space ${docId}`);
231
+ }
232
+
233
+ /** Create, sync, and set awareness on a root provider for the given docId. */
234
+ private async _connectToRoot(docId: string): Promise<void> {
235
+ const doc = new Y.Doc({ guid: docId });
236
+ const wsp = this._config.websocketProvider;
237
+ const provider = new AbracadabraProvider({
238
+ name: docId,
239
+ document: doc,
240
+ client: this.client,
241
+ disableOfflineStore: this._config.disableOfflineStore ?? true,
242
+ subdocLoading: "lazy",
243
+ ...(wsp ? { websocketProvider: wsp } : {}),
244
+ });
245
+
246
+ // When a shared websocketProvider is supplied, manageSocket=false so
247
+ // the base constructor does NOT call attach(). We must do it ourselves.
248
+ if (wsp) provider.attach();
249
+
250
+ await waitForSync(provider);
251
+
252
+ provider.awareness?.setLocalStateField("user", {
253
+ name: this.displayName,
254
+ color: this.displayColor,
255
+ });
256
+ provider.awareness?.setLocalStateField("status", null);
257
+
258
+ this._rootDoc = doc;
259
+ this._rootProvider = provider;
260
+ }
261
+
262
+ /** Graceful shutdown. */
263
+ async destroy(): Promise<void> {
264
+ for (const [, cached] of this.childCache) {
265
+ cached.provider.destroy();
266
+ }
267
+ this.childCache.clear();
268
+
269
+ if (this._rootProvider) {
270
+ this._rootProvider.awareness?.setLocalStateField(
271
+ "status",
272
+ null,
273
+ );
274
+ this._rootProvider.destroy();
275
+ this._rootProvider = null;
276
+ }
277
+ this._rootDoc = null;
278
+
279
+ this.log("Disconnected");
280
+ }
281
+
282
+ // ── Provider cache ────────────────────────────────────────────────────────
283
+
284
+ /** Get or create a child provider for a document (synced). */
285
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
286
+ const cached = this.childCache.get(docId);
287
+ if (cached) {
288
+ cached.lastAccessed = Date.now();
289
+ return cached.provider;
290
+ }
291
+
292
+ if (!this._rootProvider) {
293
+ throw new Error("Not connected. Call connect() first.");
294
+ }
295
+
296
+ const childProvider = await this._rootProvider.loadChild(docId);
297
+ await waitForSync(childProvider);
298
+
299
+ childProvider.awareness?.setLocalStateField("user", {
300
+ name: this.displayName,
301
+ color: this.displayColor,
302
+ });
303
+
304
+ this.childCache.set(docId, {
305
+ provider: childProvider,
306
+ lastAccessed: Date.now(),
307
+ });
308
+
309
+ return childProvider;
310
+ }
311
+
312
+ // ── Internal ──────────────────────────────────────────────────────────────
313
+
314
+ private log(msg: string): void {
315
+ if (!this._config.quiet) {
316
+ console.error(`[abracadabra] ${msg}`);
317
+ }
318
+ }
319
+ }
@@ -16,6 +16,19 @@
16
16
  * are fetched on subsequent connects.
17
17
  * - After sync, a fresh encrypted snapshot is saved.
18
18
  *
19
+ * Client-side compaction:
20
+ * - After `compactionThreshold` encrypted updates have been applied in this
21
+ * session (local + remote), and the doc has been quiescent for
22
+ * `compactionQuiescenceMs`, the provider merges the whole Y.Doc, encrypts it,
23
+ * and sends `snapshot:compact` — the server atomically replaces the per-doc
24
+ * update log with that single compacted blob. The server acknowledges by
25
+ * broadcasting `snapshot:compacted`, which emits the `"compacted"` event.
26
+ * - Requires Owner or above (server silently drops non-Owner requests).
27
+ * - Callers that want a final compaction before teardown should
28
+ * `await provider.compactNow()` before `destroy()`. `destroy()` does not
29
+ * compact (it'd race with the socket teardown) — any pending debounce is
30
+ * cancelled.
31
+ *
19
32
  * Key availability limitation: if the user's WebAuthn key is not in
20
33
  * DocKeyManager's in-memory cache and there is no network, E2E docs show
21
34
  * empty — the key fetch requires either a cached in-memory key or network.
@@ -30,15 +43,49 @@ import type { AbracadabraClient } from "./AbracadabraClient.ts";
30
43
  import { UpdateMessage } from "./OutgoingMessages/UpdateMessage.ts";
31
44
  import { encryptField, decryptField } from "./EncryptedY.ts";
32
45
  import { E2EOfflineStore } from "./E2EOfflineStore.ts";
46
+ import type { onCompactedParameters } from "./types.ts";
33
47
 
34
48
  function fromBase64(b64: string): Uint8Array {
35
49
  return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
36
50
  }
37
51
 
52
+ function toBase64(bytes: Uint8Array): string {
53
+ let bin = "";
54
+ for (let i = 0; i < bytes.length; i += 0x8000) {
55
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
56
+ }
57
+ return btoa(bin);
58
+ }
59
+
38
60
  export interface E2EAbracadabraProviderConfiguration extends AbracadabraProviderConfiguration {
39
61
  docKeyManager: DocKeyManager;
40
62
  keystore: CryptoIdentityKeystore;
41
63
  client: AbracadabraClient;
64
+ /**
65
+ * Enable client-side compaction: after `compactionThreshold` E2E updates,
66
+ * the provider merges the whole doc, re-encrypts it, and asks the server to
67
+ * atomically replace its update log via the `snapshot:compact` stateless
68
+ * protocol. Requires the user to have a role that `can_manage` the doc
69
+ * (Owner or above); otherwise the server silently rejects.
70
+ *
71
+ * Default: true.
72
+ */
73
+ compactionEnabled?: boolean;
74
+ /**
75
+ * Number of E2E updates applied in this session before compaction fires.
76
+ * Default: 50. Ignored when `compactionEnabled === false`.
77
+ */
78
+ compactionThreshold?: number;
79
+ /**
80
+ * Quiescence delay: once the threshold is crossed, wait this many ms with
81
+ * no further updates before firing compaction. Debounces active editing
82
+ * and reduces the chance of racing an in-flight remote update that hasn't
83
+ * reached us yet (the server does not currently validate state-vector
84
+ * coverage, so a compaction fired mid-flight could drop an update).
85
+ *
86
+ * Default: 2000 ms.
87
+ */
88
+ compactionQuiescenceMs?: number;
42
89
  }
43
90
 
44
91
  export class E2EAbracadabraProvider extends AbracadabraProvider {
@@ -52,6 +99,18 @@ export class E2EAbracadabraProvider extends AbracadabraProvider {
52
99
  private e2eStore: E2EOfflineStore | null = null;
53
100
  private readonly e2eServerOrigin: string | undefined;
54
101
 
102
+ // ── Client-side compaction ────────────────────────────────────────────────
103
+ private readonly compactionEnabled: boolean;
104
+ private readonly compactionThreshold: number;
105
+ private readonly compactionQuiescenceMs: number;
106
+ private updatesSinceCompaction = 0;
107
+ private compactionInFlight = false;
108
+ /** Cleared on `snapshot:compacted` or on 30s timeout. */
109
+ private compactionInFlightTimeout: ReturnType<typeof setTimeout> | null = null;
110
+ /** Quiescence debounce: reset on every update, fires the actual compaction. */
111
+ private compactionDebounceTimer: ReturnType<typeof setTimeout> | null = null;
112
+ private destroyed = false;
113
+
55
114
  constructor(configuration: E2EAbracadabraProviderConfiguration) {
56
115
  // Disable the parent's offline store — E2E uses its own encrypted store.
57
116
  super({ ...configuration, disableOfflineStore: true });
@@ -59,6 +118,10 @@ export class E2EAbracadabraProvider extends AbracadabraProvider {
59
118
  this.keystore = configuration.keystore;
60
119
  this.e2eClient = configuration.client;
61
120
 
121
+ this.compactionEnabled = configuration.compactionEnabled !== false;
122
+ this.compactionThreshold = Math.max(1, configuration.compactionThreshold ?? 50);
123
+ this.compactionQuiescenceMs = Math.max(0, configuration.compactionQuiescenceMs ?? 2000);
124
+
62
125
  // Derive server origin for E2EOfflineStore namespacing
63
126
  this.e2eServerOrigin = E2EAbracadabraProvider.deriveServerOrigin(
64
127
  configuration,
@@ -89,6 +152,14 @@ export class E2EAbracadabraProvider extends AbracadabraProvider {
89
152
 
90
153
  /** Handle stateless messages including e2e_ready and e2e_update. */
91
154
  override receiveStateless(payload: string): void {
155
+ // `snapshot:compacted {"doc_id":"...","by":"..."}` is a space-separated
156
+ // stateless frame — not valid JSON as a whole — so handle it before the
157
+ // JSON.parse fallthrough.
158
+ if (payload.startsWith("snapshot:compacted ")) {
159
+ this._handleCompactedBroadcast(payload.slice("snapshot:compacted ".length));
160
+ return;
161
+ }
162
+
92
163
  let parsed: unknown;
93
164
  try {
94
165
  parsed = JSON.parse(payload);
@@ -165,6 +236,7 @@ export class E2EAbracadabraProvider extends AbracadabraProvider {
165
236
  const plaintext = await decryptField(encryptedData, key);
166
237
  Y.applyUpdate(this.document, plaintext, this);
167
238
  this.lastSeq = Math.max(this.lastSeq, seq);
239
+ this._noteUpdateApplied();
168
240
  } catch (e) {
169
241
  console.error("[E2EAbracadabraProvider] decryption failed for seq", seq, e);
170
242
  }
@@ -190,9 +262,126 @@ export class E2EAbracadabraProvider extends AbracadabraProvider {
190
262
  update: encrypted,
191
263
  documentName: this.configuration.name,
192
264
  });
265
+ this._noteUpdateApplied();
266
+ }
267
+
268
+ // ── Compaction ────────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Force an immediate compaction attempt, bypassing the threshold and
272
+ * quiescence debounce. Resolves once the `snapshot:compact` frame has been
273
+ * sent (or rejected locally for missing prerequisites — destroyed, no
274
+ * doc key, or already in-flight). The server acknowledges via a
275
+ * `snapshot:compacted` broadcast, which emits the `"compacted"` event.
276
+ */
277
+ async compactNow(): Promise<void> {
278
+ if (this.compactionDebounceTimer) {
279
+ clearTimeout(this.compactionDebounceTimer);
280
+ this.compactionDebounceTimer = null;
281
+ }
282
+ await this._performCompaction();
283
+ }
284
+
285
+ private _noteUpdateApplied(): void {
286
+ if (!this.compactionEnabled || this.destroyed) return;
287
+ this.updatesSinceCompaction += 1;
288
+ if (this.updatesSinceCompaction < this.compactionThreshold) return;
289
+
290
+ // Debounce: reset on every update so compaction only fires after a
291
+ // quiescent window. This avoids racing concurrent remote updates that
292
+ // are still propagating through the server.
293
+ if (this.compactionDebounceTimer) clearTimeout(this.compactionDebounceTimer);
294
+ this.compactionDebounceTimer = setTimeout(() => {
295
+ this.compactionDebounceTimer = null;
296
+ this._performCompaction().catch((e) => {
297
+ console.error("[E2EAbracadabraProvider] compaction failed:", e);
298
+ });
299
+ }, this.compactionQuiescenceMs);
300
+ }
301
+
302
+ private async _performCompaction(): Promise<void> {
303
+ if (this.destroyed) return;
304
+ // Single-flight: server handles concurrent compactions, but we don't
305
+ // want to pile up outbound frames.
306
+ if (this.compactionInFlight) return;
307
+ // Nothing to compact — skip unless explicitly forced after threshold.
308
+ if (this.updatesSinceCompaction === 0) return;
309
+
310
+ // Compaction replaces the entire server-side update log with our state.
311
+ // Only run after the initial sync — otherwise we'd overwrite the log
312
+ // with a partial state.
313
+ if (!this.synced) return;
314
+
315
+ const key = await this.ensureDocKey();
316
+ if (!key) return;
317
+ if (this.destroyed) return; // key fetch may be async
318
+
319
+ this.compactionInFlight = true;
320
+ try {
321
+ const stateVector = Y.encodeStateVector(this.document);
322
+ const fullState = Y.encodeStateAsUpdate(this.document);
323
+ const encrypted = await encryptField(fullState, key);
324
+ if (this.destroyed) {
325
+ this.compactionInFlight = false;
326
+ return;
327
+ }
328
+
329
+ const payload = `snapshot:compact ${JSON.stringify({
330
+ state_vector: toBase64(stateVector),
331
+ compacted: toBase64(encrypted),
332
+ })}`;
333
+ this.sendStateless(payload);
334
+
335
+ // Release in-flight on server broadcast; if nothing arrives in 30s
336
+ // (e.g. permissions rejected, connection dropped) release anyway.
337
+ this.compactionInFlightTimeout = setTimeout(() => {
338
+ this.compactionInFlight = false;
339
+ this.compactionInFlightTimeout = null;
340
+ }, 30_000);
341
+ } catch (e) {
342
+ this.compactionInFlight = false;
343
+ throw e;
344
+ }
345
+ }
346
+
347
+ private _handleCompactedBroadcast(jsonStr: string): void {
348
+ let parsed: { doc_id?: string; by?: string } = {};
349
+ try {
350
+ parsed = JSON.parse(jsonStr);
351
+ } catch {
352
+ /* malformed — still clear in-flight below */
353
+ }
354
+
355
+ // Only act on broadcasts for our own doc (server scopes this already,
356
+ // but be defensive).
357
+ if (parsed.doc_id && parsed.doc_id !== this.configuration.name) return;
358
+
359
+ if (this.compactionInFlightTimeout) {
360
+ clearTimeout(this.compactionInFlightTimeout);
361
+ this.compactionInFlightTimeout = null;
362
+ }
363
+ this.compactionInFlight = false;
364
+ this.updatesSinceCompaction = 0;
365
+
366
+ const event: onCompactedParameters = {
367
+ docId: parsed.doc_id ?? this.configuration.name,
368
+ by: parsed.by,
369
+ };
370
+ this.emit("compacted", event);
193
371
  }
194
372
 
195
373
  override destroy(): void {
374
+ if (this.destroyed) return;
375
+ this.destroyed = true;
376
+
377
+ if (this.compactionDebounceTimer) {
378
+ clearTimeout(this.compactionDebounceTimer);
379
+ this.compactionDebounceTimer = null;
380
+ }
381
+ if (this.compactionInFlightTimeout) {
382
+ clearTimeout(this.compactionInFlightTimeout);
383
+ this.compactionInFlightTimeout = null;
384
+ }
196
385
  this.e2eStore?.destroy();
197
386
  this.e2eStore = null;
198
387
  super.destroy();