@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.
- package/dist/abracadabra-provider.cjs +12680 -9133
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12697 -9200
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1426 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +80 -12
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +161 -6
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +10 -0
- package/src/DocumentManager.ts +62 -85
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/IdentityDoc.ts +25 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +61 -17
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +166 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
package/src/TreeManager.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
342
|
-
updatedAt:
|
|
385
|
+
createdAt: now,
|
|
386
|
+
updatedAt: now,
|
|
343
387
|
};
|
|
344
388
|
}
|
|
345
389
|
|
package/src/TreeTimestamps.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
32
|
-
*
|
|
33
|
-
* @returns Cleanup function — call on provider destroy.
|
|
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
|
|
43
|
+
let lastFlushedAt = 0;
|
|
44
|
+
let pendingTs = 0;
|
|
46
45
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
47
46
|
|
|
48
|
-
function
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -183,20 +183,68 @@ export interface UserProfile {
|
|
|
183
183
|
role: string;
|
|
184
184
|
/** Account-level Ed25519 public key (base64url). Canonical user identity. */
|
|
185
185
|
publicKey: string | null;
|
|
186
|
+
/**
|
|
187
|
+
* Whether the user has a password set. `false` for key-based soft
|
|
188
|
+
* identities until they opt in via {@link AbracadabraClient.setPassword}.
|
|
189
|
+
* Drives the UI's "Set password" vs "Change password" branching.
|
|
190
|
+
*/
|
|
191
|
+
hasPassword?: boolean;
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
export interface DocumentMeta {
|
|
189
195
|
id: string;
|
|
190
196
|
parent_id: string | null;
|
|
197
|
+
/**
|
|
198
|
+
* Renderer hint — `"kanban"`, `"checklist"`, `"outline"`, `"graph"`,
|
|
199
|
+
* `"sheet"`, `"mindmap"`, `"doc"`, etc. Drives the dashboard's choice of
|
|
200
|
+
* Vue component for this doc. Orthogonal to {@link kind}.
|
|
201
|
+
*/
|
|
191
202
|
doc_type?: string | null;
|
|
192
203
|
label?: string | null;
|
|
193
204
|
description?: string | null;
|
|
194
205
|
public_access?: string | null;
|
|
195
206
|
owner_id?: string | null;
|
|
196
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Structural role hint — `"server"` for the reserved server-root doc,
|
|
209
|
+
* `"space"` for top-level workspace containers, `null`/absent otherwise.
|
|
210
|
+
* Distinct from {@link doc_type}: a Space (`kind: "space"`) can contain
|
|
211
|
+
* many Kanban boards (each `doc_type: "kanban"`).
|
|
212
|
+
*/
|
|
213
|
+
kind?: string | null;
|
|
197
214
|
updated_at?: number | null;
|
|
198
215
|
}
|
|
199
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Well-known structural roles for the {@link DocumentMeta.kind} field. These
|
|
219
|
+
* are conventions only — the server treats `kind` as an opaque string. Use
|
|
220
|
+
* these constants instead of string literals to avoid typos.
|
|
221
|
+
*/
|
|
222
|
+
export const Kind = {
|
|
223
|
+
/** The reserved server root document. There is exactly one per server. */
|
|
224
|
+
Server: "server",
|
|
225
|
+
/** A top-level workspace container — what the dashboard calls a "Space". */
|
|
226
|
+
Space: "space",
|
|
227
|
+
/** A regular content page rendered by the editor. */
|
|
228
|
+
Page: "page",
|
|
229
|
+
/** A group chat container under a Space. */
|
|
230
|
+
Channel: "channel",
|
|
231
|
+
/** A direct-message container at the server root with two-member permissions. */
|
|
232
|
+
Dm: "dm",
|
|
233
|
+
/** A child of channel/dm holding the messages Y.Array. */
|
|
234
|
+
ChannelPeriod: "channel-period",
|
|
235
|
+
/** A per-user inbox doc holding inbox entries; child of server root. */
|
|
236
|
+
Inbox: "inbox",
|
|
237
|
+
} as const;
|
|
238
|
+
export type Kind = typeof Kind[keyof typeof Kind];
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Hardcoded UUID of the reserved server root document. Every doc in the tree
|
|
242
|
+
* chains up to this row, so a permission grant on this doc cascades into
|
|
243
|
+
* every descendant. Mirrors the constant in the Rust crate
|
|
244
|
+
* (`crate::types::SERVER_ROOT_ID_STR`).
|
|
245
|
+
*/
|
|
246
|
+
export const SERVER_ROOT_ID = "00000000-0000-0000-0000-000000000000";
|
|
247
|
+
|
|
200
248
|
export interface UploadMeta {
|
|
201
249
|
id: string;
|
|
202
250
|
doc_id: string;
|
|
@@ -250,10 +298,30 @@ export interface SnapshotMeta {
|
|
|
250
298
|
label?: string | null;
|
|
251
299
|
created_by?: string | null;
|
|
252
300
|
created_at: number;
|
|
301
|
+
/** Number of uploads referenced by this snapshot's CRDT state.
|
|
302
|
+
* Always 0 on servers older than the `snapshot_files` migration, or for
|
|
303
|
+
* snapshots created before that migration ran (until the operator runs
|
|
304
|
+
* `POST /admin/snapshots/backfill-refs`). */
|
|
305
|
+
file_count?: number;
|
|
306
|
+
/** Signed byte delta vs. the previous-version snapshot for this doc.
|
|
307
|
+
* `null` for the first snapshot of a doc (or when the previous version
|
|
308
|
+
* has been pruned). Lets the UI render `+12 KB` / `−3 KB`. */
|
|
309
|
+
delta_bytes?: number | null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface SnapshotFileEntry {
|
|
313
|
+
id: string;
|
|
314
|
+
doc_id: string;
|
|
315
|
+
filename: string;
|
|
316
|
+
mime_type?: string | null;
|
|
317
|
+
size?: number | null;
|
|
253
318
|
}
|
|
254
319
|
|
|
255
320
|
export interface SnapshotData extends SnapshotMeta {
|
|
256
321
|
data: string;
|
|
322
|
+
/** Populated only when fetched with `?include=files`. Each entry is an
|
|
323
|
+
* upload referenced by this snapshot's CRDT state. */
|
|
324
|
+
files?: SnapshotFileEntry[];
|
|
257
325
|
}
|
|
258
326
|
|
|
259
327
|
export interface SnapshotCreateResult {
|
|
@@ -285,10 +353,23 @@ export interface ServerInfo {
|
|
|
285
353
|
version?: string;
|
|
286
354
|
/** Hocuspocus wire protocol version (currently 2). */
|
|
287
355
|
protocol_version?: number;
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
356
|
+
/**
|
|
357
|
+
* The reserved server root document id. Every doc in the tree chains up
|
|
358
|
+
* to this row; granting Admin here cascades to every doc. Hardcoded by
|
|
359
|
+
* the server — clients can also use {@link SERVER_ROOT_ID} directly.
|
|
360
|
+
*/
|
|
361
|
+
root_doc_id?: string;
|
|
362
|
+
/**
|
|
363
|
+
* Server-wide access policy. Drives client-side decisions like "show the
|
|
364
|
+
* sign-up CTA?" (anonymous mode) or "warn before publishing?" (anonymous
|
|
365
|
+
* grants).
|
|
366
|
+
*/
|
|
367
|
+
access?: {
|
|
368
|
+
/** `"none"` rejects unauthenticated requests; `"observer"` allows public read. */
|
|
369
|
+
anonymous?: "none" | "observer";
|
|
370
|
+
/** Server-wide floor for authed users with no explicit grant. */
|
|
371
|
+
authenticated?: "none" | "observer" | "viewer" | "editor";
|
|
372
|
+
};
|
|
292
373
|
/** Enabled auth methods (e.g. ["crypto", "jwt"]). */
|
|
293
374
|
auth_methods?: string[];
|
|
294
375
|
/** Whether open registration is enabled. */
|
|
@@ -297,6 +378,13 @@ export interface ServerInfo {
|
|
|
297
378
|
invite_only?: boolean;
|
|
298
379
|
/** Server encryption configuration. */
|
|
299
380
|
encryption?: { default_mode?: string; minimum_mode?: string };
|
|
381
|
+
/**
|
|
382
|
+
* Ed25519 public key (base64url, no padding) the server uses to sign every
|
|
383
|
+
* accepted `messages:*` record (`server_sig`). Clients can verify message
|
|
384
|
+
* placement / ordering by checking each record's `server_sig` against this
|
|
385
|
+
* key over the canonical `{ msg_id, period_id, ts, client_sig }` payload.
|
|
386
|
+
*/
|
|
387
|
+
messages_signer_pubkey?: string;
|
|
300
388
|
}
|
|
301
389
|
|
|
302
390
|
// ── Search ───────────────────────────────────────────────────────────────────
|
|
@@ -307,21 +395,83 @@ export interface SearchResult {
|
|
|
307
395
|
score: number;
|
|
308
396
|
}
|
|
309
397
|
|
|
310
|
-
|
|
398
|
+
/**
|
|
399
|
+
* A single hit returned by the server's full-text search endpoint
|
|
400
|
+
* (`GET /docs/search`). Distinct from {@link SearchResult}, which is the
|
|
401
|
+
* client-side trigram index in `SearchIndex.ts`.
|
|
402
|
+
*
|
|
403
|
+
* `snippet` is server-rendered HTML with `<mark>` wrappers around the
|
|
404
|
+
* matched tokens — safe to inject after standard sanitization. `rank` is
|
|
405
|
+
* a backend-specific score (SQLite bm25 vs Postgres ts_rank); the only
|
|
406
|
+
* guarantee is that hits arrive in best-first order.
|
|
407
|
+
*/
|
|
408
|
+
export interface DocSearchHit {
|
|
409
|
+
doc_id: string;
|
|
410
|
+
parent_id: string | null;
|
|
411
|
+
label: string | null;
|
|
412
|
+
kind: string | null;
|
|
413
|
+
/** HTML-safe snippet with `<mark>` markers around the matched tokens. */
|
|
414
|
+
snippet: string;
|
|
415
|
+
rank: number;
|
|
416
|
+
}
|
|
311
417
|
|
|
312
|
-
|
|
418
|
+
// ── Audit log (admin) ────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A single row from `GET /admin/audit`. Mirrors the server's audit log
|
|
422
|
+
* row format (see `audit::AuditLogRow`). `metadata` is the parsed JSON
|
|
423
|
+
* object the server stored, or `null` if the row had no metadata. Each
|
|
424
|
+
* row carries an internal `prev_hash` / `row_hash` chain that admins
|
|
425
|
+
* verify with {@link AuditVerifyResult}.
|
|
426
|
+
*/
|
|
427
|
+
export interface AuditLogEntry {
|
|
313
428
|
id: string;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
429
|
+
ts: number;
|
|
430
|
+
event_type: string;
|
|
431
|
+
actor_user_id: string | null;
|
|
432
|
+
actor_ip: string | null;
|
|
433
|
+
request_id: string | null;
|
|
434
|
+
target_type: string | null;
|
|
435
|
+
target_id: string | null;
|
|
436
|
+
metadata: Record<string, unknown> | null;
|
|
437
|
+
outcome: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Filters for `GET /admin/audit`. All optional and AND-combined. */
|
|
441
|
+
export interface AuditQueryOpts {
|
|
442
|
+
event_type?: string;
|
|
443
|
+
actor_user_id?: string;
|
|
444
|
+
target_type?: string;
|
|
445
|
+
target_id?: string;
|
|
446
|
+
since_ts?: number;
|
|
447
|
+
until_ts?: number;
|
|
448
|
+
limit?: number;
|
|
449
|
+
offset?: number;
|
|
323
450
|
}
|
|
324
451
|
|
|
452
|
+
/** Response of `GET /admin/audit/verify`. `status: "broken"` carries `break`. */
|
|
453
|
+
export interface AuditVerifyResult {
|
|
454
|
+
status: "ok" | "broken";
|
|
455
|
+
message?: string;
|
|
456
|
+
break?: { rows_checked: number; row_id: string; reason: string };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Readiness ────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
/** Response of `GET /readyz`. HTTP 200 when ready, 503 when not. */
|
|
462
|
+
export interface ReadyzStatus {
|
|
463
|
+
status: "ready" | "unready";
|
|
464
|
+
version: string;
|
|
465
|
+
checks: { database: "ok" | "error" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Spaces ───────────────────────────────────────────────────────────────────
|
|
469
|
+
//
|
|
470
|
+
// Spaces are documents whose `parent_id` is the server root and whose `kind`
|
|
471
|
+
// is `"space"`. Listing them is `client.listSpaces()`; they come back as
|
|
472
|
+
// regular `DocumentMeta` objects. Visibility is encoded in `public_access`:
|
|
473
|
+
// `null` (or `"none"`) = private, `"observer"` = public read-only.
|
|
474
|
+
|
|
325
475
|
// ── Invites ──────────────────────────────────────────────────────────────────
|
|
326
476
|
|
|
327
477
|
export interface InviteRow {
|