@abraca/dabra 1.5.0 → 1.8.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.
@@ -0,0 +1,234 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+ import type {
3
+ ChatChannel,
4
+ ChatMessage,
5
+ ChatReadCursor,
6
+ ChatReadReceipt,
7
+ ChatTypingEvent,
8
+ GetChatHistoryInput,
9
+ SendChatMessageInput,
10
+ } from "./types.ts";
11
+
12
+ /**
13
+ * Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
14
+ * Kept as an interface so consumers can pass any compatible transport.
15
+ */
16
+ export interface ChatClientTransport {
17
+ sendStateless(payload: string): void;
18
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
19
+ on(event: string, fn: Function): unknown;
20
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
21
+ off(event: string, fn?: Function): unknown;
22
+ }
23
+
24
+ type PendingResolver<T> = {
25
+ resolve: (value: T) => void;
26
+ reject: (err: Error) => void;
27
+ timer: ReturnType<typeof setTimeout>;
28
+ };
29
+
30
+ const DEFAULT_TIMEOUT_MS = 10_000;
31
+
32
+ /**
33
+ * Typed client for the Abracadabra chat feature.
34
+ *
35
+ * Wraps a connected provider (or base provider) and translates JSON envelopes
36
+ * on the stateless channel into typed method calls and events.
37
+ *
38
+ * Events emitted:
39
+ * - `message` → ChatMessage (new message broadcast)
40
+ * - `typing` → ChatTypingEvent (typing indicator broadcast)
41
+ * - `readReceipt` → ChatReadReceipt (mark_read broadcast)
42
+ */
43
+ export class ChatClient extends EventEmitter {
44
+ private readonly provider: ChatClientTransport;
45
+ private readonly responseTimeoutMs: number;
46
+
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.
50
+ private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
51
+
52
+ private readonly boundOnStateless: (data: { payload: string }) => void;
53
+
54
+ constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
55
+ super();
56
+ this.provider = provider;
57
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
58
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
59
+ this.provider.on("stateless", this.boundOnStateless);
60
+ }
61
+
62
+ /** Stop listening for chat messages. Does not disconnect the underlying provider. */
63
+ destroy(): void {
64
+ this.provider.off("stateless", this.boundOnStateless);
65
+ for (const queue of this.pending.values()) {
66
+ for (const p of queue) {
67
+ clearTimeout(p.timer);
68
+ p.reject(new Error("ChatClient destroyed"));
69
+ }
70
+ }
71
+ this.pending.clear();
72
+ this.removeAllListeners();
73
+ }
74
+
75
+ // ── Outgoing requests ──────────────────────────────────────────────────────
76
+
77
+ /** Send a chat message on a channel. Fire-and-forget (the server broadcasts). */
78
+ sendMessage(input: SendChatMessageInput): void {
79
+ this.provider.sendStateless(
80
+ JSON.stringify({
81
+ type: "chat:send",
82
+ channel: input.channel,
83
+ content: input.content,
84
+ ...(input.sender_name !== undefined ? { sender_name: input.sender_name } : {}),
85
+ }),
86
+ );
87
+ }
88
+
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");
92
+ this.provider.sendStateless(
93
+ JSON.stringify({
94
+ type: "chat:history",
95
+ channel: input.channel,
96
+ ...(input.before !== undefined ? { before: input.before } : {}),
97
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
98
+ }),
99
+ );
100
+ return promise;
101
+ }
102
+
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" }));
112
+ return promise;
113
+ }
114
+
115
+ /** Mark a channel read up to `timestamp` (unix ms). */
116
+ markRead(channel: string, timestamp: number): void {
117
+ this.provider.sendStateless(
118
+ JSON.stringify({ type: "chat:mark_read", channel, timestamp }),
119
+ );
120
+ }
121
+
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",
126
+ );
127
+ this.provider.sendStateless(JSON.stringify({ type: "chat:read_cursors", channel }));
128
+ return promise;
129
+ }
130
+
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
+ }
136
+
137
+ onTyping(fn: (e: ChatTypingEvent) => void): this {
138
+ return this.on("typing", fn) as this;
139
+ }
140
+
141
+ onReadReceipt(fn: (e: ChatReadReceipt) => void): this {
142
+ return this.on("readReceipt", fn) as this;
143
+ }
144
+
145
+ // ── Internals ─────────────────────────────────────────────────────────────
146
+
147
+ private enqueue<T>(type: string): Promise<T> {
148
+ return new Promise<T>((resolve, reject) => {
149
+ const timer = setTimeout(() => {
150
+ this.removePending(type, entry);
151
+ reject(new Error(`ChatClient: timeout waiting for ${type} response`));
152
+ }, this.responseTimeoutMs);
153
+ const entry: PendingResolver<T> = { resolve, reject, timer };
154
+ const queue = this.pending.get(type) ?? [];
155
+ queue.push(entry);
156
+ this.pending.set(type, queue);
157
+ });
158
+ }
159
+
160
+ private removePending(type: string, entry: PendingResolver<any>): void {
161
+ const queue = this.pending.get(type);
162
+ if (!queue) return;
163
+ const idx = queue.indexOf(entry);
164
+ if (idx >= 0) queue.splice(idx, 1);
165
+ if (queue.length === 0) this.pending.delete(type);
166
+ }
167
+
168
+ private resolveNext<T>(type: string, value: T): boolean {
169
+ const queue = this.pending.get(type);
170
+ if (!queue || queue.length === 0) return false;
171
+ const next = queue.shift()!;
172
+ if (queue.length === 0) this.pending.delete(type);
173
+ clearTimeout(next.timer);
174
+ next.resolve(value);
175
+ return true;
176
+ }
177
+
178
+ private handleStateless(payload: string): void {
179
+ let parsed: any;
180
+ try {
181
+ parsed = JSON.parse(payload);
182
+ } catch {
183
+ return;
184
+ }
185
+ 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;
232
+ }
233
+ }
234
+ }
@@ -626,6 +626,17 @@ export class IdentityDocProvider extends EventEmitter {
626
626
 
627
627
  // ── Lifecycle ────────────────────────────────────────────────────────────
628
628
 
629
+ /**
630
+ * Enable WebRTC P2P sync at runtime.
631
+ * Use this for claimed/passkey users where E2EE identity derivation
632
+ * was deferred to avoid biometric prompts on page load.
633
+ */
634
+ enableWebRTC(webrtcConfig: NonNullable<IdentityDocConfiguration["webrtc"]>): void {
635
+ if (this._destroyed || this.webrtc) return;
636
+ this.config = { ...this.config, webrtc: webrtcConfig };
637
+ this._connectWebRTC();
638
+ }
639
+
629
640
  /**
630
641
  * Update the sync server URL at runtime (e.g. when user changes their
631
642
  * designated sync server in settings).
@@ -0,0 +1,185 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+ import type {
3
+ CreateNotificationInput,
4
+ FetchNotificationsInput,
5
+ NotificationReadUpdate,
6
+ NotificationRecord,
7
+ } from "./types.ts";
8
+ import type { ChatClientTransport } from "./ChatClient.ts";
9
+
10
+ type PendingResolver<T> = {
11
+ resolve: (value: T) => void;
12
+ reject: (err: Error) => void;
13
+ timer: ReturnType<typeof setTimeout>;
14
+ };
15
+
16
+ const DEFAULT_TIMEOUT_MS = 10_000;
17
+
18
+ /**
19
+ * Typed client for the Abracadabra notifications feature.
20
+ *
21
+ * Emits:
22
+ * - `new` → NotificationRecord (incoming notify:new broadcast)
23
+ * - `readUpdate` → NotificationReadUpdate (broadcast after mark_read / mark_all_read)
24
+ */
25
+ export class NotificationsClient extends EventEmitter {
26
+ private readonly provider: ChatClientTransport;
27
+ private readonly responseTimeoutMs: number;
28
+ private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
29
+ private readonly boundOnStateless: (data: { payload: string }) => void;
30
+
31
+ constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
32
+ super();
33
+ this.provider = provider;
34
+ this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
35
+ this.boundOnStateless = (data) => this.handleStateless(data.payload);
36
+ this.provider.on("stateless", this.boundOnStateless);
37
+ }
38
+
39
+ destroy(): void {
40
+ this.provider.off("stateless", this.boundOnStateless);
41
+ for (const queue of this.pending.values()) {
42
+ for (const p of queue) {
43
+ clearTimeout(p.timer);
44
+ p.reject(new Error("NotificationsClient destroyed"));
45
+ }
46
+ }
47
+ this.pending.clear();
48
+ this.removeAllListeners();
49
+ }
50
+
51
+ // ── Outgoing requests ──────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Create a notification targeting a specific recipient. Requires elevated role
55
+ * (service or admin); a `server:error` event with code `forbidden` is emitted
56
+ * by the underlying provider if the caller lacks permission.
57
+ */
58
+ create(input: CreateNotificationInput): void {
59
+ this.provider.sendStateless(
60
+ JSON.stringify({
61
+ type: "notify:create",
62
+ recipient_id: input.recipient_id,
63
+ ...(input.notification_type !== undefined ? { notification_type: input.notification_type } : {}),
64
+ title: input.title,
65
+ ...(input.body !== undefined ? { body: input.body } : {}),
66
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
67
+ ...(input.link !== undefined ? { link: input.link } : {}),
68
+ ...(input.source_id !== undefined ? { source_id: input.source_id } : {}),
69
+ }),
70
+ );
71
+ }
72
+
73
+ /** Fetch notification history for the current user. */
74
+ fetch(input: FetchNotificationsInput = {}): Promise<{ notifications: NotificationRecord[] }> {
75
+ const promise = this.enqueue<{ notifications: NotificationRecord[] }>("notify:history");
76
+ this.provider.sendStateless(
77
+ JSON.stringify({
78
+ type: "notify:fetch",
79
+ ...(input.before !== undefined ? { before: input.before } : {}),
80
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
81
+ ...(input.unread_only !== undefined ? { unread_only: input.unread_only } : {}),
82
+ }),
83
+ );
84
+ return promise;
85
+ }
86
+
87
+ /** Mark a single notification, or a batch, as read. */
88
+ markRead(target: { id: string } | { ids: string[] }): void {
89
+ const body: Record<string, unknown> = { type: "notify:mark_read" };
90
+ if ("id" in target) body.id = target.id;
91
+ else body.ids = target.ids;
92
+ this.provider.sendStateless(JSON.stringify(body));
93
+ }
94
+
95
+ /** Mark every notification for the current user as read. */
96
+ markAllRead(): void {
97
+ this.provider.sendStateless(JSON.stringify({ type: "notify:mark_all_read" }));
98
+ }
99
+
100
+ /** Mark all notifications generated by the given source (e.g. a chat channel) as read. */
101
+ markReadBySource(sourceId: string): void {
102
+ this.provider.sendStateless(
103
+ JSON.stringify({ type: "notify:mark_read_by_source", source_id: sourceId }),
104
+ );
105
+ }
106
+
107
+ // ── Event helpers ─────────────────────────────────────────────────────────
108
+
109
+ onNew(fn: (n: NotificationRecord) => void): this {
110
+ return this.on("new", fn) as this;
111
+ }
112
+
113
+ onReadUpdate(fn: (u: NotificationReadUpdate) => void): this {
114
+ return this.on("readUpdate", fn) as this;
115
+ }
116
+
117
+ // ── Internals ─────────────────────────────────────────────────────────────
118
+
119
+ private enqueue<T>(type: string): Promise<T> {
120
+ return new Promise<T>((resolve, reject) => {
121
+ const timer = setTimeout(() => {
122
+ this.removePending(type, entry);
123
+ reject(new Error(`NotificationsClient: timeout waiting for ${type} response`));
124
+ }, this.responseTimeoutMs);
125
+ const entry: PendingResolver<T> = { resolve, reject, timer };
126
+ const queue = this.pending.get(type) ?? [];
127
+ queue.push(entry);
128
+ this.pending.set(type, queue);
129
+ });
130
+ }
131
+
132
+ private removePending(type: string, entry: PendingResolver<any>): void {
133
+ const queue = this.pending.get(type);
134
+ if (!queue) return;
135
+ const idx = queue.indexOf(entry);
136
+ if (idx >= 0) queue.splice(idx, 1);
137
+ if (queue.length === 0) this.pending.delete(type);
138
+ }
139
+
140
+ private resolveNext<T>(type: string, value: T): boolean {
141
+ const queue = this.pending.get(type);
142
+ if (!queue || queue.length === 0) return false;
143
+ const next = queue.shift()!;
144
+ if (queue.length === 0) this.pending.delete(type);
145
+ clearTimeout(next.timer);
146
+ next.resolve(value);
147
+ return true;
148
+ }
149
+
150
+ private handleStateless(payload: string): void {
151
+ let parsed: any;
152
+ try {
153
+ parsed = JSON.parse(payload);
154
+ } catch {
155
+ return;
156
+ }
157
+ const type: unknown = parsed?.type;
158
+ if (typeof type !== "string" || !type.startsWith("notify:")) return;
159
+
160
+ switch (type) {
161
+ case "notify:new": {
162
+ const { type: _t, ...rest } = parsed;
163
+ this.emit("new", rest as NotificationRecord);
164
+ break;
165
+ }
166
+ case "notify:history": {
167
+ this.resolveNext("notify:history", {
168
+ notifications: parsed.notifications ?? [],
169
+ });
170
+ break;
171
+ }
172
+ case "notify:read_update": {
173
+ const update: NotificationReadUpdate = {
174
+ recipient_id: parsed.recipient_id,
175
+ };
176
+ if (parsed.ids !== undefined) update.ids = parsed.ids;
177
+ if (parsed.all !== undefined) update.all = parsed.all;
178
+ this.emit("readUpdate", update);
179
+ break;
180
+ }
181
+ default:
182
+ break;
183
+ }
184
+ }
185
+ }
@@ -8,47 +8,78 @@
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
- * Limitation: at least one client must have the child doc open after an edit
12
- * for the timestamp to propagate (eventually consistent).
11
+ * A trailing-edge throttle (default 5 s) limits writes to avoid CRDT bloat
12
+ * on the root doc during rapid typing.
13
13
  */
