@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.
@@ -0,0 +1,342 @@
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 { ServerInfo, SpaceMeta, DocumentMeta } from "./types.ts";
28
+ import { TreeManager } from "./TreeManager.ts";
29
+ import { ContentManager } from "./ContentManager.ts";
30
+ import { MetaManager } from "./MetaManager.ts";
31
+ import { waitForSync } from "./DocUtils.ts";
32
+
33
+ export interface DocumentManagerConfig {
34
+ /** Server base URL (http or https). */
35
+ url: string;
36
+ /** Display name for awareness (cursor labels, presence). */
37
+ name?: string;
38
+ /** Cursor / awareness color. */
39
+ color?: string;
40
+ /** Invite code for first-time registration. */
41
+ inviteCode?: string;
42
+ /** Suppress stderr logging. */
43
+ quiet?: boolean;
44
+ /** Disable IndexedDB offline persistence. */
45
+ disableOfflineStore?: boolean;
46
+ /** Custom fetch for Node.js environments. */
47
+ fetch?: typeof globalThis.fetch;
48
+ /**
49
+ * Pre-configured AbracadabraClient instance.
50
+ * When provided, the `url` and `fetch` fields are ignored — the client's
51
+ * configuration is used instead. This is useful when the consumer has
52
+ * already authenticated or configured the client externally.
53
+ */
54
+ client?: AbracadabraClient;
55
+ }
56
+
57
+ /** Map a DocumentMeta (REST API) to SpaceMeta shape for display compatibility. */
58
+ function docToSpaceMeta(doc: DocumentMeta): SpaceMeta {
59
+ const publicAccess = doc.public_access;
60
+ let visibility: SpaceMeta["visibility"] = "private";
61
+ if (publicAccess && publicAccess !== "none") visibility = "public";
62
+
63
+ return {
64
+ id: doc.id,
65
+ doc_id: doc.id,
66
+ name: doc.label ?? doc.id,
67
+ description: doc.description ?? null,
68
+ visibility,
69
+ is_hub: doc.is_hub ?? false,
70
+ owner_id: doc.owner_id ?? null,
71
+ created_at: 0,
72
+ updated_at: doc.updated_at ?? 0,
73
+ public_access: publicAccess ?? null,
74
+ };
75
+ }
76
+
77
+ interface CachedProvider {
78
+ provider: AbracadabraProvider;
79
+ lastAccessed: number;
80
+ }
81
+
82
+ export class DocumentManager {
83
+ readonly client: AbracadabraClient;
84
+
85
+ /** Tree CRUD operations. */
86
+ readonly tree: TreeManager;
87
+ /** Document content read/write. */
88
+ readonly content: ContentManager;
89
+ /** Document metadata read/write. */
90
+ readonly meta: MetaManager;
91
+
92
+ private _config: DocumentManagerConfig;
93
+ private _serverInfo: ServerInfo | null = null;
94
+ private _rootDocId: string | null = null;
95
+ private _spaces: SpaceMeta[] = [];
96
+ private _rootDoc: Y.Doc | null = null;
97
+ private _rootProvider: AbracadabraProvider | null = null;
98
+ private childCache = new Map<string, CachedProvider>();
99
+
100
+ constructor(config: DocumentManagerConfig) {
101
+ this._config = config;
102
+ this.client =
103
+ config.client ??
104
+ new AbracadabraClient({
105
+ url: config.url,
106
+ persistAuth: false,
107
+ fetch: config.fetch,
108
+ });
109
+
110
+ this.tree = new TreeManager(this);
111
+ this.content = new ContentManager(this);
112
+ this.meta = new MetaManager(this);
113
+ }
114
+
115
+ // ── Accessors ─────────────────────────────────────────────────────────────
116
+
117
+ get displayName(): string {
118
+ return this._config.name || "DocumentManager";
119
+ }
120
+
121
+ get displayColor(): string {
122
+ return this._config.color || "hsl(45, 90%, 55%)";
123
+ }
124
+
125
+ get serverInfo(): ServerInfo | null {
126
+ return this._serverInfo;
127
+ }
128
+
129
+ get rootDocId(): string | null {
130
+ return this._rootDocId;
131
+ }
132
+
133
+ get rootDocument(): Y.Doc | null {
134
+ return this._rootDoc;
135
+ }
136
+
137
+ get rootProvider(): AbracadabraProvider | null {
138
+ return this._rootProvider;
139
+ }
140
+
141
+ get spaces(): SpaceMeta[] {
142
+ return this._spaces;
143
+ }
144
+
145
+ get isConnected(): boolean {
146
+ return this._rootProvider?.isConnected ?? false;
147
+ }
148
+
149
+ // ── Y.Map access ──────────────────────────────────────────────────────────
150
+
151
+ /** Get the root doc-tree Y.Map. */
152
+ getTreeMap(): Y.Map<any> | null {
153
+ return this._rootDoc?.getMap("doc-tree") ?? null;
154
+ }
155
+
156
+ /** Get the root doc-trash Y.Map. */
157
+ getTrashMap(): Y.Map<any> | null {
158
+ return this._rootDoc?.getMap("doc-trash") ?? null;
159
+ }
160
+
161
+ /** Get the root space-plugins Y.Map. */
162
+ getPluginsMap(): Y.Map<any> | null {
163
+ return this._rootDoc?.getMap("space-plugins") ?? null;
164
+ }
165
+
166
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Connect to the server: discover the entry-point document, sync the root
170
+ * Y.Doc, and set awareness.
171
+ *
172
+ * **Authentication must be done before calling connect().**
173
+ * Call `dm.client.loginWithKey(publicKey, signFn)` or `dm.client.login()`
174
+ * to authenticate first.
175
+ */
176
+ async connect(): Promise<void> {
177
+ // Step 1: Discover server info
178
+ this._serverInfo = await this.client.serverInfo();
179
+
180
+ // Step 2: Discover root documents / spaces
181
+ let initialDocId: string | null =
182
+ this._serverInfo.index_doc_id ?? null;
183
+ try {
184
+ const roots = await this.client.listRootDocuments();
185
+ this._spaces = roots.map(docToSpaceMeta);
186
+ const hub = roots.find((d: any) => d.is_hub);
187
+ if (hub) {
188
+ initialDocId = hub.id;
189
+ this.log(
190
+ `Hub document: ${hub.label ?? hub.id} (${hub.id})`,
191
+ );
192
+ } else if (roots.length > 0) {
193
+ initialDocId = roots[0].id;
194
+ this.log(
195
+ `No hub, using first root doc: ${roots[0].label ?? roots[0].id}`,
196
+ );
197
+ }
198
+ } catch {
199
+ try {
200
+ this._spaces = await this.client.listSpaces();
201
+ const hub = this._spaces.find((s) => s.is_hub);
202
+ if (hub) {
203
+ initialDocId = hub.doc_id;
204
+ } else if (this._spaces.length > 0) {
205
+ initialDocId = this._spaces[0].doc_id;
206
+ }
207
+ } catch {
208
+ this.log(
209
+ "Neither /docs?root=true nor /spaces available, using index_doc_id",
210
+ );
211
+ }
212
+ }
213
+
214
+ if (!initialDocId) {
215
+ throw new Error(
216
+ "No entry point found: server has neither spaces nor index_doc_id configured.",
217
+ );
218
+ }
219
+
220
+ this._rootDocId = initialDocId;
221
+
222
+ // Step 3: Connect provider and sync
223
+ const doc = new Y.Doc({ guid: initialDocId });
224
+ const provider = new AbracadabraProvider({
225
+ name: initialDocId,
226
+ document: doc,
227
+ client: this.client,
228
+ disableOfflineStore:
229
+ this._config.disableOfflineStore ?? true,
230
+ subdocLoading: "lazy",
231
+ });
232
+
233
+ await waitForSync(provider);
234
+
235
+ provider.awareness.setLocalStateField("user", {
236
+ name: this.displayName,
237
+ color: this.displayColor,
238
+ });
239
+ provider.awareness.setLocalStateField("status", null);
240
+
241
+ this._rootDoc = doc;
242
+ this._rootProvider = provider;
243
+ this.log("Connected and synced");
244
+ }
245
+
246
+ /** Switch active space to a different root document. */
247
+ async switchSpace(docId: string): Promise<void> {
248
+ // Destroy existing child providers
249
+ for (const [, cached] of this.childCache) {
250
+ cached.provider.destroy();
251
+ }
252
+ this.childCache.clear();
253
+
254
+ // Destroy current root provider
255
+ if (this._rootProvider) {
256
+ this._rootProvider.destroy();
257
+ this._rootProvider = null;
258
+ }
259
+ this._rootDoc = null;
260
+
261
+ // Connect to new space
262
+ const doc = new Y.Doc({ guid: docId });
263
+ const provider = new AbracadabraProvider({
264
+ name: docId,
265
+ document: doc,
266
+ client: this.client,
267
+ disableOfflineStore:
268
+ this._config.disableOfflineStore ?? true,
269
+ subdocLoading: "lazy",
270
+ });
271
+
272
+ await waitForSync(provider);
273
+
274
+ provider.awareness.setLocalStateField("user", {
275
+ name: this.displayName,
276
+ color: this.displayColor,
277
+ });
278
+
279
+ this._rootDoc = doc;
280
+ this._rootProvider = provider;
281
+ this._rootDocId = docId;
282
+ this.log(`Switched to space ${docId}`);
283
+ }
284
+
285
+ /** Graceful shutdown. */
286
+ async destroy(): Promise<void> {
287
+ for (const [, cached] of this.childCache) {
288
+ cached.provider.destroy();
289
+ }
290
+ this.childCache.clear();
291
+
292
+ if (this._rootProvider) {
293
+ this._rootProvider.awareness.setLocalStateField(
294
+ "status",
295
+ null,
296
+ );
297
+ this._rootProvider.destroy();
298
+ this._rootProvider = null;
299
+ }
300
+ this._rootDoc = null;
301
+
302
+ this.log("Disconnected");
303
+ }
304
+
305
+ // ── Provider cache ────────────────────────────────────────────────────────
306
+
307
+ /** Get or create a child provider for a document (synced). */
308
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
309
+ const cached = this.childCache.get(docId);
310
+ if (cached) {
311
+ cached.lastAccessed = Date.now();
312
+ return cached.provider;
313
+ }
314
+
315
+ if (!this._rootProvider) {
316
+ throw new Error("Not connected. Call connect() first.");
317
+ }
318
+
319
+ const childProvider = await this._rootProvider.loadChild(docId);
320
+ await waitForSync(childProvider);
321
+
322
+ childProvider.awareness.setLocalStateField("user", {
323
+ name: this.displayName,
324
+ color: this.displayColor,
325
+ });
326
+
327
+ this.childCache.set(docId, {
328
+ provider: childProvider,
329
+ lastAccessed: Date.now(),
330
+ });
331
+
332
+ return childProvider;
333
+ }
334
+
335
+ // ── Internal ──────────────────────────────────────────────────────────────
336
+
337
+ private log(msg: string): void {
338
+ if (!this._config.quiet) {
339
+ console.error(`[abracadabra] ${msg}`);
340
+ }
341
+ }
342
+ }
@@ -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();
@@ -287,6 +287,16 @@ export class FileBlobStore extends EventEmitter {
287
287
  }
