@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.
@@ -12,6 +12,7 @@ import type {
12
12
  TreeSearchResult,
13
13
  PageMeta,
14
14
  } from "./DocTypes.ts";
15
+ import { resolvePageType } from "./DocTypes.ts";
15
16
  import { toPlain, normalizeRootId } from "./DocUtils.ts";
16
17
  import type { DocumentManager } from "./DocumentManager.ts";
17
18
 
@@ -227,16 +228,50 @@ export class TreeManager {
227
228
  };
228
229
  }
229
230
 
231
+ /**
232
+ * Create a document and auto-apply renderer defaults from the page type's
233
+ * `defaultMetaFields` (toggle fields with `default` values).
234
+ */
235
+ createWithTypeDefaults(opts: {
236
+ parentId?: string | null;
237
+ label: string;
238
+ type: string;
239
+ extraMeta?: Partial<PageMeta>;
240
+ }): TreeEntry {
241
+ const typeInfo = resolvePageType(opts.type);
242
+ const autoMeta: Record<string, unknown> = {};
243
+ if (typeInfo?.defaultMetaFields) {
244
+ for (const field of typeInfo.defaultMetaFields) {
245
+ if (field.type === "toggle" && field.key && field.default !== undefined) {
246
+ autoMeta[field.key] = field.default;
247
+ }
248
+ }
249
+ }
250
+ const mergedMeta =
251
+ Object.keys(autoMeta).length > 0 || opts.extraMeta
252
+ ? { ...autoMeta, ...(opts.extraMeta ?? {}) }
253
+ : undefined;
254
+ return this.create({
255
+ parentId: opts.parentId,
256
+ label: opts.label,
257
+ type: opts.type,
258
+ meta: mergedMeta,
259
+ });
260
+ }
261
+
230
262
  /** Rename a document. */
231
263
  rename(docId: string, label: string): void {
232
264
  const treeMap = this.dm.getTreeMap();
233
- if (!treeMap) throw new Error("Not connected");
265
+ const rootDoc = this.dm.rootDocument;
266
+ if (!treeMap || !rootDoc) throw new Error("Not connected");
234
267
 
235
268
  const raw = treeMap.get(docId);
236
269
  if (!raw) throw new Error(`Document ${docId} not found`);
237
270
 
238
271
  const entry = toPlain(raw) as Record<string, unknown>;
239
- treeMap.set(docId, { ...entry, label, updatedAt: Date.now() });
272
+ rootDoc.transact(() => {
273
+ treeMap.set(docId, { ...entry, label, updatedAt: Date.now() });
274
+ });
240
275
  }
241
276
 
242
277
  /** Move a document to a new parent. */
