@abraca/dabra 1.9.1 → 2.0.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.
@@ -73,6 +73,15 @@ export interface AbracadabraProviderConfiguration
73
73
  * sharing one local store. Used for the identity doc.
74
74
  */
75
75
  serverAgnostic?: boolean;
76
+
77
+ /**
78
+ * Maximum number of simultaneously cached child providers before LRU
79
+ * eviction reclaims the least-recently-used unpinned ones. Default 20.
80
+ * Apps that legitimately need more docs alive (e.g. background-sync of
81
+ * a working set) should raise this; pin individual children with
82
+ * `pinChild()` to make them eviction-immune regardless of cap.
83
+ */
84
+ maxChildren?: number;
76
85
  }
77
86
 
78
87
  /** Validate that a string is a UUID acceptable by the server's DocId parser. */
@@ -114,8 +123,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
114
123
  private childAccessTimes = new Map<string, number>();
115
124
  /** Pinned children that must not be evicted (e.g. actively viewed docs) */
116
125
  private pinnedChildren = new Set<string>();
117
- /** Max simultaneously cached child providers before LRU eviction kicks in */
118
- private static readonly MAX_CHILDREN = 20;
126
+ /** Default cap on simultaneously cached child providers; configurable per-instance via `maxChildren`. */
127
+ private static readonly DEFAULT_MAX_CHILDREN = 20;
128
+ private readonly maxChildren: number;
119
129
 
120
130
  private abracadabraConfig: AbracadabraProviderConfiguration;
121
131
 
@@ -143,8 +153,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
143
153
  const client = configuration.client ?? null;
144
154
 
