@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,429 @@
1
+ /**
2
+ * TreeManager — typed ORM layer over the root Y.Doc's "doc-tree" / "doc-trash"
3
+ * Y.Maps.
4
+ *
5
+ * Extracted from `mcp/tools/tree.ts` and `cli/commands/documents.ts` logic.
6
+ * All tree CRUD operations go through this class.
7
+ */
8
+ import * as Y from "yjs";
9
+ import type {
10
+ TreeEntry,
11
+ TreeNode,
12
+ TreeSearchResult,
13
+ PageMeta,
14
+ } from "./DocTypes.ts";
15
+ import { toPlain, normalizeRootId } from "./DocUtils.ts";
16
+ import type { DocumentManager } from "./DocumentManager.ts";
17
+
18
+ export class TreeManager {
19
+ constructor(private dm: DocumentManager) {}
20
+
21
+ // ── Reading ───────────────────────────────────────────────────────────────
22
+
23
+ /** Read all tree entries as plain objects. */
24
+ readEntries(): TreeEntry[] {
25
+ const treeMap = this.dm.getTreeMap();
26
+ if (!treeMap) return [];
27
+
28
+ const entries: TreeEntry[] = [];
29
+ treeMap.forEach((raw: unknown, id: string) => {
30
+ const value = toPlain(raw) as Record<string, unknown>;
31
+ if (typeof value !== "object" || value === null) return;
32
+ entries.push({
33
+ id,
34
+ label: (value.label as string) || "Untitled",
35
+ parentId: (value.parentId as string | null) ?? null,
36
+ order: (value.order as number) ?? 0,
37
+ type: value.type as string | undefined,
38
+ meta: value.meta as PageMeta | undefined,
39
+ createdAt: value.createdAt as number | undefined,
40
+ updatedAt: value.updatedAt as number | undefined,
41
+ });
42
+ });
43
+ return entries;
44
+ }
45
+
46
+ /** Get immediate children of a parent (sorted by order). */
47
+ childrenOf(parentId: string | null): TreeEntry[] {
48
+ const normalized = normalizeRootId(parentId, this.dm.rootDocId);
49
+ return this.readEntries()
50
+ .filter((e) => e.parentId === normalized)
51
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
52
+ }
53
+
54
+ /** Get all descendants recursively. */
55
+ descendantsOf(parentId: string | null): TreeEntry[] {
56
+ const normalized = normalizeRootId(parentId, this.dm.rootDocId);
57
+ const entries = this.readEntries();
58
+ const result: TreeEntry[] = [];
59
+ const visited = new Set<string>();
60
+
61
+ const collect = (pid: string | null) => {
62
+ if (pid !== null && visited.has(pid)) return;
63
+ if (pid !== null) visited.add(pid);
64
+ for (const child of entries
65
+ .filter((e) => e.parentId === pid)
66
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
67
+ result.push(child);
68
+ collect(child.id);
69
+ }
70
+ };
71
+ collect(normalized);
72
+ return result;
73
+ }
74
+
75
+ /** Build nested tree JSON. */
76
+ buildTree(
77
+ rootId?: string | null,
78
+ maxDepth = 3,
79
+ ): TreeNode[] {
80
+ const normalized = normalizeRootId(
81
+ rootId ?? null,
82
+ this.dm.rootDocId,
83
+ );
84
+ const entries = this.readEntries();
85
+ return this._buildTree(entries, normalized, maxDepth, 0, new Set());
86
+ }
87
+
88
+ private _buildTree(
89
+ entries: TreeEntry[],
90
+ rootId: string | null,
91
+ maxDepth: number,
92
+ currentDepth: number,
93
+ visited: Set<string>,
94
+ ): TreeNode[] {
95
+ if (maxDepth >= 0 && currentDepth >= maxDepth) return [];
96
+ const children = entries
97
+ .filter((e) => e.parentId === rootId)
98
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
99
+
100
+ return children
101
+ .filter((e) => !visited.has(e.id))
102
+ .map((entry) => {
103
+ const next = new Set(visited);
104
+ next.add(entry.id);
105
+ return {
106
+ id: entry.id,
107
+ label: entry.label,
108
+ type: entry.type,
109
+ meta: entry.meta,
110
+ order: entry.order,
111
+ children: this._buildTree(
112
+ entries,
113
+ entry.id,
114
+ maxDepth,
115
+ currentDepth + 1,
116
+ next,
117
+ ),
118
+ };
119
+ });
120
+ }
121
+
122
+ /** Find a single entry by ID. */
123
+ get(docId: string): TreeEntry | null {
124
+ const treeMap = this.dm.getTreeMap();
125
+ if (!treeMap) return null;
126
+ const raw = treeMap.get(docId);
127
+ if (!raw) return null;
128
+ const value = toPlain(raw) as Record<string, unknown>;
129
+ if (typeof value !== "object" || value === null) return null;
130
+ return {
131
+ id: docId,
132
+ label: (value.label as string) || "Untitled",
133
+ parentId: (value.parentId as string | null) ?? null,
134
+ order: (value.order as number) ?? 0,
135
+ type: value.type as string | undefined,
136
+ meta: value.meta as PageMeta | undefined,
137
+ createdAt: value.createdAt as number | undefined,
138
+ updatedAt: value.updatedAt as number | undefined,
139
+ };
140
+ }
141
+
142
+ /** Search by label (case-insensitive substring match). */
143
+ find(
144
+ query: string,
145
+ rootId?: string | null,
146
+ ): TreeSearchResult[] {
147
+ const entries = this.readEntries();
148
+ const lowerQuery = query.toLowerCase();
149
+
150
+ const normalized = normalizeRootId(
151
+ rootId ?? null,
152
+ this.dm.rootDocId,
153
+ );
154
+ const searchSet = normalized
155
+ ? this.descendantsOf(normalized)
156
+ : entries;
157
+
158
+ const matches = searchSet.filter((e) =>
159
+ e.label.toLowerCase().includes(lowerQuery),
160
+ );
161
+
162
+ const byId = new Map(entries.map((e) => [e.id, e]));
163
+ return matches.map((entry) => {
164
+ const path: string[] = [];
165
+ let current = entry.parentId;
166
+ const visited = new Set<string>();
167
+ while (current && !visited.has(current)) {
168
+ visited.add(current);
169
+ const parent = byId.get(current);
170
+ if (!parent) break;
171
+ path.unshift(parent.label);
172
+ current = parent.parentId;
173
+ }
174
+ return {
175
+ id: entry.id,
176
+ label: entry.label,
177
+ type: entry.type,
178
+ meta: entry.meta,
179
+ path,
180
+ };
181
+ });
182
+ }
183
+
184
+ // ── Writing ──────────────────────────────────────────────────────────────
185
+
186
+ /** Create a new document in the tree. Returns the new entry. */
187
+ create(opts: {
188
+ parentId?: string | null;
189
+ label: string;
190
+ type?: string;
191
+ meta?: Partial<PageMeta>;
192
+ }): TreeEntry {
193
+ const treeMap = this.dm.getTreeMap();
194
+ const rootDoc = this.dm.rootDocument;
195
+ if (!treeMap || !rootDoc) {
196
+ throw new Error("Not connected");
197
+ }
198
+
199
+ const id = crypto.randomUUID();
200
+ const normalizedParent = normalizeRootId(
201
+ opts.parentId ?? null,
202
+ this.dm.rootDocId,
203
+ );
204
+ const now = Date.now();
205
+
206
+ rootDoc.transact(() => {
207
+ treeMap.set(id, {
208
+ label: opts.label,
209
+ parentId: normalizedParent,
210
+ order: now,
211
+ type: opts.type,
212
+ meta: opts.meta as PageMeta,
213
+ createdAt: now,
214
+ updatedAt: now,
215
+ });
216
+ });
217
+
218
+ return {
219
+ id,
220
+ label: opts.label,
221
+ parentId: normalizedParent,
222
+ order: now,
223
+ type: opts.type,
224
+ meta: opts.meta as PageMeta | undefined,
225
+ createdAt: now,
226
+ updatedAt: now,
227
+ };
228
+ }
229
+
230
+ /** Rename a document. */
231
+ rename(docId: string, label: string): void {
232
+ const treeMap = this.dm.getTreeMap();
233
+ if (!treeMap) throw new Error("Not connected");
234
+
235
+ const raw = treeMap.get(docId);
236
+ if (!raw) throw new Error(`Document ${docId} not found`);
237
+
238
+ const entry = toPlain(raw) as Record<string, unknown>;
239
+ treeMap.set(docId, { ...entry, label, updatedAt: Date.now() });
240
+ }
241
+
242
+ /** Move a document to a new parent. */
243
+ move(
244
+ docId: string,
245
+ newParentId?: string | null,
246
+ order?: number,
247
+ ): void {
248
+ const treeMap = this.dm.getTreeMap();
249
+ if (!treeMap) throw new Error("Not connected");
250
+
251
+ const raw = treeMap.get(docId);
252
+ if (!raw) throw new Error(`Document ${docId} not found`);
253
+
254
+ const entry = toPlain(raw) as Record<string, unknown>;
255
+ treeMap.set(docId, {
256
+ ...entry,
257
+ parentId: normalizeRootId(
258
+ newParentId ?? null,
259
+ this.dm.rootDocId,
260
+ ),
261
+ order: order ?? Date.now(),
262
+ updatedAt: Date.now(),
263
+ });
264
+ }
265
+
266
+ /** Change the page type of a document. */
267
+ changeType(docId: string, type: string): void {
268
+ const treeMap = this.dm.getTreeMap();
269
+ if (!treeMap) throw new Error("Not connected");
270
+
271
+ const raw = treeMap.get(docId);
272
+ if (!raw) throw new Error(`Document ${docId} not found`);
273
+
274
+ const entry = toPlain(raw) as Record<string, unknown>;
275
+ treeMap.set(docId, { ...entry, type, updatedAt: Date.now() });
276
+ }
277
+
278
+ /**
279
+ * Soft-delete a document and descendants (move to trash).
280
+ * @returns count of deleted documents
281
+ */
282
+ delete(docId: string): number {
283
+ const treeMap = this.dm.getTreeMap();
284
+ const trashMap = this.dm.getTrashMap();
285
+ const rootDoc = this.dm.rootDocument;
286
+ if (!treeMap || !trashMap || !rootDoc) {
287
+ throw new Error("Not connected");
288
+ }
289
+
290
+ const entries = this.readEntries();
291
+ const toDelete = [
292
+ docId,
293
+ ...this._descendantIds(entries, docId),
294
+ ];
295
+
296
+ const now = Date.now();
297
+ rootDoc.transact(() => {
298
+ for (const nid of toDelete) {
299
+ const raw = treeMap.get(nid);
300
+ if (!raw) continue;
301
+ const entry = toPlain(raw) as Record<string, unknown>;
302
+ trashMap.set(nid, {
303
+ label: entry.label || "Untitled",
304
+ parentId: entry.parentId ?? null,
305
+ order: entry.order ?? 0,
306
+ type: entry.type,
307
+ meta: entry.meta,
308
+ deletedAt: now,
309
+ });
310
+ treeMap.delete(nid);
311
+ }
312
+ });
313
+
314
+ return toDelete.length;
315
+ }
316
+
317
+ /** Duplicate a document (shallow clone). Returns the new entry. */
318
+ duplicate(docId: string): TreeEntry {
319
+ const treeMap = this.dm.getTreeMap();
320
+ if (!treeMap) throw new Error("Not connected");
321
+
322
+ const raw = treeMap.get(docId);
323
+ if (!raw) throw new Error(`Document ${docId} not found`);
324
+
325
+ const entry = toPlain(raw) as Record<string, unknown>;
326
+ const newId = crypto.randomUUID();
327
+ const now = Date.now();
328
+ treeMap.set(newId, {
329
+ ...entry,
330
+ label: ((entry.label as string) || "Untitled") + " (copy)",
331
+ order: now,
332
+ });
333
+
334
+ return {
335
+ id: newId,
336
+ label: ((entry.label as string) || "Untitled") + " (copy)",
337
+ parentId: (entry.parentId as string | null) ?? null,
338
+ order: now,
339
+ type: entry.type as string | undefined,
340
+ meta: entry.meta as PageMeta | undefined,
341
+ createdAt: entry.createdAt as number | undefined,
342
+ updatedAt: entry.updatedAt as number | undefined,
343
+ };
344
+ }
345
+
346
+ /** Restore a document from trash back into the tree. */
347
+ restore(docId: string): void {
348
+ const treeMap = this.dm.getTreeMap();
349
+ const trashMap = this.dm.getTrashMap();
350
+ const rootDoc = this.dm.rootDocument;
351
+ if (!treeMap || !trashMap || !rootDoc) {
352
+ throw new Error("Not connected");
353
+ }
354
+
355
+ const raw = trashMap.get(docId);
356
+ if (!raw) throw new Error(`Document ${docId} not found in trash`);
357
+
358
+ const entry = toPlain(raw) as Record<string, unknown>;
359
+ const now = Date.now();
360
+ rootDoc.transact(() => {
361
+ treeMap.set(docId, {
362
+ label: entry.label || "Untitled",
363
+ parentId: entry.parentId ?? null,
364
+ order: entry.order ?? now,
365
+ type: entry.type,
366
+ meta: entry.meta,
367
+ createdAt: entry.createdAt ?? now,
368
+ updatedAt: now,
369
+ });
370
+ trashMap.delete(docId);
371
+ });
372
+ }
373
+
374
+ /** List trashed documents. */
375
+ listTrash(): TreeEntry[] {
376
+ const trashMap = this.dm.getTrashMap();
377
+ if (!trashMap) return [];
378
+
379
+ const entries: TreeEntry[] = [];
380
+ trashMap.forEach((raw: unknown, id: string) => {
381
+ const value = toPlain(raw) as Record<string, unknown>;
382
+ if (typeof value !== "object" || value === null) return;
383
+ entries.push({
384
+ id,
385
+ label: (value.label as string) || "Untitled",
386
+ parentId: (value.parentId as string | null) ?? null,
387
+ order: (value.order as number) ?? 0,
388
+ type: value.type as string | undefined,
389
+ meta: value.meta as PageMeta | undefined,
390
+ });
391
+ });
392
+ return entries;
393
+ }
394
+
395
+ /** Get enabled plugin names from space-plugins map. */
396
+ getEnabledPlugins(): string[] {
397
+ const pluginsMap = this.dm.getPluginsMap();
398
+ if (!pluginsMap) return [];
399
+
400
+ const plugins: string[] = [];
401
+ pluginsMap.forEach((raw: unknown, key: string) => {
402
+ const value = toPlain(raw) as Record<string, unknown>;
403
+ if (value && value.enabled) {
404
+ plugins.push(key);
405
+ }
406
+ });
407
+ return plugins;
408
+ }
409
+
410
+ // ── Internal helpers ──────────────────────────────────────────────────────
411
+
412
+ private _descendantIds(
413
+ entries: TreeEntry[],
414
+ parentId: string,
415
+ ): string[] {
416
+ const result: string[] = [];
417
+ const visited = new Set<string>();
418
+ const collect = (pid: string) => {
419
+ if (visited.has(pid)) return;
420
+ visited.add(pid);
421
+ for (const child of entries.filter((e) => e.parentId === pid)) {
422
+ result.push(child.id);
423
+ collect(child.id);
424
+ }
425
+ };
426
+ collect(parentId);
427
+ return result;
428
+ }
429
+ }
package/src/types.ts CHANGED
@@ -124,10 +124,19 @@ export type onStatelessParameters = {
124
124
  payload: string;
125
125
  };
126
126
 
127
+ /** Fired by E2EAbracadabraProvider when the server acknowledges an E2E
128
+ * client-side compaction (via the `snapshot:compacted` stateless broadcast). */
129
+ export type onCompactedParameters = {
130
+ docId: string;
131
+ /** User id that performed the compaction; undefined if payload was malformed. */
132
+ by: string | undefined;
133
+ };
134
+
127
135
  export type onServerErrorParameters = {
128
136
  source: string;
129
137
  code: string;
130
138
  message: string;
139
+ meta?: Record<string, unknown>;
131
140
  };
132
141
 
133
142
  export type StatesArray = { clientId: number; [key: string | number]: any }[];