@abraca/dabra 2.3.0 → 2.5.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.
@@ -13,7 +13,12 @@ import type {
13
13
  PageMeta,
14
14
  } from "./DocTypes.ts";
15
15
  import { resolvePageType } from "./DocTypes.ts";
16
- import { toPlain, normalizeRootId } from "./DocUtils.ts";
16
+ import {
17
+ toPlain,
18
+ normalizeRootId,
19
+ makeEntryMap,
20
+ patchEntry,
21
+ } from "./DocUtils.ts";
17
22
  import type { DocumentManager } from "./DocumentManager.ts";
18
23
  import {
19
24
  projectTreeEntry,
@@ -21,9 +26,146 @@ import {
21
26
  type TypedTreeEntry,
22
27
  } from "./SchemaTypes.ts";
23
28
 
29
+ /**
30
+ * Stable total order over tree siblings: `order` ascending, then `id`
31
+ * ascending as a deterministic tiebreak. The legacy scan sorted by
32
+ * `order` alone and left ties to insertion/iteration order — a superset
33
+ * change that makes cursor pagination well-defined.
34
+ */
35
+ function cmpKey(oa: number, ia: string, ob: number, ib: string): number {
36
+ if (oa !== ob) return oa < ob ? -1 : 1;
37
+ return ia < ib ? -1 : ia > ib ? 1 : 0;
38
+ }
39
+ function cmpEntry(a: TreeEntry, b: TreeEntry): number {
40
+ return cmpKey(a.order ?? 0, a.id, b.order ?? 0, b.id);
41
+ }
42
+
43
+ /** Opaque, dependency-free cursor over the (order,id) sibling order. */
44
+ function encodeCursor(order: number, id: string): string {
45
+ return encodeURIComponent(JSON.stringify([order ?? 0, id]));
46
+ }
47
+ function decodeCursor(c: string): { order: number; id: string } | null {
48
+ try {
49
+ const v = JSON.parse(decodeURIComponent(c)) as unknown;
50
+ if (
51
+ Array.isArray(v) &&
52
+ typeof v[0] === "number" &&
53
+ typeof v[1] === "string"
54
+ ) {
55
+ return { order: v[0], id: v[1] };
56
+ }
57
+ } catch {
58
+ // Garbage / tampered cursor → treat as "from the beginning".
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export interface ChildrenPage {
64
+ entries: TreeEntry[];
65
+ /** Pass back as `cursor` for the next page; `null` when exhausted. */
66
+ nextCursor: string | null;
67
+ }
68
+
24
69
  export class TreeManager {
25
70
  constructor(private dm: DocumentManager) {}
26
71
 
72
+ // ── Path-1 prototype: lazy parent→children index ──────────────────────────
73
+ //
74
+ // Gated by `dm.treeIndexEnabled`. When off, every method below falls
75
+ // through to the original whole-map scan (unchanged). When on, tree
76
+ // WALKS resolve against a normalized adjacency index that is rebuilt
77
+ // lazily: an `observeDeep` on the bound `doc-tree` Y.Map only flips a
78
+ // dirty bit, so the O(n) rebuild happens at most once per mutation
79
+ // batch instead of once per read, and reads between writes are
80
+ // O(k)/O(result) instead of O(n)/O(n²). `readEntries`/`get` keep RAW
81
+ // parentId for round-trip consumers and are intentionally NOT indexed.
82
+ private _idxMap: Y.Map<unknown> | null = null;
83
+ private _idxObserver: (() => void) | null = null;
84
+ private _idxDirty = true;
85
+ private _byId = new Map<string, TreeEntry>();
86
+ private _childrenByParent = new Map<string | null, TreeEntry[]>();
87
+
88
+ /**
89
+ * Ensure the index is enabled, bound to the current root doc's tree
90
+ * map, and fresh. Returns `false` when the index is disabled or there
91
+ * is no tree map yet — callers then use the legacy scan path.
92
+ */
93
+ private ensureIndex(): boolean {
94
+ if (!this.dm.treeIndexEnabled) return false;
95
+ const treeMap = this.dm.getTreeMap();
96
+ if (!treeMap) {
97
+ this.unbindIndex();
98
+ return false;
99
+ }
100
+ if (treeMap !== this._idxMap) {
101
+ // Root doc (space) swapped, or first use — rebind the observer.
102
+ this.unbindIndex();
103
+ const obs = () => {
104
+ this._idxDirty = true;
105
+ };
106
+ treeMap.observeDeep(obs);
107
+ this._idxMap = treeMap;
108
+ this._idxObserver = obs;
109
+ this._idxDirty = true;
110
+ }
111
+ if (this._idxDirty) this.rebuildIndex(treeMap);
112
+ return true;
113
+ }
114
+
115
+ private unbindIndex(): void {
116
+ if (this._idxMap && this._idxObserver) {
117
+ this._idxMap.unobserveDeep(this._idxObserver);
118
+ }
119
+ this._idxMap = null;
120
+ this._idxObserver = null;
121
+ this._byId = new Map();
122
+ this._childrenByParent = new Map();
123
+ this._idxDirty = true;
124
+ }
125
+
126
+ private rebuildIndex(treeMap: Y.Map<unknown>): void {
127
+ const root = this.dm.rootDocId;
128
+ const byId = new Map<string, TreeEntry>();
129
+ const childrenByParent = new Map<string | null, TreeEntry[]>();
130
+ treeMap.forEach((raw: unknown, id: string) => {
131
+ const value = toPlain(raw) as Record<string, unknown>;
132
+ if (typeof value !== "object" || value === null) return;
133
+ const entry: TreeEntry = {
134
+ id,
135
+ label: (value.label as string) || "Untitled",
136
+ parentId: normalizeRootId(
137
+ (value.parentId as string | null) ?? null,
138
+ root,
139
+ ),
140
+ order: (value.order as number) ?? 0,
141
+ type: value.type as string | undefined,
142
+ meta: value.meta as PageMeta | undefined,
143
+ createdAt: value.createdAt as number | undefined,
144
+ updatedAt: value.updatedAt as number | undefined,
145
+ };
146
+ byId.set(id, entry);
147
+ let bucket = childrenByParent.get(entry.parentId);
148
+ if (!bucket) {
149
+ bucket = [];
150
+ childrenByParent.set(entry.parentId, bucket);
151
+ }
152
+ bucket.push(entry);
153
+ });
154
+ for (const bucket of childrenByParent.values()) bucket.sort(cmpEntry);
155
+ this._byId = byId;
156
+ this._childrenByParent = childrenByParent;
157
+ this._idxDirty = false;
158
+ }
159
+
160
+ /**
161
+ * Release the deep observer. Optional — the observer is auto-rebound
162
+ * on space switch and becomes moot when the root Y.Doc is GC'd — but
163
+ * available for consumers that want deterministic teardown.
164
+ */
165
+ dispose(): void {
166
+ this.unbindIndex();
167
+ }
168
+
27
169
  // ── Reading ───────────────────────────────────────────────────────────────
28
170
 
29
171
  /** Read all tree entries as plain objects. */
@@ -49,18 +191,100 @@ export class TreeManager {
49
191
  return entries;
50
192
  }
51
193
 
194
+ /**
195
+ * Like {@link readEntries} but with every entry's *stored* parentId
196
+ * run through {@link normalizeRootId} (parentId === rootDocId → null),
197
+ * so a cou-sh / orphan-rescue top-level doc (parentId === spaceRoot)
198
+ * resolves to top-level identically to a provider-created one
199
+ * (parentId: null). Without this, the raw `parentId === spaceRoot`
200
+ * form never matches the normalized `null` query and such docs are
201
+ * silently invisible cross-client. Mirrors the Rust provider's
202
+ * `normalized_entries`. readEntries/get keep raw values for
203
+ * round-trip consumers; only tree-walk reads use this.
204
+ */
205
+ private normalizedEntries(): TreeEntry[] {
206
+ if (this.ensureIndex()) return Array.from(this._byId.values());
207
+ const root = this.dm.rootDocId;
208
+ return this.readEntries().map((e) => ({
209
+ ...e,
210
+ parentId: normalizeRootId(e.parentId, root),
211
+ }));
212
+ }
213
+
52
214
  /** Get immediate children of a parent (sorted by order). */
53
215
  childrenOf(parentId: string | null): TreeEntry[] {
54
216
  const normalized = normalizeRootId(parentId, this.dm.rootDocId);
55
- return this.readEntries()
217
+ if (this.ensureIndex()) {
218
+ // Copy: callers (descendantsOf, find, consumers) must not
219
+ // mutate the cached bucket.
220
+ const bucket = this._childrenByParent.get(normalized);
221
+ return bucket ? bucket.slice() : [];
222
+ }
223
+ return this.normalizedEntries()
56
224
  .filter((e) => e.parentId === normalized)
57
225
  .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
58
226
  }
59
227
 
228
+ /**
229
+ * Paginated immediate children — the Path-1 surface for large fan-out
230
+ * parents. Walks the same stable (order,id) sibling order as
231
+ * {@link childrenOf}; `cursor` is opaque (round-trip `nextCursor`).
232
+ * `limit` defaults to 100. A stale/garbage cursor restarts from the
233
+ * head rather than throwing. Cursor stability is exact when the index
234
+ * is enabled; on the legacy scan path siblings with equal `order`
235
+ * may shift between calls.
236
+ */
237
+ childrenOfPage(
238
+ parentId: string | null,
239
+ opts: { limit?: number; cursor?: string | null } = {},
240
+ ): ChildrenPage {
241
+ const all = this.childrenOf(parentId);
242
+ const limit =
243
+ opts.limit != null && opts.limit > 0
244
+ ? Math.floor(opts.limit)
245
+ : 100;
246
+ let start = 0;
247
+ if (opts.cursor) {
248
+ const dec = decodeCursor(opts.cursor);
249
+ if (dec) {
250
+ const at = all.findIndex(
251
+ (e) =>
252
+ cmpKey(e.order ?? 0, e.id, dec.order, dec.id) > 0,
253
+ );
254
+ start = at < 0 ? all.length : at;
255
+ }
256
+ }
257
+ const entries = all.slice(start, start + limit);
258
+ const last = entries[entries.length - 1];
259
+ const nextCursor =
260
+ last && start + limit < all.length
261
+ ? encodeCursor(last.order ?? 0, last.id)
262
+ : null;
263
+ return { entries, nextCursor };
264
+ }
265
+
60
266
  /** Get all descendants recursively. */
61
267
  descendantsOf(parentId: string | null): TreeEntry[] {
62
268
  const normalized = normalizeRootId(parentId, this.dm.rootDocId);
63
- const entries = this.readEntries();
269
+ if (this.ensureIndex()) {
270
+ const result: TreeEntry[] = [];
271
+ const visited = new Set<string>();
272
+ const walk = (pid: string | null) => {
273
+ if (pid !== null) {
274
+ if (visited.has(pid)) return;
275
+ visited.add(pid);
276
+ }
277
+ const bucket = this._childrenByParent.get(pid);
278
+ if (!bucket) return;
279
+ for (const child of bucket) {
280
+ result.push(child);
281
+ walk(child.id);
282
+ }
283
+ };
284
+ walk(normalized);
285
+ return result;
286
+ }
287
+ const entries = this.normalizedEntries();
64
288
  const result: TreeEntry[] = [];
65
289
  const visited = new Set<string>();
66
290
 
@@ -87,10 +311,47 @@ export class TreeManager {
87
311
  rootId ?? null,
88
312
  this.dm.rootDocId,
89
313
  );
90
- const entries = this.readEntries();
314
+ if (this.ensureIndex()) {
315
+ return this._buildTreeIndexed(
316
+ normalized,
317
+ maxDepth,
318
+ 0,
319
+ new Set(),
320
+ );
321
+ }
322
+ const entries = this.normalizedEntries();
91
323
  return this._buildTree(entries, normalized, maxDepth, 0, new Set());
92
324
  }
93
325
 
326
+ private _buildTreeIndexed(
327
+ rootId: string | null,
328
+ maxDepth: number,
329
+ currentDepth: number,
330
+ visited: Set<string>,
331
+ ): TreeNode[] {
332
+ if (maxDepth >= 0 && currentDepth >= maxDepth) return [];
333
+ const children = this._childrenByParent.get(rootId) ?? [];
334
+ return children
335
+ .filter((e) => !visited.has(e.id))
336
+ .map((entry) => {
337
+ const next = new Set(visited);
338
+ next.add(entry.id);
339
+ return {
340
+ id: entry.id,
341
+ label: entry.label,
342
+ type: entry.type,
343
+ meta: entry.meta,
344
+ order: entry.order,
345
+ children: this._buildTreeIndexed(
346
+ entry.id,
347
+ maxDepth,
348
+ currentDepth + 1,
349
+ next,
350
+ ),
351
+ };
352
+ });
353
+ }
354
+
94
355
  private _buildTree(
95
356
  entries: TreeEntry[],
96
357
  rootId: string | null,
@@ -172,7 +433,7 @@ export class TreeManager {
172
433
  query: string,
173
434
  rootId?: string | null,
174
435
  ): TreeSearchResult[] {
175
- const entries = this.readEntries();
436
+ const entries = this.normalizedEntries();
176
437
  const lowerQuery = query.toLowerCase();
177
438
 
178
439
  const normalized = normalizeRootId(
@@ -232,15 +493,18 @@ export class TreeManager {
232
493
  const now = Date.now();
233
494
 
234
495
  rootDoc.transact(() => {
235
- treeMap.set(id, {
236
- label: opts.label,
237
- parentId: normalizedParent,
238
- order: now,
239
- type: opts.type,
240
- meta: opts.meta as PageMeta,
241
- createdAt: now,
242
- updatedAt: now,
243
- });
496
+ treeMap.set(
497
+ id,
498
+ makeEntryMap({
499
+ label: opts.label,
500
+ parentId: normalizedParent,
501
+ order: now,
502
+ type: opts.type,
503
+ meta: opts.meta as PageMeta,
504
+ createdAt: now,
505
+ updatedAt: now,
506
+ }),
507
+ );
244
508
  });
245
509
 
246
510
  return {
@@ -295,9 +559,8 @@ export class TreeManager {
295
559
  const raw = treeMap.get(docId);
296
560
  if (!raw) throw new Error(`Document ${docId} not found`);
297
561
 
298
- const entry = toPlain(raw) as Record<string, unknown>;
299
562
  rootDoc.transact(() => {
300
- treeMap.set(docId, { ...entry, label, updatedAt: Date.now() });
563
+ patchEntry(treeMap, docId, { label, updatedAt: Date.now() });
301
564
  });
302
565
  }
303
566
 
@@ -314,10 +577,8 @@ export class TreeManager {
314
577
  const raw = treeMap.get(docId);
315
578
  if (!raw) throw new Error(`Document ${docId} not found`);
316
579
 
317
- const entry = toPlain(raw) as Record<string, unknown>;
318
580
  rootDoc.transact(() => {
319
- treeMap.set(docId, {
320
- ...entry,
581
+ patchEntry(treeMap, docId, {
321
582
  parentId: normalizeRootId(
322
583
  newParentId ?? null,
323
584
  this.dm.rootDocId,
@@ -337,9 +598,8 @@ export class TreeManager {
337
598
  const raw = treeMap.get(docId);
338
599
  if (!raw) throw new Error(`Document ${docId} not found`);
339
600
 
340
- const entry = toPlain(raw) as Record<string, unknown>;
341
601
  rootDoc.transact(() => {
342
- treeMap.set(docId, { ...entry, type, updatedAt: Date.now() });
602
+ patchEntry(treeMap, docId, { type, updatedAt: Date.now() });
343
603
  });
344
604
  }
345
605
 
@@ -355,14 +615,17 @@ export class TreeManager {
355
615
  throw new Error("Not connected");
356
616
  }
357
617
 
358
- const entries = this.readEntries();
359
- const toDelete = [
360
- docId,
361
- ...this._descendantIds(entries, docId),
362
- ];
363
-
364
618
  const now = Date.now();
619
+ let deletedCount = 0;
620
+ // Compute the descendant set and trash it inside ONE synchronous
621
+ // Yjs transaction. transact() callbacks are atomic — no local or
622
+ // remote update interleaves — so a child concurrently added under
623
+ // this subtree cannot be stranded with a trashed parent the way a
624
+ // read-then-separate-write delete allowed.
365
625
  rootDoc.transact(() => {
626
+ const entries = this.readEntries();
627
+ const toDelete = [docId, ...this._descendantIds(entries, docId)];
628
+ deletedCount = toDelete.length;
366
629
  for (const nid of toDelete) {
367
630
  const raw = treeMap.get(nid);
368
631
  if (!raw) continue;
@@ -379,7 +642,7 @@ export class TreeManager {
379
642
  }
380
643
  });
381
644
 
382
- return toDelete.length;
645
+ return deletedCount;
383
646
  }
384
647
 
385
648
  /** Duplicate a document (shallow clone). Returns the new entry. */
@@ -394,13 +657,16 @@ export class TreeManager {
394
657
  const newId = crypto.randomUUID();
395
658
  const now = Date.now();
396
659
  const newLabel = ((entry.label as string) || "Untitled") + " (copy)";
397
- treeMap.set(newId, {
398
- ...entry,
399
- label: newLabel,
400
- order: now,
401
- createdAt: now,
402
- updatedAt: now,
403
- });
660
+ treeMap.set(
661
+ newId,
662
+ makeEntryMap({
663
+ ...entry,
664
+ label: newLabel,
665
+ order: now,
666
+ createdAt: now,
667
+ updatedAt: now,
668
+ }),
669
+ );
404
670
 
405
671
  return {
406
672
  id: newId,
@@ -423,22 +689,52 @@ export class TreeManager {
423
689
  throw new Error("Not connected");
424
690
  }
425
691
 
426
- const raw = trashMap.get(docId);
427
- if (!raw) throw new Error(`Document ${docId} not found in trash`);
692
+ if (!trashMap.get(docId)) {
693
+ throw new Error(`Document ${docId} not found in trash`);
694
+ }
428
695
 
429
- const entry = toPlain(raw) as Record<string, unknown>;
430
696
  const now = Date.now();
697
+ // Restore docId AND its whole trashed subtree. delete() trashes
698
+ // the subtree as independent entries; restoring only the named id
699
+ // stranded every descendant in trash forever (audit RISK 4). The
700
+ // trash snapshot + descendant walk happen INSIDE the transaction,
701
+ // the same atomicity discipline delete() uses.
431
702
  rootDoc.transact(() => {
432
- treeMap.set(docId, {
433
- label: entry.label || "Untitled",
434
- parentId: entry.parentId ?? null,
435
- order: entry.order ?? now,
436
- type: entry.type,
437
- meta: entry.meta,
438
- createdAt: entry.createdAt ?? now,
439
- updatedAt: now,
703
+ const trashed = new Map<string, Record<string, unknown>>();
704
+ trashMap.forEach((raw: unknown, id: string) => {
705
+ const v = toPlain(raw) as Record<string, unknown>;
706
+ if (typeof v === "object" && v !== null) trashed.set(id, v);
440
707
  });
441
- trashMap.delete(docId);
708
+
709
+ const toRestore: string[] = [];
710
+ const visited = new Set<string>();
711
+ const collect = (id: string) => {
712
+ if (visited.has(id)) return;
713
+ visited.add(id);
714
+ if (!trashed.has(id)) return;
715
+ toRestore.push(id);
716
+ for (const [cid, v] of trashed) {
717
+ if ((v.parentId ?? null) === id) collect(cid);
718
+ }
719
+ };
720
+ collect(docId);
721
+
722
+ for (const id of toRestore) {
723
+ const entry = trashed.get(id) as Record<string, unknown>;
724
+ treeMap.set(
725
+ id,
726
+ makeEntryMap({
727
+ label: entry.label || "Untitled",
728
+ parentId: entry.parentId ?? null,
729
+ order: entry.order ?? now,
730
+ type: entry.type,
731
+ meta: entry.meta,
732
+ createdAt: entry.createdAt ?? now,
733
+ updatedAt: now,
734
+ }),
735
+ );
736
+ trashMap.delete(id);
737
+ }
442
738
  });
443
739
  }
444
740
 
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import * as Y from "yjs";
17
+
18
+ import { patchEntry } from "./DocUtils.ts";
17
19
  import type { OfflineStore } from "./OfflineStore.ts";
18
20
 
19
21
  /**
@@ -47,8 +49,10 @@ export function attachUpdatedAtObserver(
47
49
  function writeTs(ts: number): void {
48
50
  const raw = treeMap.get(childDocId);
49
51
  if (!raw) return;
50
- const entry = raw instanceof Y.Map ? (raw as any).toJSON() : raw;
51
- treeMap.set(childDocId, { ...entry, updatedAt: ts });
52
+ // Per-key set on the entry's nested Y.Map: the 5 s `updatedAt`
53
+ // write no longer clobbers a concurrent parentId/label edit
54
+ // (whole-entry-LWW fix, audit ⑦).
55
+ patchEntry(treeMap, childDocId, { updatedAt: ts });
52
56
  lastFlushedAt = ts;
53
57
  }
54
58
 
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export {
128
128
  export { DocumentManager } from "./DocumentManager.ts";
129
129
  export type { DocumentManagerConfig } from "./DocumentManager.ts";
130
130
  export { TreeManager } from "./TreeManager.ts";
131
+ export type { ChildrenPage } from "./TreeManager.ts";
131
132
  export { ContentManager } from "./ContentManager.ts";
132
133
  export type { DocumentContent } from "./ContentManager.ts";
133
134
  export { MetaManager, MetaValidationError } from "./MetaManager.ts";
@@ -144,7 +145,14 @@ export type {
144
145
  TypedDocsClient,
145
146
  } from "./SchemaTypes.ts";
146
147
  export * from "./DocTypes.ts";
147
- export { waitForSync, withTimeout, normalizeRootId, toPlain } from "./DocUtils.ts";
148
+ export {
149
+ waitForSync,
150
+ withTimeout,
151
+ normalizeRootId,
152
+ toPlain,
153
+ makeEntryMap,
154
+ patchEntry,
155
+ } from "./DocUtils.ts";
148
156
  export {
149
157
  yjsToMarkdown,
150
158
  populateYDocFromMarkdown,