@abraca/dabra 1.9.1 → 2.0.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.
@@ -118,7 +118,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
118
118
  if (!docId) return "";
119
119
  const seamlessAttr = el.getAttribute("seamless");
120
120
  const seamless =
121
- seamlessAttr === true || seamlessAttr === "true";
121
+ (seamlessAttr as unknown) === true || seamlessAttr === "true";
122
122
  return seamless ? `![[${docId}]]{seamless}` : `![[${docId}]]`;
123
123
  }
124
124
 
@@ -154,7 +154,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
154
154
  const label = el.getAttribute("label") || "Details";
155
155
  const open = el.getAttribute("open");
156
156
  const props: string[] = [`label="${label}"`];
157
- if (open === true || open === "true") props.push('open="true"');
157
+ if ((open as unknown) === true || open === "true") props.push('open="true"');
158
158
  const inner = serializeChildren(el, indent);
159
159
  return `::collapsible{${props.join(" ")}}\n${inner}\n::`;
160
160
  }
@@ -219,7 +219,7 @@ function serializeElement(el: Y.XmlElement, indent = ""): string {
219
219
  const props: string[] = [];
220
220
  if (fieldName) props.push(`name="${fieldName}"`);
221
221
  props.push(`type="${fieldType}"`);
222
- if (required === true || required === "true")
222
+ if ((required as unknown) === true || required === "true")
223
223
  props.push('required="true"');
224
224
  const inner = serializeChildren(el, indent);
225
225
  return `::field{${props.join(" ")}}\n${inner}\n::`;
@@ -266,7 +266,7 @@ function serializeTaskList(el: Y.XmlElement, indent: string): string {
266
266
  ) {
267
267
  const checked = item.getAttribute("checked");
268
268
  const marker =
269
- checked === true || checked === "true" ? "[x]" : "[ ]";
269
+ (checked as unknown) === true || checked === "true" ? "[x]" : "[ ]";
270
270
  const content = elementTextContent(item);
271
271
  lines.push(`${indent}- ${marker} ${content}`);
272
272
  }
@@ -504,7 +504,7 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
504
504
 
505
505
  const checkedRaw = raw["checked"] ?? raw["done"];
506
506
  if (checkedRaw !== undefined)
507
- meta.checked = checkedRaw === "true" || checkedRaw === true;
507
+ meta.checked = checkedRaw === "true" || (checkedRaw as unknown) === true;
508
508
 
509
509
  const dateStart = getStr(["date", "created"]);
510
510
  if (dateStart) meta.dateStart = dateStart;
@@ -533,7 +533,7 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
533
533
  if (datetimeEnd) meta.datetimeEnd = datetimeEnd;
534
534
  const allDayRaw = raw["allDay"];
535
535
  if (allDayRaw !== undefined)
536
- meta.allDay = allDayRaw === "true" || allDayRaw === true;
536
+ meta.allDay = allDayRaw === "true" || (allDayRaw as unknown) === true;
537
537
 
538
538
  // Geo fields
539
539
  const geoLatRaw = getStr(["geoLat"]);
@@ -1705,3 +1705,158 @@ export function populateYDocFromMarkdown(
1705
1705
  contentBlocks.forEach((block, i) => fillBlock(bodyEls[i]!, block));
1706
1706
  });
1707
1707
  }