14
14
 
15
15
  import * as Y from "yjs";
16
16
  import type { OfflineStore } from "./OfflineStore.ts";
17
17
 
18
18
  /**
19
- * Attach an observer that writes `updatedAt: Date.now()` to the root
20
- * doc-tree entry for `childDocId` whenever the child doc receives a
21
- * non-offline update.
19
+ * Attach an observer that writes `updatedAt` to the root doc-tree entry for
20
+ * `childDocId` whenever the child doc receives a non-offline update.
22
21
  *
23
- * @param treeMap The root doc's "doc-tree" Y.Map.
24
- * @param childDocId The child document's UUID (key in treeMap).
25
- * @param childDoc The child Y.Doc to observe.
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
+ * @param treeMap The root doc's "doc-tree" Y.Map.
26
+ * @param childDocId The child document's UUID (key in treeMap).
27
+ * @param childDoc The child Y.Doc to observe.
26
28
  * @param offlineStore The child provider's OfflineStore (used to detect
27
29
  * offline-replay origins and skip them). Pass null when
28
30
  * the offline store is disabled.
29
- * @returns Cleanup function call on provider destroy.
31
+ * @param options Optional config. `throttleMs` controls the write
32
+ * interval (default 5000).
33
+ * @returns Cleanup function — call on provider destroy. Flushes
34
+ * any pending write before detaching.
30
35
  */
