@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/src/DocUtils.ts CHANGED
@@ -87,3 +87,65 @@ export function normalizeRootId(
87
87
  export function toPlain(val: unknown): unknown {
88
88
  return val instanceof Y.Map ? val.toJSON() : val;
89
89
  }
90
+
91
+ /**
92
+ * Build a tree/trash entry as a nested `Y.Map`. Use for a brand-new or
93
+ * re-created key (create / duplicate / restore) where no concurrent
94
+ * writer exists, so a whole-value write is safe. `undefined` fields are
95
+ * omitted; `null` is kept (a real value, e.g. top-level `parentId`).
96
+ */
97
+ export function makeEntryMap(
98
+ fields: Record<string, unknown>,
99
+ ): Y.Map<unknown> {
100
+ const m = new Y.Map<unknown>();
101
+ for (const [k, v] of Object.entries(fields)) {
102
+ if (v !== undefined) m.set(k, v);
103
+ }
104
+ return m;
105
+ }
106
+
107
+ /**
108
+ * Patch an EXISTING entry's fields per-key on its nested `Y.Map`, so a
109
+ * concurrent edit to a *different* field by a peer is preserved instead
110
+ * of being clobbered by a whole-entry write — the whole-entry-LWW fix
111
+ * (audit ⑦), the mirror of the Rust provider's `with_entry_mut`.
112
+ *
113
+ * - nested `Y.Map` entry → set/delete only the touched keys in place;
114
+ * - legacy opaque (plain-object) entry → migrated once to a `Y.Map`;
115
+ * - missing entry → created from the patch (lenient; matches the prior
116
+ * call-site behaviour of spreading `undefined`).
117
+ *
118
+ * A patch value of `undefined` deletes the key; `null` is written.
119
+ * Self-transacting: it batches its writes in one `Y.Doc` transaction
120
+ * (a safe reentrant no-op join when already inside one), so callers
121
+ * don't need to pass or own a transaction.
122
+ */
123
+ export function patchEntry(
124
+ treeMap: Y.Map<unknown>,
125
+ id: string,
126
+ patch: Record<string, unknown>,
127
+ removeKeys: string[] = [],
128
+ ): void {
129
+ const apply = (): void => {
130
+ const raw = treeMap.get(id);
131
+ if (raw instanceof Y.Map) {
132
+ for (const [k, v] of Object.entries(patch)) {
133
+ if (v === undefined) raw.delete(k);
134
+ else raw.set(k, v);
135
+ }
136
+ for (const k of removeKeys) raw.delete(k);
137
+ return;
138
+ }
139
+ const base =
140
+ raw == null ? {} : (toPlain(raw) as Record<string, unknown>);
141
+ const merged: Record<string, unknown> = { ...base, ...patch };
142
+ for (const [k, v] of Object.entries(patch)) {
143
+ if (v === undefined) delete merged[k];
144
+ }
145
+ for (const k of removeKeys) delete merged[k];
146
+ treeMap.set(id, makeEntryMap(merged));
147
+ };
148
+ const doc = treeMap.doc;
149
+ if (doc) doc.transact(apply);
150
+ else apply();
151
+ }
@@ -74,6 +74,16 @@ export interface DocumentManagerConfig {
74
74
  * the entry-point docId is already known.
75
75
  */
76
76
  rootDocId?: string;
77
+ /**
78
+ * PROTOTYPE (Path-1 doctree scaling): opt into TreeManager's in-memory
79
+ * parent→children index. When `false` (default), every tree walk
80
+ * re-scans the whole `doc-tree` Y.Map (O(n) per call, O(n²) recursive
81
+ * traversal) — the historical behaviour, byte-for-byte. When `true`,
82
+ * walks resolve against a lazily-rebuilt adjacency index (O(k) per
83
+ * lookup, O(result) traversal) and `tree.childrenOfPage()` becomes
84
+ * usable. Behind a flag so it ships dark until benchmarked.
85
+ */
86
+ treeIndex?: boolean;
77
87
  }
78
88
 
79
89
  interface CachedProvider {
@@ -195,6 +205,14 @@ export class DocumentManager {
195
205
  return this._rootDocId;
196
206
  }
197
207
 
208
+ /**
209
+ * Whether the TreeManager in-memory index is enabled (Path-1 prototype).
210
+ * Off by default — see {@link DocumentManagerConfig.treeIndex}.
211
+ */
212
+ get treeIndexEnabled(): boolean {
213
+ return this._config.treeIndex ?? false;
214
+ }
215
+
198
216
  get rootDocument(): Y.Doc | null {
199
217
  return this._rootDoc;
200
218
  }
@@ -97,6 +97,17 @@ export class FileBlobStore extends EventEmitter {
97
97
  .catch(() => null)
98
98
  .then((db) => {
99
99
  this.db = db;
100
+ if (db) {
101
+ // A browser-initiated close (storage pressure, tab
102
+ // eviction) leaves the cached handle unusable. Reset so
103
+ // the next getDb() reopens instead of handing back a
104
+ // connection that throws "database connection is
105
+ // closing" on transaction().
106
+ db.onclose = () => {
107
+ if (this.db === db) this.db = null;
108
+ this.dbPromise = null;
109
+ };
110
+ }
100
111
  return db;
101
112
  });
102
113
  }
@@ -125,13 +136,34 @@ export class FileBlobStore extends EventEmitter {
125
136
  const existing = this.objectUrls.get(key);
126
137
  if (existing) return existing;
127
138
 
128
- const db = await this.getDb();
139
+ let db = await this.getDb();
129
140
  if (db) {
130
- const tx = db.transaction("blobs", "readonly");
131
- const entry = await txPromise<BlobCacheEntry | undefined>(
132
- tx.objectStore("blobs"),
133
- tx.objectStore("blobs").get(key),
134
- );
141
+ let entry: BlobCacheEntry | undefined;
142
+ try {
143
+ const tx = db.transaction("blobs", "readonly");
144
+ entry = await txPromise<BlobCacheEntry | undefined>(
145
+ tx.objectStore("blobs"),
146
+ tx.objectStore("blobs").get(key),
147
+ );
148
+ } catch (err: unknown) {
149
+ // The cached connection was closed underneath us (space
150
+ // teardown / destroy() racing this async read). Reopen once
151
+ // and retry — the blob is still persisted in IDB.
152
+ if ((err as { name?: string })?.name === "InvalidStateError") {
153
+ if (this.db === db) this.db = null;
154
+ this.dbPromise = null;
155
+ db = await this.getDb();
156
+ if (db) {
157
+ const tx = db.transaction("blobs", "readonly");
158
+ entry = await txPromise<BlobCacheEntry | undefined>(
159
+ tx.objectStore("blobs"),
160
+ tx.objectStore("blobs").get(key),
161
+ );
162
+ }
163
+ } else {
164
+ throw err;
165
+ }
166
+ }
135
167
  if (entry) {
136
168
  const url = URL.createObjectURL(entry.blob);
137
169
  this.objectUrls.set(key, url);
@@ -436,6 +468,11 @@ export class FileBlobStore extends EventEmitter {
436
468
 
437
469
  this.db?.close();
438
470
  this.db = null;
471
+ // Drop the cached promise too — otherwise a still-mounted FileNodeView
472
+ // that calls getBlobUrl() after teardown gets the just-closed handle
473
+ // and throws InvalidStateError. Nulling it lets getDb() reopen a fresh
474
+ // connection to read the (still-persisted) cached blob.
475
+ this.dbPromise = null;
439
476
  this.removeAllListeners();
440
477
  }
441
478
  }
@@ -12,7 +12,7 @@
12
12
  * `update`/`set` behave exactly as before (Rule 4).
13
13
  */
14
14
  import type { PageMeta } from "./DocTypes.ts";
15
- import { toPlain } from "./DocUtils.ts";
15
+ import { toPlain, patchEntry } from "./DocUtils.ts";
16
16
  import type { DocumentManager } from "./DocumentManager.ts";
17
17
 
18
18
  export interface DocumentMetaInfo {
@@ -124,8 +124,7 @@ export class MetaManager {
124
124
  const entry = toPlain(raw) as Record<string, unknown>;
125
125
  const mergedMeta = { ...((entry.meta as PageMeta) ?? {}), ...meta };
126
126
  this.validateOrThrow(docId, entry, mergedMeta);
127
- treeMap.set(docId, {
128
- ...entry,
127
+ patchEntry(treeMap, docId, {
129
128
  meta: mergedMeta,
130
129
  updatedAt: Date.now(),
131
130
  });
@@ -147,8 +146,7 @@ export class MetaManager {
147
146
 
148
147
  const entry = toPlain(raw) as Record<string, unknown>;
149
148
  this.validateOrThrow(docId, entry, meta);
150
- treeMap.set(docId, {
151
- ...entry,
149
+ patchEntry(treeMap, docId, {
152
150
  meta,
153
151
  updatedAt: Date.now(),
154
152
  });
@@ -174,8 +172,7 @@ export class MetaManager {
174
172
  delete updated[key];
175
173
  }
176
174
  this.validateOrThrow(docId, entry, updated as PageMeta);
177
- treeMap.set(docId, {
178
- ...entry,
175
+ patchEntry(treeMap, docId, {
179
176
  meta: updated,
180
177
  updatedAt: Date.now(),
181
178
  });