1708
+
1709
+ // ── Block element builders (exported for ContentManager and SDK consumers) ────
1710
+
1711
+ export function buildHeadingElement(text: string, level: 1|2|3|4|5|6 = 1): Y.XmlElement {
1712
+ const el = new Y.XmlElement("heading");
1713
+ el.setAttribute("level", level as any);
1714
+ fillTextInto(el, parseInline(text));
1715
+ return el;
1716
+ }
1717
+
1718
+ export function buildParagraphElement(text: string): Y.XmlElement {
1719
+ const el = new Y.XmlElement("paragraph");
1720
+ fillTextInto(el, parseInline(text));
1721
+ return el;
1722
+ }
1723
+
1724
+ export function buildBulletListElement(items: string[]): Y.XmlElement {
1725
+ const el = new Y.XmlElement("bulletList");
1726
+ const itemEls = items.map(() => new Y.XmlElement("listItem"));
1727
+ el.insert(0, itemEls);
1728
+ items.forEach((text, i) => {
1729
+ const paraEl = new Y.XmlElement("paragraph");
1730
+ itemEls[i]!.insert(0, [paraEl]);
1731
+ fillTextInto(paraEl, parseInline(text));
1732
+ });
1733
+ return el;
1734
+ }
1735
+
1736
+ export function buildOrderedListElement(items: string[]): Y.XmlElement {
1737
+ const el = new Y.XmlElement("orderedList");
1738
+ const itemEls = items.map(() => new Y.XmlElement("listItem"));
1739
+ el.insert(0, itemEls);
1740
+ items.forEach((text, i) => {
1741
+ const paraEl = new Y.XmlElement("paragraph");
1742
+ itemEls[i]!.insert(0, [paraEl]);
1743
+ fillTextInto(paraEl, parseInline(text));
1744
+ });
1745
+ return el;
1746
+ }
1747
+
1748
+ export function buildTaskListElement(
1749
+ items: Array<{ text: string; checked?: boolean }>,
1750
+ ): Y.XmlElement {
1751
+ const el = new Y.XmlElement("taskList");
1752
+ const itemEls = items.map(() => new Y.XmlElement("taskItem"));
1753
+ el.insert(0, itemEls);
1754
+ items.forEach((item, i) => {
1755
+ itemEls[i]!.setAttribute("checked", (item.checked ?? false) as any);
1756
+ const paraEl = new Y.XmlElement("paragraph");
1757
+ itemEls[i]!.insert(0, [paraEl]);
1758
+ fillTextInto(paraEl, parseInline(item.text));
1759
+ });
1760
+ return el;
1761
+ }
1762
+
1763
+ export function buildCodeBlockElement(code: string, language?: string): Y.XmlElement {
1764
+ const el = new Y.XmlElement("codeBlock");
1765
+ if (language) el.setAttribute("language", language);
1766
+ const xt = new Y.XmlText();
1767
+ el.insert(0, [xt]);
1768
+ xt.insert(0, code);
1769
+ return el;
1770
+ }
1771
+
1772
+ export function buildBlockquoteElement(text: string): Y.XmlElement {
1773
+ const el = new Y.XmlElement("blockquote");
1774
+ const paraEl = new Y.XmlElement("paragraph");
1775
+ el.insert(0, [paraEl]);
1776
+ fillTextInto(paraEl, parseInline(text));
1777
+ return el;
1778
+ }
1779
+
1780
+ export function buildHorizontalRuleElement(): Y.XmlElement {
1781
+ return new Y.XmlElement("horizontalRule");
1782
+ }
1783
+
1784
+ /** Parse markdown into block Y.XmlElements (no header/meta). */
1785
+ export function buildBlocksFromMarkdown(markdown: string): Y.XmlElement[] {
1786
+ const blocks = parseBlocks(markdown);
1787
+ return blocks
1788
+ .filter((b) => b.type !== "heading" || (b as any).level !== 1)
1789
+ .map((b) => {
1790
+ const el = new Y.XmlElement(blockElName(b));
1791
+ fillBlock(el, b);
1792
+ return el;
1793
+ });
1794
+ }
1795
+
1796
+ // ── Block reader ──────────────────────────────────────────────────────────────
1797
+
1798
+ export interface DocumentBlock {
1799
+ type: string;
1800
+ attrs: Record<string, unknown>;
1801
+ text: string;
1802
+ items?: string[];
1803
+ children?: DocumentBlock[];
1804
+ }
1805
+
1806
+ export function readBlocksFromFragment(fragment: Y.XmlFragment): DocumentBlock[] {
1807
+ const result: DocumentBlock[] = [];
1808
+ for (let i = 0; i < fragment.length; i++) {
1809
+ const child = fragment.get(i);
1810
+ if (!(child instanceof Y.XmlElement)) continue;
1811
+ const name = child.nodeName;
1812
+ if (name === "documentHeader" || name === "documentMeta") continue;
1813
+ result.push(_xmlElToBlock(child));
1814
+ }
1815
+ return result;
1816
+ }
1817
+
1818
+ function _xmlElToBlock(el: Y.XmlElement): DocumentBlock {
1819
+ const name = el.nodeName;
1820
+ switch (name) {
1821
+ case "heading":
1822
+ return { type: "heading", attrs: { level: el.getAttribute("level") }, text: elementTextContent(el) };
1823
+ case "paragraph":
1824
+ case "blockquote":
1825
+ case "horizontalRule":
1826
+ return { type: name, attrs: {}, text: elementTextContent(el) };
1827
+ case "codeBlock": {
1828
+ const lang = el.getAttribute("language");
1829
+ return { type: "codeBlock", attrs: lang ? { language: lang } : {}, text: elementTextContent(el) };
1830
+ }
1831
+ case "bulletList":
1832
+ case "orderedList": {
1833
+ const items: string[] = [];
1834
+ for (let i = 0; i < el.length; i++) {
1835
+ const item = el.get(i);
1836
+ if (item instanceof Y.XmlElement && item.nodeName === "listItem")
1837
+ items.push(elementTextContent(item));
1838
+ }
1839
+ return { type: name, attrs: {}, text: "", items };
1840
+ }
1841
+ case "taskList": {
1842
+ const items: string[] = [];
1843
+ const children: DocumentBlock[] = [];
1844
+ for (let i = 0; i < el.length; i++) {
1845
+ const item = el.get(i);
1846
+ if (item instanceof Y.XmlElement && item.nodeName === "taskItem") {
1847
+ const checked = item.getAttribute("checked");
1848
+ const text = elementTextContent(item);
1849
+ items.push(text);
1850
+ children.push({
1851
+ type: "taskItem",
1852
+ attrs: { checked: checked === "true" || (checked as unknown) === true },
1853
+ text,
1854
+ });
1855
+ }
1856
+ }
1857
+ return { type: "taskList", attrs: {}, text: "", items, children };
1858
+ }
1859
+ default:
1860
+ return { type: name, attrs: {}, text: elementTextContent(el) };
1861
+ }
1862
+ }
@@ -45,16 +45,64 @@ export class DocKeyManager {
45
45
  }