288
288
  }
289
289
 
290
+ /**
291
+ * Clear the 404 negative-cache entry for (docId, uploadId) so the next
292
+ * getBlobUrl() re-fetches from the server instead of short-circuiting.
293
+ * Use on explicit user retry or after reconnect, when the file may have
294
+ * become available since the last 404.
295
+ */
296
+ clearNotFound(docId: string, uploadId: string): void {
297
+ this._notFound.delete(this.blobKey(docId, uploadId));
298
+ }
299
+
290
300
  /** Revoke the object URL and remove the blob from cache. */
291
301
  async evictBlob(docId: string, uploadId: string): Promise<void> {
292
302
  const key = this.blobKey(docId, uploadId);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MetaManager — read/write PageMeta on tree entries.
3
+ *
4
+ * Extracted from `mcp/tools/meta.ts` and `cli/commands/meta.ts`.
5
+ */
6
+ import type { PageMeta } from "./DocTypes.ts";
7
+ import { toPlain } from "./DocUtils.ts";
8
+ import type { DocumentManager } from "./DocumentManager.ts";
9
+
10
+ export interface DocumentMetaInfo {
11
+ id: string;
12
+ label: string;
13
+ type?: string;
14
+ meta: PageMeta;
15
+ }
16
+
17
+ export class MetaManager {
18
+ constructor(private dm: DocumentManager) {}
19
+
20
+ /** Read metadata for a document. Returns null if not found. */
21
+ get(docId: string): DocumentMetaInfo | null {
22
+ const treeMap = this.dm.getTreeMap();
23
+ if (!treeMap) return null;
24
+
25
+ const raw = treeMap.get(docId);
26
+ if (!raw) return null;
27
+
28
+ const entry = toPlain(raw) as Record<string, unknown>;
29
+ return {
30
+ id: docId,
31
+ label: (entry.label as string) || "Untitled",
32
+ type: entry.type as string | undefined,
33
+ meta: (entry.meta as PageMeta) ?? {},
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Merge fields into a document's metadata.
39
+ * Existing keys not in the update are preserved.
40
+ */
41
+ update(docId: string, meta: Partial<PageMeta>): void {
42
+ const treeMap = this.dm.getTreeMap();
43
+ if (!treeMap) throw new Error("Not connected");
44
+
45
+ const raw = treeMap.get(docId);
46
+ if (!raw) throw new Error(`Document ${docId} not found`);
47
+
48
+ const entry = toPlain(raw) as Record<string, unknown>;
49
+ treeMap.set(docId, {
50
+ ...entry,
51
+ meta: { ...((entry.meta as PageMeta) ?? {}), ...meta },
52
+ updatedAt: Date.now(),
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Replace all metadata on a document.
58
+ * This overwrites the entire meta object.
59
+ */
60
+ set(docId: string, meta: PageMeta): void {
61
+ const treeMap = this.dm.getTreeMap();
62
+ if (!treeMap) throw new Error("Not connected");
63
+
64
+ const raw = treeMap.get(docId);
65
+ if (!raw) throw new Error(`Document ${docId} not found`);
66
+
67
+ const entry = toPlain(raw) as Record<string, unknown>;
68
+ treeMap.set(docId, {
69
+ ...entry,
70
+ meta,
71
+ updatedAt: Date.now(),
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Clear specific metadata keys (set them to null/undefined).
77
+ */
78
+ clear(docId: string, keys: string[]): void {
79
+ const treeMap = this.dm.getTreeMap();
80
+ if (!treeMap) throw new Error("Not connected");
81
+
82
+ const raw = treeMap.get(docId);
83
+ if (!raw) throw new Error(`Document ${docId} not found`);
84
+
85
+ const entry = toPlain(raw) as Record<string, unknown>;
86
+ const existingMeta = ((entry.meta as PageMeta) ?? {}) as Record<
87
+ string,
88
+ unknown
89
+ >;
90
+ const updated = { ...existingMeta };
91
+ for (const key of keys) {
92
+ delete updated[key];
93
+ }
94
+ treeMap.set(docId, {
95
+ ...entry,
96
+ meta: updated,
97
+ updatedAt: Date.now(),
98
+ });
99
+ }
100
+ }