@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.
- package/dist/abracadabra-provider.cjs +12722 -9050
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12683 -9061
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1485 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +228 -0
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +1862 -0
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +628 -0
- package/src/DocUtils.ts +89 -0
- package/src/DocumentManager.ts +319 -0
- package/src/E2EAbracadabraProvider.ts +189 -0
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/FileBlobStore.ts +10 -0
- package/src/IdentityDoc.ts +25 -0
- package/src/MetaManager.ts +100 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +473 -0
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +174 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
package/src/DocUtils.ts
ADDED
|
@@ -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();
|