46
46
 
47
47
  const envelope = await client.getMyKeyEnvelope(docId);
48
- if (!envelope) return null;
48
+ if (envelope) {
49
+ const x25519PrivKey = await keystore.getX25519PrivateKey();
50
+ try {
51
+ const wrapped = fromBase64(envelope.encrypted_key);
52
+ const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
53
+ this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
54
+ return docKey;
55
+ } finally {
56
+ x25519PrivKey.fill(0);
57
+ }
58
+ }
49
59
 
50
- const x25519PrivKey = await keystore.getX25519PrivateKey();
60
+ // Inheritance fallback. The doc has no envelope of our own — but it
61
+ // might inherit its encryption mode from an ancestor (e.g. a child
62
+ // page under an E2E space). The ancestor IS provisioned for us
63
+ // (otherwise we couldn't open the parent either). Resolve the
64
+ // encryption-source via /docs/:id/encryption.inherited_from, fetch
65
+ // OUR envelope on that source, unwrap with the source's salt,
66
+ // re-wrap for the current docId with its own salt, upload the new
67
+ // envelope so subsequent calls hit the direct-envelope path.
51
68
  try {
52
- const wrapped = fromBase64(envelope.encrypted_key);
53
- const docKey = await this._unwrapKey(wrapped, x25519PrivKey, docId);
54
- this.cache.set(docId, { key: docKey, epoch: envelope.key_epoch, fetchedAt: Date.now() });
55
- return docKey;
56
- } finally {
57
- x25519PrivKey.fill(0);
69
+ const enc = await client.getDocEncryption(docId);
70
+ if (enc.effective_mode !== "e2e" || !enc.inherited_from || enc.inherited_from === docId) {
71
+ return null;
72
+ }
73
+ const sourceEnv = await client.getMyKeyEnvelope(enc.inherited_from);
74
+ if (!sourceEnv) return null;
75
+ const x25519PrivKey = await keystore.getX25519PrivateKey();
76
+ try {
77
+ const wrappedSrc = fromBase64(sourceEnv.encrypted_key);
78
+ const docKey = await this._unwrapKey(wrappedSrc, x25519PrivKey, enc.inherited_from);
79
+ this.cache.set(docId, { key: docKey, epoch: sourceEnv.key_epoch, fetchedAt: Date.now() });
80
+ // Re-wrap for the current doc + upload so future opens take
81
+ // the direct path. Best-effort — caller already has the key.
82
+ const me = await client.getMe();
83
+ if (me.publicKey) {
84
+ const myKeys = await client.listUserKeys(me.id);
85
+ const primaryKey = myKeys[0];
86
+ if (primaryKey?.x25519Key) {
87
+ const x25519Pub = fromBase64(primaryKey.x25519Key.replace(/-/g, "+").replace(/_/g, "/"));
88
+ const rewrapped = await this.wrapKeyForRecipient(docKey, x25519Pub, docId);
89
+ const b64 = (() => {
90
+ let s = "";
91
+ for (let i = 0; i < rewrapped.length; i++) s += String.fromCharCode(rewrapped[i]!);
92
+ return btoa(s);
93
+ })();
94
+ await client.uploadKeyEnvelopes(docId, {
95
+ key_epoch: sourceEnv.key_epoch,
96
+ envelopes: [{ recipient_key_id: primaryKey.id, encrypted_key: b64 }],
97
+ }).catch(() => null);
98
+ }
99
+ }
100
+ return docKey;
101
+ } finally {
102
+ x25519PrivKey.fill(0);
103
+ }
104
+ } catch {
105
+ return null;
58
106
  }