@@ -246,33 +281,39 @@ export class TreeManager {
246
281
  order?: number,
247
282
  ): void {
248
283
  const treeMap = this.dm.getTreeMap();
249
- if (!treeMap) throw new Error("Not connected");
284
+ const rootDoc = this.dm.rootDocument;
285
+ if (!treeMap || !rootDoc) throw new Error("Not connected");
250
286
 
251
287
  const raw = treeMap.get(docId);
252
288
  if (!raw) throw new Error(`Document ${docId} not found`);
253
289
 
254
290
  const entry = toPlain(raw) as Record<string, unknown>;
255
- treeMap.set(docId, {
256
- ...entry,
257
- parentId: normalizeRootId(
258
- newParentId ?? null,
259
- this.dm.rootDocId,
260
- ),
261
- order: order ?? Date.now(),
262
- updatedAt: Date.now(),
291
+ rootDoc.transact(() => {
292
+ treeMap.set(docId, {
293
+ ...entry,
294
+ parentId: normalizeRootId(
295
+ newParentId ?? null,
296
+ this.dm.rootDocId,
297
+ ),
298
+ order: order ?? Date.now(),
299
+ updatedAt: Date.now(),
300
+ });
263
301
  });
264
302
  }
265
303
 
266
304
  /** Change the page type of a document. */
267
305
  changeType(docId: string, type: string): void {
268
306
  const treeMap = this.dm.getTreeMap();
269
- if (!treeMap) throw new Error("Not connected");
307
+ const rootDoc = this.dm.rootDocument;
308
+ if (!treeMap || !rootDoc) throw new Error("Not connected");
270
309
 
271
310
  const raw = treeMap.get(docId);
272
311
  if (!raw) throw new Error(`Document ${docId} not found`);
273
312
 
274
313
  const entry = toPlain(raw) as Record<string, unknown>;
275
- treeMap.set(docId, { ...entry, type, updatedAt: Date.now() });
314
+ rootDoc.transact(() => {
315
+ treeMap.set(docId, { ...entry, type, updatedAt: Date.now() });
316
+ });
276
317
  }
277
318
 
278
319
  /**
@@ -325,21 +366,24 @@ export class TreeManager {
325
366
  const entry = toPlain(raw) as Record<string, unknown>;
326
367
  const newId = crypto.randomUUID();
327
368
  const now = Date.now();
369
+ const newLabel = ((entry.label as string) || "Untitled") + " (copy)";
328
370
  treeMap.set(newId, {
329
371
  ...entry,
330
- label: ((entry.label as string) || "Untitled") + " (copy)",
372
+ label: newLabel,
331
373
  order: now,
374
+ createdAt: now,
375
+ updatedAt: now,
332
376
  });
333
377
 
334
378
  return {
335
379
  id: newId,
336
- label: ((entry.label as string) || "Untitled") + " (copy)",
380
+ label: newLabel,
337
381
  parentId: (entry.parentId as string | null) ?? null,
338
382
  order: now,
339
383
  type: entry.type as string | undefined,
340
384
  meta: entry.meta as PageMeta | undefined,
341
- createdAt: entry.createdAt as number | undefined,
342
- updatedAt: entry.updatedAt as number | undefined,
385
+ createdAt: now,
386
+ updatedAt: now,
343
387
  };
344
388
  }
345
389
 
@@ -8,8 +8,9 @@
8
8
  * This propagates "last edited" timestamps to all peers via the root CRDT,
9
9
  * without requiring any server-side changes.
10
10
  *
11
- * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
12
- * on the root doc during rapid typing.
11
+ * Leading-edge + trailing-edge throttle (default 5 s window): the first
12
+ * update flushes immediately so the "last edited" badge updates instantly;
13
+ * follow-up updates within the window are coalesced into one trailing flush.
13
14
  */
14
15
 
15
16
  import * as Y from "yjs";
@@ -19,18 +20,15 @@ import type { OfflineStore } from "./OfflineStore.ts";
19
20
  * Attach an observer that writes `updatedAt` to the root doc-tree entry for
20
21
  * `childDocId` whenever the child doc receives a non-offline update.
21
22
  *
22
- * Writes are throttled: the first qualifying update records the timestamp;
23
- * a trailing-edge timer flushes it to the tree map after `throttleMs`.
24
- *
25
23
  * @param treeMap The root doc's "doc-tree" Y.Map.
26
24
  * @param childDocId The child document's UUID (key in treeMap).
27
25
  * @param childDoc The child Y.Doc to observe.
28
26
  * @param offlineStore The child provider's OfflineStore (used to detect
29
27
  * offline-replay origins and skip them). Pass null when
30
28
  * the offline store is disabled.
31
- * @param options Optional config. `throttleMs` controls the write
32
- * interval (default 5000).
33
- * @returns Cleanup function — call on provider destroy. Flushes
29
+ * @param options Optional config. `throttleMs` controls the throttle
30
+ * window (default 5000).
31
+ * @returns Cleanup function — call on provider destroy. Flushes
34
32
  * any pending write before detaching.
35
33
  */
36
34
  export function attachUpdatedAtObserver(
@@ -42,34 +40,39 @@ export function attachUpdatedAtObserver(
42
40
  ): () => void {
43
41
  const throttleMs = options?.throttleMs ?? 5000;
44
42
 
45
- let latestTs = 0;
43
+ let lastFlushedAt = 0;
44
+ let pendingTs = 0;
46
45
  let timer: ReturnType<typeof setTimeout> | null = null;
47
46
 
48
- function flush(): void {
49
- if (latestTs === 0) return;
50
- const ts = latestTs;
51
- latestTs = 0;
52
- timer = null;
53
-
47
+ function writeTs(ts: number): void {
54
48
  const raw = treeMap.get(childDocId);
55
49
  if (!raw) return;
56
-
57
- // Guard: if the entry is a nested Y.Map (possible after Yrs
58
- // document compaction), convert to plain object so spread works.
59
50
  const entry = raw instanceof Y.Map ? (raw as any).toJSON() : raw;
60
51
  treeMap.set(childDocId, { ...entry, updatedAt: ts });
52
+ lastFlushedAt = ts;
53
+ }
54
+
55
+ function flushPending(): void {
56
+ timer = null;
57
+ if (pendingTs === 0) return;
58
+ const ts = pendingTs;
59
+ pendingTs = 0;
60
+ writeTs(ts);
61
61
  }
62
62
 
63
63
  function handler(_update: Uint8Array, origin: unknown): void {
64
- // Skip updates replayed from the local offline store — they represent
65
- // content that was already "seen" and shouldn't advance updatedAt.
66
64
  if (offlineStore !== null && origin === offlineStore) return;
67
65
 
68
- latestTs = Date.now();
69
-
70
- // Schedule a trailing-edge flush if none is pending.
66
+ const now = Date.now();
67
+ if (now - lastFlushedAt >= throttleMs) {
68
+ // Leading edge flush immediately.
69
+ writeTs(now);
70
+ return;
71
+ }
72
+ // Inside the throttle window — coalesce into a trailing flush.
73
+ pendingTs = now;
71
74
  if (timer === null) {
72
- timer = setTimeout(flush, throttleMs);
75
+ timer = setTimeout(flushPending, throttleMs - (now - lastFlushedAt));
73
76
  }
74
77
  }
75
78
 
@@ -79,7 +82,7 @@ export function attachUpdatedAtObserver(
79
82
  childDoc.off("update", handler);
80
83
  if (timer !== null) {
81
84
  clearTimeout(timer);
82
- flush(); // persist the last pending timestamp
85
+ flushPending();
83
86
  }
84
87
  };
85
88
  }
package/src/index.ts CHANGED
@@ -37,8 +37,52 @@ export type {
37
37
  } from "./IdentityDoc.ts";
38
38
  export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
39
39
  export { ChatClient } from "./ChatClient.ts";
40
- export type { ChatClientTransport } from "./ChatClient.ts";
40
+ export { RpcClient, RpcError, RPC_PREFIX } from "./RpcClient.ts";
41
+ export type {
42
+ RpcKind,
43
+ RpcFrame,
44
+ RpcErrorPayload,
45
+ RpcErrorCode,
46
+ RpcTransport,
47
+ RpcTarget,
48
+ RpcCallOptions,
49
+ RpcCallHandle,
50
+ RpcHandler,
51
+ RpcHandlerContext,
52
+ } from "./RpcClient.ts";
53
+ export type {
54
+ ChatClientTransport,
55
+ SendMessageInput,
56
+ SendMessageResult,
57
+ EditMessageInput,
58
+ DeleteMessageInput,
59
+ MarkReadInput as ChatMarkReadInput,
60
+ } from "./ChatClient.ts";
61
+ export {
62
+ EncryptedChatClient,
63
+ ChannelKeyResolver,
64
+ encryptChatContent,
65
+ decryptChatContent,
66
+ isEncryptedContent,
67
+ } from "./EncryptedChatClient.ts";
68
+ export {
69
+ foldRecords,
70
+ recordFromYAny,
71
+ } from "./messageRecord.ts";
72
+ export type {
73
+ MessageRecord,
74
+ FoldedMessage,
75
+ } from "./messageRecord.ts";
41
76
  export { NotificationsClient } from "./NotificationsClient.ts";
77
+ export type {
78
+ InboxEntry,
79
+ FetchInboxInput,
80
+ MarkReadInput as InboxMarkReadInput,
81
+ } from "./NotificationsClient.ts";
82
+ // Legacy type re-exports — wire-shape types from the deprecated chat:* / notify:*
83
+ // protocol that consumers may still import. The dashboard's reactive layer
84
+ // reads/writes these shapes directly off Y.Array entries on channel-period and
85
+ // inbox docs, independently of ChatClient/NotificationsClient.
42
86
  export type {
43
87
  ChatMessage,
44
88
  ChatChannel,
@@ -62,3 +106,29 @@ export {
62
106
  unwrapSeed,
63
107
  bip39Wordlist,
64
108
  } from "./MnemonicKeyDerivation.ts";
109
+ export { DocumentManager } from "./DocumentManager.ts";
110
+ export type { DocumentManagerConfig } from "./DocumentManager.ts";
111
+ export { TreeManager } from "./TreeManager.ts";
112
+ export { ContentManager } from "./ContentManager.ts";
113
+ export type { DocumentContent } from "./ContentManager.ts";
114
+ export { MetaManager } from "./MetaManager.ts";
115
+ export type { DocumentMetaInfo } from "./MetaManager.ts";
116
+ export * from "./DocTypes.ts";
117
+ export { waitForSync, withTimeout, normalizeRootId, toPlain } from "./DocUtils.ts";
118
+ export {
119
+ yjsToMarkdown,
120
+ populateYDocFromMarkdown,
121
+ parseFrontmatter,
122
+ filenameToLabel,
123
+ buildHeadingElement,
124
+ buildParagraphElement,
125
+ buildBulletListElement,
126
+ buildOrderedListElement,
127
+ buildTaskListElement,
128
+ buildCodeBlockElement,
129
+ buildBlockquoteElement,
130
+ buildHorizontalRuleElement,
131
+ buildBlocksFromMarkdown,
132
+ readBlocksFromFragment,
133
+ } from "./DocConverters.ts";
134
+ export type { DocumentBlock } from "./DocConverters.ts";
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Decode + fold message records from a period doc's `messages` Y.Array
3
+ * into the flat `ChatMessage[]` shape the dashboard renders.
4
+ *
5
+ * The server appends three kinds of records to the array (see
6
+ * `abracadabra-rs/crates/abracadabra/src/messages.rs`):
7
+ *
8
+ * - `record_kind: "message"` — a real message (default kind).
9
+ * - `record_kind: "edit"` — an updated version of `target_id`.
10
+ * - `record_kind: "tombstone"` — `target_id` is deleted.
11
+ *
12
+ * Folding rules:
13
+ * - For each `target_id`, apply edits in `ts` order; the *last* edit's
14
+ * content wins. The original message's id, sender_id, and ts stay.
15
+ * `editedAt` is the latest edit's ts.
16
+ * - Any target_id with a tombstone is dropped from the output entirely.
17
+ * - Records without record_kind default to "message" (back-compat).
18
+ *
19
+ * Cross-period folding: edits and tombstones can in principle target a
20
+ * message in a *prior* period (e.g. user edits a message that lived on
21
+ * the previous period after rollover). Callers that paginate across
22
+ * periods should fold the joined list, not each period independently.
23
+ */
24
+
25
+ export interface MessageRecord {
26
+ record_kind?: 'message' | 'edit' | 'tombstone'
27
+ id: string
28
+ target_id?: string
29
+ sender_id: string
30
+ channel_doc_id: string
31
+ period_id: string
32
+ ts: number
33
+ content: string
34
+ mentions?: string[]
35
+ reply_to?: string | null
36
+ sig?: string
37
+ server_sig?: string
38
+ }
39
+
40
+ export interface FoldedMessage {
41
+ id: string
42
+ channel: string
43
+ senderId: string
44
+ senderName?: string
45
+ content: string
46
+ createdAt: number
47
+ /** Set if the message has been edited at least once. */
48
+ editedAt?: number
49
+ /** Reply target message id, if any. */
50
+ replyTo?: string
51
+ }
52
+
53
+ /**
54
+ * Fold an ordered list of records (oldest-first) into the visible
55
+ * messages a UI should render. Records may come from one period or
56
+ * across multiple periods concatenated in time order.
57
+ */
58
+ export function foldRecords(records: MessageRecord[]): FoldedMessage[] {
59
+ const tombstoned = new Set<string>()
60
+ for (const r of records) {
61
+ if (r.record_kind === 'tombstone' && r.target_id) tombstoned.add(r.target_id)
62
+ }
63
+
64
+ const base = new Map<string, FoldedMessage>()
65
+ for (const r of records) {
66
+ const kind = r.record_kind ?? 'message'
67
+ if (kind !== 'message') continue
68
+ if (tombstoned.has(r.id)) continue
69
+ base.set(r.id, {
70
+ id: r.id,
71
+ channel: r.channel_doc_id,
72
+ senderId: r.sender_id,
73
+ content: r.content,
74
+ createdAt: r.ts,
75
+ ...(r.reply_to ? { replyTo: r.reply_to } : {}),
76
+ })
77
+ }
78
+
79
+ for (const r of records) {
80
+ if (r.record_kind !== 'edit' || !r.target_id) continue
81
+ if (tombstoned.has(r.target_id)) continue
82
+ const original = base.get(r.target_id)
83
+ if (!original) continue
84
+ original.content = r.content
85
+ original.editedAt = r.ts
86
+ }
87
+
88
+ return [...base.values()].sort((a, b) => a.createdAt - b.createdAt)
89
+ }
90
+
91
+ /**
92
+ * Decode whatever the Y.Array stored for an entry into a MessageRecord.
93
+ * Note: the server stamps `ts` server-side via `Any::BigInt(now_ts_ms())`,
94
+ * so on the wire it round-trips as a JS BigInt — `typeof` is `'bigint'`,
95
+ * not `'number'`. Accept both and coerce.
96
+ */
97
+ export function recordFromYAny(any: unknown): MessageRecord | null {
98
+ if (!any || typeof any !== 'object') return null
99
+ const o = any as Record<string, unknown>
100
+ const tsRaw = o.ts
101
+ const tsIsValid = typeof tsRaw === 'number' || typeof tsRaw === 'bigint'
102
+ if (typeof o.id !== 'string' || typeof o.sender_id !== 'string'
103
+ || typeof o.channel_doc_id !== 'string' || !tsIsValid) {
104
+ return null
105
+ }
106
+ const ts = typeof tsRaw === 'bigint' ? Number(tsRaw) : (tsRaw as number)
107
+ return {
108
+ record_kind: typeof o.record_kind === 'string' ? o.record_kind as MessageRecord['record_kind'] : 'message',
109
+ id: o.id,
110
+ target_id: typeof o.target_id === 'string' ? o.target_id : undefined,
111
+ sender_id: o.sender_id,
112
+ channel_doc_id: o.channel_doc_id,
113
+ period_id: typeof o.period_id === 'string' ? o.period_id : '',
114
+ ts,
115
+ content: typeof o.content === 'string' ? o.content : '',
116
+ mentions: Array.isArray(o.mentions) ? o.mentions as string[] : [],
117
+ reply_to: typeof o.reply_to === 'string' ? o.reply_to : null,
118
+ sig: typeof o.sig === 'string' ? o.sig : undefined,
119
+ server_sig: typeof o.server_sig === 'string' ? o.server_sig : undefined,
120
+ }
121
+ }