31
36
  export function attachUpdatedAtObserver(
32
37
  treeMap: Y.Map<any>,
33
38
  childDocId: string,
34
39
  childDoc: Y.Doc,
35
40
  offlineStore: OfflineStore | null,
41
+ options?: { throttleMs?: number },
36
42
  ): () => void {
37
- function handler(update: Uint8Array, origin: unknown): void {
38
- // Skip updates replayed from the local offline store — they represent
39
- // content that was already "seen" and shouldn't advance updatedAt.
40
- if (offlineStore !== null && origin === offlineStore) return;
43
+ const throttleMs = options?.throttleMs ?? 5000;
44
+
45
+ let latestTs = 0;
46
+ let timer: ReturnType<typeof setTimeout> | null = null;
47
+
48
+ function flush(): void {
49
+ if (latestTs === 0) return;
50
+ const ts = latestTs;
51
+ latestTs = 0;
52
+ timer = null;
41
53
 
42
- // Update the root tree entry (no-op if the entry doesn't exist).
43
54
  const raw = treeMap.get(childDocId);
44
55
  if (!raw) return;
45
56
 
46
57
  // Guard: if the entry is a nested Y.Map (possible after Yrs
47
58
  // document compaction), convert to plain object so spread works.
48
59
  const entry = raw instanceof Y.Map ? (raw as any).toJSON() : raw;
49
- treeMap.set(childDocId, { ...entry, updatedAt: Date.now() });
60
+ treeMap.set(childDocId, { ...entry, updatedAt: ts });
61
+ }
62
+
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
+ if (offlineStore !== null && origin === offlineStore) return;
67
+
68
+ latestTs = Date.now();
69
+
70
+ // Schedule a trailing-edge flush if none is pending.
71
+ if (timer === null) {
72
+ timer = setTimeout(flush, throttleMs);
73
+ }
50
74
  }
51
75
 
52
76
  childDoc.on("update", handler);
53
- return () => childDoc.off("update", handler);
77
+
78
+ return () => {
79
+ childDoc.off("update", handler);
80
+ if (timer !== null) {
81
+ clearTimeout(timer);
82
+ flush(); // persist the last pending timestamp
83
+ }
84
+ };
54
85
  }