145
155
  if (client) {
146
- if (!resolved.url && !resolved.websocketProvider) {
147
- (resolved as any).url = client.wsUrl;
156
+ // `url` is no longer part of the base provider config — the URL
157
+ // rides on `websocketProvider`. Keep the legacy field assignment
158
+ // for downstream consumers that read it off the merged object,
159
+ // but cast through `any` since `resolved` doesn't declare it.
160
+ const r = resolved as { url?: string; websocketProvider?: AbracadabraWS };
161
+ if (!r.url && !r.websocketProvider) {
162
+ r.url = client.wsUrl;
148
163
  }
149
164
  if (resolved.token === undefined && !configuration.cryptoIdentity) {
150
165
  resolved.token = () => client.token ?? "";
@@ -155,6 +170,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
155
170
  this._client = client;
156
171
  this.abracadabraConfig = configuration;
157
172
  this.subdocLoading = configuration.subdocLoading ?? "lazy";
173
+ this.maxChildren = configuration.maxChildren ?? AbracadabraProvider.DEFAULT_MAX_CHILDREN;
158
174
 
159
175
  const serverOrigin = configuration.serverAgnostic
160
176
  ? undefined
@@ -560,9 +576,8 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
560
576
  * at or below MAX_CHILDREN.
561
577
  */
562
578
  private evictLRU() {
563
- if (this.childProviders.size <= AbracadabraProvider.MAX_CHILDREN) return;
579
+ if (this.childProviders.size <= this.maxChildren) return;
564
580
 
565
- // Build a list of evictable children sorted by last access (oldest first)
566
581
  const evictable: Array<{ id: string; accessTime: number }> = [];
567
582
  for (const [id] of this.childProviders) {
568
583
  if (this.pinnedChildren.has(id)) continue;
@@ -570,7 +585,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
570
585
  }
571
586
  evictable.sort((a, b) => a.accessTime - b.accessTime);
572
587
 
573
- let toEvict = this.childProviders.size - AbracadabraProvider.MAX_CHILDREN;
588
+ let toEvict = this.childProviders.size - this.maxChildren;
574
589
  for (const entry of evictable) {
575
590
  if (toEvict <= 0) break;
576
591
  this.unloadChild(entry.id);
@@ -542,7 +542,7 @@ export class AbracadabraWS extends EventEmitter {
542
542
  // Detect server-side rate-limit close (code 4429).
543
543
  // `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
544
544
  // `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
545
- const isRateLimited = (event as any)?.code === 4429 || event === 4429;
545
+ const isRateLimited = (event as any)?.code === 4429 || (event as unknown) === 4429;
546
546
  this.emit("disconnect", { event });
547
547
  if (isRateLimited) {
548
548
  this.emit("rateLimited");
package/src/ChatClient.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import EventEmitter from "./EventEmitter.ts";
2
2
  import type {
3
- ChatChannel,
4
- ChatMessage,
5
- ChatReadCursor,
6
- ChatReadReceipt,
7
3
  ChatTypingEvent,
8
- GetChatHistoryInput,
9
- SendChatMessageInput,
4
+ SendChatMessageInput as _LegacySendInput,
5
+ GetChatHistoryInput as _LegacyHistoryInput,
10
6
  } from "./types.ts";
11
7
 
8
+ // Legacy alias re-exports kept for type-import compat with consumers that
9
+ // import these names from `@abraca/dabra`. The old shapes still describe
10
+ // the wire the dashboard's own reactive layer reads/writes; this client
11
+ // itself no longer uses them as method inputs.
12
+ export type _ReExportedTypes = _LegacySendInput | _LegacyHistoryInput;
13
+
12
14
  /**
13
15
  * Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
14
- * Kept as an interface so consumers can pass any compatible transport.
15
16
  */
16
17
  export interface ChatClientTransport {
17
18
  sendStateless(payload: string): void;
@@ -30,38 +31,114 @@ type PendingResolver<T> = {
30
31
  const DEFAULT_TIMEOUT_MS = 10_000;
31
32
 
32
33
  /**
33
- * Typed client for the Abracadabra chat feature.
34
+ * Send a chat message into a channel doc.
35
+ *
36
+ * `channel_doc_id` is the UUID of the channel/dm doc — `documents.id` for
37
+ * a row with `kind = "channel"` or `kind = "dm"`. Group channels under a
38
+ * Space pass the channel doc's id; DMs pass the DM doc's id (callers
39
+ * resolve "the DM doc between user A and user B" via their own path —
40
+ * typically `client.listChildren(serverRootId)` filtered by `kind === "dm"`
41
+ * with both pubkeys in `permissions`).
42
+ */
43
+ export interface SendMessageInput {
44
+ channel_doc_id: string;
45
+ content: string;
46
+ /** Cleartext recipient pubkeys for server-side mention fan-out. */
47
+ mentions?: string[];
48
+ /** Message id this is a reply to. */
49
+ reply_to?: string;
50
+ /**
51
+ * Optional Ed25519 signature over the canonical envelope, base64url. When
52
+ * server config `[messages].require_client_sig = true`, sends without a
53
+ * sig are rejected. Default-config servers accept unsigned sends — the
54
+ * threat model relies on JWT-bound session auth + the server's own
55
+ * `server_sig` over `(msg_id, period_id, ts, sig)`.
56
+ */
57
+ sig?: string;
58
+ /** Client-side timestamp (ms) for skew-tolerance check. Optional. */
59
+ ts_hint?: number;
60
+ }
61
+
62
+ export interface SendMessageResult {
63
+ message_id: string;
64
+ period_id: string;
65
+ ts: number;
66
+ server_sig: string;
67
+ }
68
+
69
+ export interface EditMessageInput {
70
+ channel_doc_id: string;
71
+ message_id: string;
72
+ content: string;
73
+ sig?: string;
74
+ }
75
+
76
+ export interface DeleteMessageInput {
77
+ channel_doc_id: string;
78
+ message_id: string;
79
+ }
80
+
81
+ export interface MarkReadInput {
82
+ channel_doc_id: string;
83
+ period_id: string;
84
+ message_id: string;
85
+ /** Unix milliseconds. Defaults to server time. */
86
+ ts?: number;
87
+ }
88
+
89
+ /**
90
+ * Thin façade over the `messages:*` stateless protocol.
34
91
  *
35
- * Wraps a connected provider (or base provider) and translates JSON envelopes
36
- * on the stateless channel into typed method calls and events.
92
+ * The unified messages model stores chat history as Y.Array entries on
93
+ * `kind = "channel-period"` child docs of channel/dm docs (and notifications
94
+ * as entries on a per-user `kind = "inbox"` doc). Reading history, listing
95
+ * channels, observing real-time messages, and tracking read cursors are all
96
+ * Y.Doc-shaped operations that the SDK consumer performs via
97
+ * `AbracadabraClient` + `AbracadabraProvider` directly — not through this
98
+ * façade. ChatClient handles only the *write-and-validate* path (sends,
99
+ * edits, deletes, typing, mark-read), where the server is the sole authority
100
+ * over the channel-period Y.Array.
37
101
  *
38
102
  * Events emitted:
39
- * - `message` ChatMessage (new message broadcast)
40
- * - `typing` → ChatTypingEvent (typing indicator broadcast)
41
- * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
103
+ * - `typing` ChatTypingEvent (typing broadcast on the channel doc)
104
+ *
105
+ * For incoming-message observation, the consumer opens an
106
+ * `AbracadabraProvider` on the active period doc and observes its
107
+ * `messages` Y.Array directly. The dashboard's `useChat` composable does
108
+ * this; the integration tests do this; external consumers should mirror
109
+ * the pattern.
42
110
  */
43
111
  export class ChatClient extends EventEmitter {
44
112
  private readonly provider: ChatClientTransport;
45
113
  private readonly responseTimeoutMs: number;
46
114
 
47
- // FIFO of promises waiting on a particular typed server response. The server
48
- // replies on the same socket, one-per-request, so a simple queue per type
49
- // matches observed behavior.
115
+ // FIFO of promises waiting on a typed `messages:ok` reply (keyed by source).
50
116
  private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
51
117
 
52
118
  private readonly boundOnStateless: (data: { payload: string }) => void;
119
+ private readonly boundOnServerError: (data: {
120
+ source: string;
121
+ code: string;
122
+ message: string;
123
+ meta?: unknown;
124
+ }) => void;
53
125
 
54
126
  constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
55
127
  super();
56
128
  this.provider = provider;
57
129
  this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
58
130
  this.boundOnStateless = (data) => this.handleStateless(data.payload);
131
+ this.boundOnServerError = (data) => this.handleServerError(data);
59
132
  this.provider.on("stateless", this.boundOnStateless);
133
+ // BaseProvider intercepts `{type:"error",source,code}` frames and emits
134
+ // them as `serverError`, not `stateless` — so request/response code paths
135
+ // must listen here to learn about rejections.
136
+ this.provider.on("serverError", this.boundOnServerError);
60
137
  }
61
138
 
62
- /** Stop listening for chat messages. Does not disconnect the underlying provider. */
63
139
  destroy(): void {
64
140
  this.provider.off("stateless", this.boundOnStateless);
141
+ this.provider.off("serverError", this.boundOnServerError);
65
142
  for (const queue of this.pending.values()) {
66
143
  for (const p of queue) {
67
144
  clearTimeout(p.timer);
@@ -74,107 +151,121 @@ export class ChatClient extends EventEmitter {
74
151
 
75
152
  // ── Outgoing requests ──────────────────────────────────────────────────────
76
153
 
77
- /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
78
- sendMessage(input: SendChatMessageInput): void {
154
+ /** Send a chat message. Resolves with the server-assigned id, ts, and signatures. */
155
+ send(input: SendMessageInput): Promise<SendMessageResult> {
156
+ const promise = this.enqueue<SendMessageResult>("messages:send");
79
157
  this.provider.sendStateless(
80
158
  JSON.stringify({
81
- type: "chat:send",
82
- channel: input.channel,
159
+ type: "messages:send",
160
+ channel_doc_id: input.channel_doc_id,
83
161
  content: input.content,
84
- ...(input.sender_name !== undefined ? { sender_name: input.sender_name } : {}),
162
+ mentions: input.mentions ?? [],
163
+ ...(input.reply_to !== undefined ? { reply_to: input.reply_to } : {}),
164
+ ...(input.sig !== undefined ? { sig: input.sig } : {}),
165
+ ...(input.ts_hint !== undefined ? { ts_hint: input.ts_hint } : {}),
85
166
  }),
86
167
  );
168
+ return promise;
87
169
  }
88
170
 
89
- /** Fetch historical messages for a channel. Resolves with the server response. */
90
- getHistory(input: GetChatHistoryInput): Promise<{ channel: string; messages: ChatMessage[] }> {
91
- const promise = this.enqueue<{ channel: string; messages: ChatMessage[] }>("chat:history");
171
+ /** Edit a previously-sent message. Append-only server records an `edit` entry. */
172
+ edit(input: EditMessageInput): Promise<void> {
173
+ const promise = this.enqueue<void>("messages:edit");
92
174
  this.provider.sendStateless(
93
175
  JSON.stringify({
94
- type: "chat:history",
95
- channel: input.channel,
96
- ...(input.before !== undefined ? { before: input.before } : {}),
97
- ...(input.limit !== undefined ? { limit: input.limit } : {}),
176
+ type: "messages:edit",
177
+ channel_doc_id: input.channel_doc_id,
178
+ message_id: input.message_id,
179
+ content: input.content,
180
+ ...(input.sig !== undefined ? { sig: input.sig } : {}),
98
181
  }),
99
182
  );
100
183
  return promise;
101
184
  }
102
185
 
103
- /** Broadcast a typing indicator on a channel. */
104
- sendTyping(channel: string): void {
105
- this.provider.sendStateless(JSON.stringify({ type: "chat:typing", channel }));
106
- }
107
-
108
- /** List the current user's channels (ordered by last activity). */
109
- listChannels(): Promise<{ channels: ChatChannel[] }> {
110
- const promise = this.enqueue<{ channels: ChatChannel[] }>("chat:channels");
111
- this.provider.sendStateless(JSON.stringify({ type: "chat:channels" }));
186
+ /** Delete a message. Append-only server records a `tombstone` entry. */
187
+ delete(input: DeleteMessageInput): Promise<void> {
188
+ const promise = this.enqueue<void>("messages:delete");
189
+ this.provider.sendStateless(
190
+ JSON.stringify({
191
+ type: "messages:delete",
192
+ channel_doc_id: input.channel_doc_id,
193
+ message_id: input.message_id,
194
+ }),
195
+ );
112
196
  return promise;
113
197
  }
114
198
 
115
- /** Mark a channel read up to `timestamp` (unix ms). */
116
- markRead(channel: string, timestamp: number): void {
199
+ /** Broadcast a typing indicator on a channel. Fire-and-forget. */
200
+ typing(channel_doc_id: string): void {
117
201
  this.provider.sendStateless(
118
- JSON.stringify({ type: "chat:mark_read", channel, timestamp }),
202
+ JSON.stringify({ type: "messages:typing", channel_doc_id }),
119
203
  );
120
204
  }
121
205
 
122
- /** Fetch per-user read cursors for a channel. */
123
- getReadCursors(channel: string): Promise<{ channel: string; cursors: ChatReadCursor[] }> {
124
- const promise = this.enqueue<{ channel: string; cursors: ChatReadCursor[] }>(
125
- "chat:read_cursors",
206
+ /** Mark a (period, message) position as read for the calling user. Fire-and-forget. */
207
+ markRead(input: MarkReadInput): void {
208
+ this.provider.sendStateless(
209
+ JSON.stringify({
210
+ type: "messages:mark_read",
211
+ channel_doc_id: input.channel_doc_id,
212
+ period_id: input.period_id,
213
+ message_id: input.message_id,
214
+ ...(input.ts !== undefined ? { ts: input.ts } : {}),
215
+ }),
126
216
  );
127
- this.provider.sendStateless(JSON.stringify({ type: "chat:read_cursors", channel }));
128
- return promise;
129
217
  }
130
218
 
131
- // ── Typed event subscription helpers (optional sugar over EventEmitter) ───
132
-
133
- onMessage(fn: (m: ChatMessage) => void): this {
134
- return this.on("message", fn) as this;
135
- }
219
+ // ── Typed event subscription helpers ──────────────────────────────────────
136
220
 
221
+ /** Observe typing broadcasts. */
137
222
  onTyping(fn: (e: ChatTypingEvent) => void): this {
138
223
  return this.on("typing", fn) as this;
139
224
  }
140
225
 
141
- onReadReceipt(fn: (e: ChatReadReceipt) => void): this {
142
- return this.on("readReceipt", fn) as this;
143
- }
144
-
145
226
  // ── Internals ─────────────────────────────────────────────────────────────
146
227
 
147
- private enqueue<T>(type: string): Promise<T> {
228
+ private enqueue<T>(source: string): Promise<T> {
148
229
  return new Promise<T>((resolve, reject) => {
149
230
  const timer = setTimeout(() => {
150
- this.removePending(type, entry);
151
- reject(new Error(`ChatClient: timeout waiting for ${type} response`));
231
+ this.removePending(source, entry);
232
+ reject(new Error(`ChatClient: timeout waiting for ${source} ack`));
152
233
  }, this.responseTimeoutMs);
153
234
  const entry: PendingResolver<T> = { resolve, reject, timer };
154
- const queue = this.pending.get(type) ?? [];
235
+ const queue = this.pending.get(source) ?? [];
155
236
  queue.push(entry);
156
- this.pending.set(type, queue);
237
+ this.pending.set(source, queue);
157
238
  });
158
239
  }
159
240
 
160
- private removePending(type: string, entry: PendingResolver<any>): void {
161
- const queue = this.pending.get(type);
241
+ private removePending(source: string, entry: PendingResolver<any>): void {
242
+ const queue = this.pending.get(source);
162
243
  if (!queue) return;
163
244
  const idx = queue.indexOf(entry);
164
245
  if (idx >= 0) queue.splice(idx, 1);
165
- if (queue.length === 0) this.pending.delete(type);
246
+ if (queue.length === 0) this.pending.delete(source);
166
247
  }
167
248
 
168
- private resolveNext<T>(type: string, value: T): boolean {
169
- const queue = this.pending.get(type);
249
+ private resolveNext<T>(source: string, value: T): boolean {
250
+ const queue = this.pending.get(source);
170
251
  if (!queue || queue.length === 0) return false;
171
252
  const next = queue.shift()!;
172
- if (queue.length === 0) this.pending.delete(type);
253
+ if (queue.length === 0) this.pending.delete(source);
173
254
  clearTimeout(next.timer);
174
255
  next.resolve(value);
175
256
  return true;
176
257
  }
177
258
 
259
+ private rejectNext(source: string, err: Error): boolean {
260
+ const queue = this.pending.get(source);
261
+ if (!queue || queue.length === 0) return false;
262
+ const next = queue.shift()!;
263
+ if (queue.length === 0) this.pending.delete(source);
264
+ clearTimeout(next.timer);
265
+ next.reject(err);
266
+ return true;
267
+ }
268
+
178
269
  private handleStateless(payload: string): void {
179
270
  let parsed: any;
180
271
  try {
@@ -183,52 +274,41 @@ export class ChatClient extends EventEmitter {
183
274
  return;
184
275
  }
185
276
  const type: unknown = parsed?.type;
186
- if (typeof type !== "string" || !type.startsWith("chat:")) return;
187
-
188
- switch (type) {
189
- case "chat:message": {
190
- // Server flattens the ChatMessage fields at the top level alongside `type`.
191
- const { type: _t, ...rest } = parsed;
192
- this.emit("message", rest as ChatMessage);
193
- break;
194
- }
195
- case "chat:history": {
196
- this.resolveNext("chat:history", {
197
- channel: parsed.channel,
198
- messages: parsed.messages ?? [],
199
- });
200
- break;
201
- }
202
- case "chat:channels": {
203
- this.resolveNext("chat:channels", { channels: parsed.channels ?? [] });
204
- break;
205
- }
206
- case "chat:typing": {
207
- this.emit("typing", {
208
- channel: parsed.channel,
209
- sender_id: parsed.sender_id,
210
- sender_name: parsed.sender_name ?? null,
211
- } as ChatTypingEvent);
212
- break;
213
- }
214
- case "chat:read_receipt": {
215
- this.emit("readReceipt", {
216
- channel: parsed.channel,
217
- user_id: parsed.user_id,
218
- last_read_at: parsed.last_read_at,
219
- } as ChatReadReceipt);
220
- break;
221
- }
222
- case "chat:read_cursors": {
223
- this.resolveNext("chat:read_cursors", {
224
- channel: parsed.channel,
225
- cursors: parsed.cursors ?? [],
226
- });
227
- break;
228
- }
229
- default:
230
- // Unknown chat:* subtype — ignore for forward compat.
231
- break;
277
+ if (typeof type !== "string") return;
278
+
279
+ // Server emits `messages:ok` acks with a `source` matching the originating
280
+ // request type. Use that to resolve the matching pending promise.
281
+ if (type === "messages:ok" && typeof parsed.source === "string") {
282
+ this.resolveNext(parsed.source, parsed.meta);
283
+ return;
232
284
  }
285
+ if (type === "messages:typing") {
286
+ // The server includes channel_doc_id + user_id; the legacy
287
+ // ChatTypingEvent shape has `channel`, so emit it as the new doc id —
288
+ // consumers that want the old `group:<id>` form can prefix locally.
289
+ this.emit("typing", {
290
+ channel: parsed.channel_doc_id,
291
+ sender_id: parsed.user_id,
292
+ sender_name: null,
293
+ } as ChatTypingEvent);
294
+ return;
295
+ }
296
+ }
297
+
298
+ private handleServerError(data: {
299
+ source: string;
300
+ code: string;
301
+ message: string;
302
+ meta?: unknown;
303
+ }): void {
304
+ if (typeof data.source !== "string" || !data.source.startsWith("messages:")) return;
305
+ if (data.source === "messages:inbox_fetch" || data.source === "messages:inbox_mark_read") {
306
+ // Inbox traffic belongs to NotificationsClient — let it handle its own errors.
307
+ return;
308
+ }
309
+ this.rejectNext(
310
+ data.source,
311
+ new Error(`${data.code ?? "error"}: ${data.message ?? "messages request failed"}`),
312
+ );
233
313
  }
234
314
  }
@@ -10,7 +10,19 @@ import {
10
10
  yjsToMarkdown,
11
11
  populateYDocFromMarkdown,
12
12
  parseFrontmatter,
13
+ buildHeadingElement,
14
+ buildParagraphElement,
15
+ buildBulletListElement,
16
+ buildOrderedListElement,
17
+ buildTaskListElement,
18
+ buildCodeBlockElement,
19
+ buildBlockquoteElement,
20
+ buildHorizontalRuleElement,
21
+ buildBlocksFromMarkdown,
22
+ readBlocksFromFragment,
23
+ type DocumentBlock,
13
24
  } from "./DocConverters.ts";
25
+ export type { DocumentBlock } from "./DocConverters.ts";
14
26
  import type { DocumentManager } from "./DocumentManager.ts";
15
27
 
16
28
  export interface DocumentContent {
@@ -46,11 +58,12 @@ export class ContentManager {
46
58
  let label = title;
47
59
  let type: string | undefined;
48
60
  let meta: PageMeta | undefined;
49
- let children: Array<{
61
+ const childrenWithOrder: Array<{
50
62
  id: string;
51
63
  label: string;
52
64
  type?: string;
53
65
  meta?: PageMeta;
66
+ order: number;
54
67
  }> = [];
55
68
 
56
69
  if (treeMap) {
@@ -62,23 +75,28 @@ export class ContentManager {
62
75
  meta = entry.meta as PageMeta | undefined;
63
76
  }
64
77
  // Collect immediate children sorted by order
65
- treeMap.forEach((value: any, id: string) => {
66
- if (value.parentId === docId) {
67
- children.push({
78
+ treeMap.forEach((rawChild: unknown, id: string) => {
79
+ const value = toPlain(rawChild) as Record<string, unknown>;
80
+ if (value && value.parentId === docId) {
81
+ childrenWithOrder.push({
68
82
  id,
69
- label: value.label || "Untitled",
70
- type: value.type,
71
- meta: value.meta,
83
+ label: (value.label as string) || "Untitled",
84
+ type: value.type as string | undefined,
85
+ meta: value.meta as PageMeta | undefined,
86
+ order: (value.order as number) ?? 0,
72
87
  });
73
88
  }
74
89
  });
75
- children.sort((a, b) => {
76
- const orderA = treeMap.get(a.id)?.order ?? 0;
77
- const orderB = treeMap.get(b.id)?.order ?? 0;
78
- return orderA - orderB;
79
- });
90
+ childrenWithOrder.sort((a, b) => a.order - b.order);
80
91
  }
81
92
 
93
+ const children = childrenWithOrder.map(({ id, label, type, meta }) => ({
94
+ id,
95
+ label,
96
+ type,
97
+ meta,
98
+ }));
99
+
82
100
  return { label, type, meta, title, markdown, children };
83
101
  }
84
102
 
@@ -141,6 +159,56 @@ export class ContentManager {
141
159
  populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle);
142
160
  }
143
161
 
162
+ private async _appendElements(docId: string, els: Y.XmlElement[]): Promise<void> {
163
+ const provider = await this.dm.getChildProvider(docId);
164
+ const doc = provider.document;
165
+ const fragment = doc.getXmlFragment("default");
166
+ doc.transact(() => {
167
+ fragment.insert(fragment.length, els);
168
+ });
169
+ }
170
+
171
+ async appendHeading(docId: string, text: string, opts?: { level?: 1|2|3|4|5|6 }): Promise<void> {
172
+ return this._appendElements(docId, [buildHeadingElement(text, opts?.level)]);
173
+ }
174
+
175
+ async appendParagraph(docId: string, text: string): Promise<void> {
176
+ return this._appendElements(docId, [buildParagraphElement(text)]);
177
+ }
178
+
179
+ async appendBulletList(docId: string, items: string[]): Promise<void> {
180
+ return this._appendElements(docId, [buildBulletListElement(items)]);
181
+ }
182
+
183
+ async appendOrderedList(docId: string, items: string[]): Promise<void> {
184
+ return this._appendElements(docId, [buildOrderedListElement(items)]);
185
+ }
186
+
187
+ async appendTaskList(docId: string, items: Array<{ text: string; checked?: boolean }>): Promise<void> {
188
+ return this._appendElements(docId, [buildTaskListElement(items)]);
189
+ }
190
+
191
+ async appendCodeBlock(docId: string, code: string, opts?: { language?: string }): Promise<void> {
192
+ return this._appendElements(docId, [buildCodeBlockElement(code, opts?.language)]);
193
+ }
194
+
195
+ async appendBlockquote(docId: string, text: string): Promise<void> {
196
+ return this._appendElements(docId, [buildBlockquoteElement(text)]);
197
+ }
198
+
199
+ async appendHorizontalRule(docId: string): Promise<void> {
200
+ return this._appendElements(docId, [buildHorizontalRuleElement()]);
201
+ }
202
+
203
+ async appendMarkdown(docId: string, markdown: string): Promise<void> {
204
+ return this._appendElements(docId, buildBlocksFromMarkdown(markdown));
205
+ }
206
+
207
+ async getBlocks(docId: string): Promise<DocumentBlock[]> {
208
+ const provider = await this.dm.getChildProvider(docId);
209
+ return readBlocksFromFragment(provider.document.getXmlFragment("default"));
210
+ }
211
+
144
212
  /**
145
213
  * Get the raw Y.XmlFragment for a document (the 'default' fragment
146
214
  * that TipTap uses for document content).
@@ -357,7 +357,7 @@ export class CryptoIdentityKeystore {
357
357
  } else {
358
358
  const all = await dbGetAll(db);
359
359
  if (all.length > 0) {
360
- await dbPut(db, all[0].key, { ...all[0].value, username });
360
+ await dbPut(db, String(all[0].key), { ...all[0].value, username });
361
361
  }
362
362
  }
363
363
  } finally {
@@ -557,7 +557,7 @@ export class CryptoIdentityKeystore {
557
557
  challenge: crypto.getRandomValues(new Uint8Array(32)),
558
558
  rp: { id: rpId, name: rpName },
559
559
  user: {
560
- id: fromBase64url(kp.publicKeyB64),
560
+ id: fromBase64url(kp.publicKeyB64) as BufferSource,
561
561
  name: kp.publicKeyB64.slice(0, 16),
562
562
  displayName: kp.publicKeyB64.slice(0, 16),
563
563
  },
@@ -663,7 +663,7 @@ export class CryptoIdentityKeystore {
663
663
 
664
664
  if (credentialIdHint) {
665
665
  allowCredentials.push({
666
- id: fromBase64url(credentialIdHint),
666
+ id: fromBase64url(credentialIdHint) as BufferSource,
667
667
  type: "public-key",
668
668
  });
669
669
  } else {