@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.
- package/README.md +50 -0
- package/dist/abracadabra-provider.cjs +491 -142
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +486 -153
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +192 -2
- package/package.json +2 -2
- package/src/AbracadabraClient.ts +195 -13
- package/src/AbracadabraProvider.ts +25 -16
- package/src/ContentManager.ts +19 -11
- package/src/DocUtils.ts +62 -0
- package/src/DocumentManager.ts +18 -0
- package/src/FileBlobStore.ts +43 -6
- package/src/MetaManager.ts +4 -7
- package/src/TreeManager.ts +343 -47
- package/src/TreeTimestamps.ts +6 -2
- package/src/index.ts +9 -1
package/src/TreeManager.ts
CHANGED
|
@@ -13,7 +13,12 @@ import type {
|
|
|
13
13
|
PageMeta,
|
|
14
14
|
} from "./DocTypes.ts";
|
|
15
15
|
import { resolvePageType } from "./DocTypes.ts";
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
|
package/src/TreeTimestamps.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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 {
|
|
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,
|