@abraca/dabra 2.4.0 → 2.6.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 +534 -142
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +529 -153
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +248 -2
- package/package.json +2 -2
- package/src/AbracadabraClient.ts +273 -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
|
@@ -712,30 +712,39 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
712
712
|
* errors across async await boundaries.
|
|
713
713
|
*/
|
|
714
714
|
private async flushPendingUpdates() {
|
|
715
|
-
if (!this.canWrite) return;
|
|
716
715
|
const store = this.offlineStore;
|
|
717
716
|
if (!store) return;
|
|
718
717
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
718
|
+
// Flushing queued *local* writes / subdoc registrations over the wire
|
|
719
|
+
// is only meaningful for writers — a read-only session has none and
|
|
720
|
+
// must not send. The snapshot save below is deliberately OUTSIDE this
|
|
721
|
+
// guard.
|
|
722
|
+
if (this.canWrite) {
|
|
723
|
+
const updates = await store.getPendingUpdates();
|
|
724
|
+
if (updates.length > 0) {
|
|
725
|
+
for (const update of updates) {
|
|
726
|
+
this.send(UpdateMessage, {
|
|
727
|
+
update,
|
|
728
|
+
documentName: this.configuration.name,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
await store.clearPendingUpdates();
|
|
726
732
|
}
|
|
727
|
-
await store.clearPendingUpdates();
|
|
728
|
-
}
|
|
729
733
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
734
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
735
|
+
for (const { childId } of pendingSubdocs) {
|
|
736
|
+
this.send(SubdocMessage, {
|
|
737
|
+
documentName: this.configuration.name,
|
|
738
|
+
childDocumentName: childId,
|
|
739
|
+
} as any);
|
|
740
|
+
}
|
|
736
741
|
}
|
|
737
742
|
|
|
738
743
|
// Snapshot the current merged state so the next offline load sees it.
|
|
744
|
+
// This is the ONLY path that persists server-delivered content
|
|
745
|
+
// (documentUpdateHandler skips origin===this), so it MUST run for
|
|
746
|
+
// read-only roles too — otherwise observers/viewers get a permanently
|
|
747
|
+
// empty offline cache and every cold start waits for a full re-sync.
|
|
739
748
|
const snapshot = Y.encodeStateAsUpdate(this.document);
|
|
740
749
|
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
741
750
|
}
|
package/src/ContentManager.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as Y from "yjs";
|
|
7
7
|
import type { PageMeta } from "./DocTypes.ts";
|
|
8
|
-
import { toPlain } from "./DocUtils.ts";
|
|
8
|
+
import { toPlain, patchEntry } from "./DocUtils.ts";
|
|
9
9
|
import {
|
|
10
10
|
yjsToMarkdown,
|
|
11
11
|
populateYDocFromMarkdown,
|
|
@@ -51,11 +51,9 @@ export class ContentManager {
|
|
|
51
51
|
const provider = await this.dm.getChildProvider(docId);
|
|
52
52
|
const fragment = provider.document.getXmlFragment("default");
|
|
53
53
|
|
|
54
|
-
const { title, markdown } = yjsToMarkdown(fragment);
|
|
55
|
-
|
|
56
54
|
// Get tree metadata + immediate children
|
|
57
55
|
const treeMap = this.dm.getTreeMap();
|
|
58
|
-
let label =
|
|
56
|
+
let label = "Untitled";
|
|
59
57
|
let type: string | undefined;
|
|
60
58
|
let meta: PageMeta | undefined;
|
|
61
59
|
const childrenWithOrder: Array<{
|
|
@@ -70,7 +68,7 @@ export class ContentManager {
|
|
|
70
68
|
const raw = treeMap.get(docId);
|
|
71
69
|
if (raw) {
|
|
72
70
|
const entry = toPlain(raw) as Record<string, unknown>;
|
|
73
|
-
label = (entry.label as string) ||
|
|
71
|
+
label = (entry.label as string) || label;
|
|
74
72
|
type = entry.type as string | undefined;
|
|
75
73
|
meta = entry.meta as PageMeta | undefined;
|
|
76
74
|
}
|
|
@@ -97,6 +95,12 @@ export class ContentManager {
|
|
|
97
95
|
meta,
|
|
98
96
|
}));
|
|
99
97
|
|
|
98
|
+
// yjsToMarkdown returns a string and takes the resolved label/meta/type
|
|
99
|
+
// so frontmatter round-trips. (Older code destructured an object and
|
|
100
|
+
// passed only the fragment — that silently produced `undefined`.)
|
|
101
|
+
const markdown = yjsToMarkdown(fragment, label, meta, type);
|
|
102
|
+
const title = label;
|
|
103
|
+
|
|
100
104
|
return { label, type, meta, title, markdown, children };
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -127,18 +131,22 @@ export class ContentManager {
|
|
|
127
131
|
const entry = treeMap.get(docId);
|
|
128
132
|
if (entry) {
|
|
129
133
|
rootDoc.transact(() => {
|
|
130
|
-
const
|
|
131
|
-
|
|
134
|
+
const cur = toPlain(entry) as Record<
|
|
135
|
+
string,
|
|
136
|
+
unknown
|
|
137
|
+
>;
|
|
138
|
+
const patch: Record<string, unknown> = {
|
|
132
139
|
updatedAt: Date.now(),
|
|
133
140
|
};
|
|
134
|
-
if (title)
|
|
141
|
+
if (title) patch.label = title;
|
|
135
142
|
if (Object.keys(meta).length > 0) {
|
|
136
|
-
|
|
137
|
-
...(
|
|
143
|
+
patch.meta = {
|
|
144
|
+
...((cur.meta as Record<string, unknown>) ??
|
|
145
|
+
{}),
|
|
138
146
|
...meta,
|
|
139
147
|
};
|
|
140
148
|
}
|
|
141
|
-
treeMap
|
|
149
|
+
patchEntry(treeMap, docId, patch);
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
}
|
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
|
+
}
|
package/src/DocumentManager.ts
CHANGED
|
@@ -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
|
}
|
package/src/FileBlobStore.ts
CHANGED
|
@@ -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
|
-
|
|
139
|
+
let db = await this.getDb();
|
|
129
140
|
if (db) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
tx.
|
|
133
|
-
|
|
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
|
}
|
package/src/MetaManager.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
178
|
-
...entry,
|
|
175
|
+
patchEntry(treeMap, docId, {
|
|
179
176
|
meta: updated,
|
|
180
177
|
updatedAt: Date.now(),
|
|
181
178
|
});
|