59
107
  }
60
108
 
@@ -73,11 +121,11 @@ export class DocKeyManager {
73
121
 
74
122
  const salt = new TextEncoder().encode(docId);
75
123
  const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
76
- const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt"]);
124
+ const wrapKey = await crypto.subtle.importKey("raw", keyBytes as BufferSource, { name: "AES-GCM" }, false, ["encrypt"]);
77
125
 
78
126
  const rawDocKey = await crypto.subtle.exportKey("raw", docKey);
79
127
  const nonce = crypto.getRandomValues(new Uint8Array(12));
80
- const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, wrapKey, rawDocKey));
128
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce as BufferSource }, wrapKey, rawDocKey));
81
129
 
82
130
  const result = new Uint8Array(32 + 12 + ciphertext.length);
83
131
  result.set(ephemeralPub, 0);
@@ -94,8 +142,8 @@ export class DocKeyManager {
94
142
  const sharedSecret = x25519.getSharedSecret(recipientX25519PrivKey, ephemeralPub);
95
143
  const salt = new TextEncoder().encode(docId);
96
144
  const keyBytes = hkdf(sha256, sharedSecret, salt, HKDF_INFO, 32);
97
- const wrapKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
98
- const rawDocKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, wrapKey, ciphertext);
145
+ const wrapKey = await crypto.subtle.importKey("raw", keyBytes as BufferSource, { name: "AES-GCM" }, false, ["decrypt"]);
146
+ const rawDocKey = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce as BufferSource }, wrapKey, ciphertext as BufferSource);
99
147
  return crypto.subtle.importKey("raw", rawDocKey, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]);
100
148
  }
101
149
 
package/src/DocTypes.ts CHANGED
@@ -321,6 +321,16 @@ export const PAGE_TYPES: Record<string, PageTypeInfo> = {
321
321
  core: true,
322
322
  supportsChildren: true,
323
323
  },