package/src/index.ts CHANGED
@@ -36,6 +36,22 @@ export type {
36
36
  DeviceTier,
37
37
  } from "./IdentityDoc.ts";
38
38
  export { DeviceRegistrationService } from "./DeviceRegistrationService.ts";
39
+ export { ChatClient } from "./ChatClient.ts";
40
+ export type { ChatClientTransport } from "./ChatClient.ts";
41
+ export { NotificationsClient } from "./NotificationsClient.ts";
42
+ export type {
43
+ ChatMessage,
44
+ ChatChannel,
45
+ ChatTypingEvent,
46
+ ChatReadReceipt,
47
+ ChatReadCursor,
48
+ SendChatMessageInput,
49
+ GetChatHistoryInput,
50
+ NotificationRecord,
51
+ NotificationReadUpdate,
52
+ CreateNotificationInput,
53
+ FetchNotificationsInput,
54
+ } from "./types.ts";
39
55
  export {
40
56
  generateMnemonic,
41
57
  validateMnemonic,
package/src/types.ts CHANGED
@@ -124,6 +124,12 @@ export type onStatelessParameters = {
124
124
  payload: string;
125
125
  };
126
126
 
127
+ export type onServerErrorParameters = {
128
+ source: string;
129
+ code: string;
130
+ message: string;
131
+ };
132
+
127
133
  export type StatesArray = { clientId: number; [key: string | number]: any }[];
128
134
 
129
135
  // ── Abracadabra extensions ────────────────────────────────────────────────────
@@ -342,3 +348,89 @@ export interface DocEncryptionInfo {
342
348
  effective_mode: "none" | "cse" | "e2e";
343
349
  inherited_from?: string;
344
350
  }
351
+
352
+ // ── Chat (stateless channel) ─────────────────────────────────────────────────
353
+
354
+ export interface ChatMessage {
355
+ id: string;
356
+ channel: string;
357
+ sender_id: string;
358
+ sender_name?: string | null;
359
+ content: string;
360
+ created_at: number;
361
+ }
362
+
363
+ export interface ChatChannel {
364
+ channel: string;
365
+ label?: string | null;
366
+ last_message: ChatMessage;
367
+ unread_count: number;
368
+ }
369
+
370
+ export interface ChatTypingEvent {
371
+ channel: string;
372
+ sender_id: string;
373
+ sender_name?: string | null;
374
+ }
375
+
376
+ export interface ChatReadReceipt {
377
+ channel: string;
378
+ user_id: string;
379
+ last_read_at: number;
380
+ }
381
+
382
+ export interface ChatReadCursor {
383
+ user_id: string;
384
+ last_read_at: number;
385
+ }
386
+
387
+ export interface SendChatMessageInput {
388
+ channel: string;
389
+ content: string;
390
+ sender_name?: string;
391
+ }
392
+
393
+ export interface GetChatHistoryInput {
394
+ channel: string;
395
+ before?: number;
396
+ limit?: number;
397
+ }
398
+
399
+ // ── Notifications (stateless channel) ────────────────────────────────────────
400
+
401
+ export interface NotificationRecord {
402
+ id: string;
403
+ recipient_id: string;
404
+ notification_type: string;
405
+ title: string;
406
+ body: string;
407
+ icon?: string | null;
408
+ link?: string | null;
409
+ source_id?: string | null;
410
+ read: boolean;
411
+ created_at: number;
412
+ }
413
+
414
+ export interface NotificationReadUpdate {
415
+ /** Present when a specific set of notifications was marked read. */
416
+ ids?: string[];
417
+ /** Present when mark_all_read was called. */
418
+ all?: boolean;
419
+ recipient_id: string;
420
+ }
421
+
422
+ export interface CreateNotificationInput {
423
+ recipient_id: string;
424
+ notification_type?: string;
425
+ title: string;
426
+ body?: string;
427
+ icon?: string;
428
+ link?: string;
429
+ source_id?: string;
430
+ }
431
+
432
+ export interface FetchNotificationsInput {
433
+ before?: number;
434
+ limit?: number;
435
+ unread_only?: boolean;
436
+ }