324
+ prose: {
325
+ key: 'prose',
326
+ label: 'Prose',
327
+ icon: 'pen-tool',
328
+ description: 'Long-form prose with serif typography and a narrow readable measure',
329
+ core: true,
330
+ supportsChildren: true,
331
+ childLabel: 'Item',
332
+ defaultDepth: -1,
333
+ },
324
334
  kanban: {
325
335
  key: 'kanban',
326
336
  label: 'Kanban',
@@ -24,7 +24,9 @@
24
24
  import * as Y from "yjs";
25
25
  import { AbracadabraProvider } from "./AbracadabraProvider.ts";
26
26
  import { AbracadabraClient } from "./AbracadabraClient.ts";
27
- import type { ServerInfo, SpaceMeta, DocumentMeta } from "./types.ts";
27
+ import type { AbracadabraWS } from "./AbracadabraWS.ts";
28
+ import type { ServerInfo, DocumentMeta } from "./types.ts";
29
+ import { Kind, SERVER_ROOT_ID } from "./types.ts";
28
30
  import { TreeManager } from "./TreeManager.ts";
29
31
  import { ContentManager } from "./ContentManager.ts";
30
32
  import { MetaManager } from "./MetaManager.ts";
@@ -52,26 +54,19 @@ export interface DocumentManagerConfig {
52
54
  * already authenticated or configured the client externally.
53
55
  */
54
56
  client?: AbracadabraClient;
55
- }
56
-
57
- /** Map a DocumentMeta (REST API) to SpaceMeta shape for display compatibility. */
58
- function docToSpaceMeta(doc: DocumentMeta): SpaceMeta {
59
- const publicAccess = doc.public_access;
60
- let visibility: SpaceMeta["visibility"] = "private";
61
- if (publicAccess && publicAccess !== "none") visibility = "public";
62
-
63
- return {
64
- id: doc.id,
65
- doc_id: doc.id,
66
- name: doc.label ?? doc.id,
67
- description: doc.description ?? null,
68
- visibility,
69
- is_hub: doc.is_hub ?? false,
70
- owner_id: doc.owner_id ?? null,
71
- created_at: 0,
72
- updated_at: doc.updated_at ?? 0,
73
- public_access: publicAccess ?? null,
74
- };
57
+ /**
58
+ * Shared WebSocket connection. Required in Node.js environments — pass an
59
+ * AbracadabraWS constructed with WebSocketPolyfill set to the `ws` package.
60
+ * When omitted, each provider creates its own connection from client.wsUrl.
61
+ * The caller owns this instance and must destroy it after dm.destroy().
62
+ */
63
+ websocketProvider?: AbracadabraWS;
64
+ /**
65
+ * Known root document ID. When provided, connect() skips server discovery
66
+ * and connects directly to this document. Useful in tests or CLIs where
67
+ * the entry-point docId is already known.
68
+ */
69
+ rootDocId?: string;
75
70
  }
76
71
 
77
72
  interface CachedProvider {
@@ -92,7 +87,7 @@ export class DocumentManager {
92
87
  private _config: DocumentManagerConfig;
93
88
  private _serverInfo: ServerInfo | null = null;
94
89
  private _rootDocId: string | null = null;
95
- private _spaces: SpaceMeta[] = [];
90
+ private _spaces: DocumentMeta[] = [];
96
91
  private _rootDoc: Y.Doc | null = null;
97
92
  private _rootProvider: AbracadabraProvider | null = null;
98
93
  private childCache = new Map<string, CachedProvider>();
@@ -138,7 +133,11 @@ export class DocumentManager {
138
133
  return this._rootProvider;
139
134
  }
140
135
 
141
- get spaces(): SpaceMeta[] {
136
+ /**
137
+ * Spaces visible to the caller — direct children of the server root with
138
+ * `kind === "space"`. Populated by {@link connect}.
139
+ */
140
+ get spaces(): DocumentMeta[] {
142
141
  return this._spaces;
143
142
  }
144
143
 
@@ -172,74 +171,42 @@ export class DocumentManager {
172
171
  * **Authentication must be done before calling connect().**
173
172
  * Call `dm.client.loginWithKey(publicKey, signFn)` or `dm.client.login()`
174
173
  * to authenticate first.
174
+ *
175
+ * When `rootDocId` is set in the config, server discovery is skipped and
176
+ * the manager connects directly to that document.
175
177
  */
176
178
  async connect(): Promise<void> {
177
- // Step 1: Discover server info
179
+ // Step 1: Discover server info (used for awareness colour, name, etc.).
178
180
  this._serverInfo = await this.client.serverInfo();
179
181
 
180
- // Step 2: Discover root documents / spaces
181
- let initialDocId: string | null =
182
- this._serverInfo.index_doc_id ?? null;
183
- try {
184
- const roots = await this.client.listRootDocuments();
185
- this._spaces = roots.map(docToSpaceMeta);
186
- const hub = roots.find((d: any) => d.is_hub);
187
- if (hub) {
188
- initialDocId = hub.id;
189
- this.log(
190
- `Hub document: ${hub.label ?? hub.id} (${hub.id})`,
191
- );
192
- } else if (roots.length > 0) {
193
- initialDocId = roots[0].id;
194
- this.log(
195
- `No hub, using first root doc: ${roots[0].label ?? roots[0].id}`,
196
- );
197
- }
198
- } catch {
199
- try {
200
- this._spaces = await this.client.listSpaces();
201
- const hub = this._spaces.find((s) => s.is_hub);
202
- if (hub) {
203
- initialDocId = hub.doc_id;
204
- } else if (this._spaces.length > 0) {
205
- initialDocId = this._spaces[0].doc_id;
206
- }
207
- } catch {
208
- this.log(
209
- "Neither /docs?root=true nor /spaces available, using index_doc_id",
210
- );
182
+ // Step 2: Pick an entry-point doc.
183
+ //
184
+ // In the new model the dashboard's notion of "open the hub" maps to
185
+ // "open the first Space". A Space is a top-level doc with
186
+ // `kind === "space"`. If the caller pinned a `rootDocId`, honour it
187
+ // directly — useful in tests/CLIs where the entry doc is known.
188
+ let initialDocId: string | null = this._config.rootDocId ?? null;
189
+
190
+ if (!initialDocId) {
191
+ const roots = await this.client.listChildren();
192
+ this._spaces = roots.filter((d) => d.kind === Kind.Space);
193
+ const first = this._spaces[0] ?? roots[0];
194
+ if (first) {
195
+ initialDocId = first.id;
196
+ this.log(`Entry document: ${first.label ?? first.id} (${first.id})`);
211
197
  }
212
198
  }
213
199
 
214
200
  if (!initialDocId) {
215
201
  throw new Error(
216
- "No entry point found: server has neither spaces nor index_doc_id configured.",
202
+ `No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
217
203
  );
218
204
  }
219
205
 
220
206
  this._rootDocId = initialDocId;
221
207
 
222
208
  // Step 3: Connect provider and sync
223
- const doc = new Y.Doc({ guid: initialDocId });
224
- const provider = new AbracadabraProvider({
225
- name: initialDocId,
226
- document: doc,
227
- client: this.client,
228
- disableOfflineStore:
229
- this._config.disableOfflineStore ?? true,
230
- subdocLoading: "lazy",
231
- });
232
-
233
- await waitForSync(provider);
234
-
235
- provider.awareness.setLocalStateField("user", {
236
- name: this.displayName,
237
- color: this.displayColor,
238
- });
239
- provider.awareness.setLocalStateField("status", null);
240
-
241
- this._rootDoc = doc;
242
- this._rootProvider = provider;
209
+ await this._connectToRoot(initialDocId);
243
210
  this.log("Connected and synced");
244
211
  }
245
212
 
@@ -251,35 +218,45 @@ export class DocumentManager {
251
218
  }
252
219
  this.childCache.clear();
253
220
 
254
- // Destroy current root provider
221
+ // Destroy current root provider (not the wsp — caller owns it)
255
222
  if (this._rootProvider) {
256
223
  this._rootProvider.destroy();
257
224
  this._rootProvider = null;
258
225
  }
259
226
  this._rootDoc = null;
260
227
 
261
- // Connect to new space
228
+ await this._connectToRoot(docId);
229
+ this._rootDocId = docId;
230
+ this.log(`Switched to space ${docId}`);
231
+ }
232
+
233
+ /** Create, sync, and set awareness on a root provider for the given docId. */
234
+ private async _connectToRoot(docId: string): Promise<void> {
262
235
  const doc = new Y.Doc({ guid: docId });
236
+ const wsp = this._config.websocketProvider;
263
237
  const provider = new AbracadabraProvider({
264
238
  name: docId,
265
239
  document: doc,
266
240
  client: this.client,
267
- disableOfflineStore:
268
- this._config.disableOfflineStore ?? true,
241
+ disableOfflineStore: this._config.disableOfflineStore ?? true,
269
242
  subdocLoading: "lazy",
243
+ ...(wsp ? { websocketProvider: wsp } : {}),
270
244
  });
271
245
 
246
+ // When a shared websocketProvider is supplied, manageSocket=false so
247
+ // the base constructor does NOT call attach(). We must do it ourselves.
248
+ if (wsp) provider.attach();
249
+
272
250
  await waitForSync(provider);
273
251
 
274
- provider.awareness.setLocalStateField("user", {
252
+ provider.awareness?.setLocalStateField("user", {
275
253
  name: this.displayName,
276
254
  color: this.displayColor,
277
255
  });
256
+ provider.awareness?.setLocalStateField("status", null);
278
257
 
279
258
  this._rootDoc = doc;
280
259
  this._rootProvider = provider;
281
- this._rootDocId = docId;
282
- this.log(`Switched to space ${docId}`);
283
260
  }
284
261
 
285
262
  /** Graceful shutdown. */
@@ -290,7 +267,7 @@ export class DocumentManager {
290
267
  this.childCache.clear();
291
268
 
292
269
  if (this._rootProvider) {
293
- this._rootProvider.awareness.setLocalStateField(
270
+ this._rootProvider.awareness?.setLocalStateField(
294
271
  "status",
295
272
  null,
296
273
  );
@@ -319,7 +296,7 @@ export class DocumentManager {
319
296
  const childProvider = await this._rootProvider.loadChild(docId);
320
297
  await waitForSync(childProvider);
321
298
 
322
- childProvider.awareness.setLocalStateField("user", {
299
+ childProvider.awareness?.setLocalStateField("user", {
323
300
  name: this.displayName,
324
301
  color: this.displayColor,
325
302
  });