@ai-matrx/messaging 0.0.0 → 0.1.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/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +287 -2
- package/dist/index.cjs +1880 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +995 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +1862 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +2990 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +1273 -0
- package/dist/react.d.ts +1273 -0
- package/dist/react.js +2980 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.css +745 -0
- package/dist/tokens.css +119 -0
- package/package.json +100 -7
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
import { MatrxTransport } from '@ai-matrx/agents/matrx';
|
|
2
|
+
import { RealtimeManager } from '@ai-matrx/realtime';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* THE MESSAGING DOMAIN TYPES.
|
|
6
|
+
*
|
|
7
|
+
* Ported from `matrx-frontend/features/messaging/types.ts` with the coupling
|
|
8
|
+
* seams inverted: no imported host types, no app singletons. The DB-shaped
|
|
9
|
+
* types mirror `communication.dm_*` exactly (snake_case, nullable where the
|
|
10
|
+
* column is nullable) so a row read from PostgREST IS one of these without a
|
|
11
|
+
* mapping step that can silently drop a column.
|
|
12
|
+
*
|
|
13
|
+
* Strictness posture (C22): identity-bearing values are BRANDED, invalid states
|
|
14
|
+
* are unrepresentable (a `sending` message has a `clientMessageId`; a `failed`
|
|
15
|
+
* one carries a reason), and no surface here is `any`. The one honest `unknown`
|
|
16
|
+
* is the JSON boundary — `metadata` and `action_data` are host/DB-generated
|
|
17
|
+
* shapes this package does not own (the data 0.2.1 `Json = unknown` lesson).
|
|
18
|
+
*/
|
|
19
|
+
/** Nominal id types. A conversation id can never be passed where a user id goes. */
|
|
20
|
+
declare const brand: unique symbol;
|
|
21
|
+
type Brand<T, B extends string> = T & {
|
|
22
|
+
readonly [brand]: B;
|
|
23
|
+
};
|
|
24
|
+
type ConversationId = Brand<string, "ConversationId">;
|
|
25
|
+
type MessageId = Brand<string, "MessageId">;
|
|
26
|
+
type UserId = Brand<string, "UserId">;
|
|
27
|
+
type OrganizationId = Brand<string, "OrganizationId">;
|
|
28
|
+
/** The client-minted idempotency key that makes a send exactly-once end to end. */
|
|
29
|
+
type ClientMessageId = Brand<string, "ClientMessageId">;
|
|
30
|
+
declare const asConversationId: (value: string) => ConversationId;
|
|
31
|
+
declare const asMessageId: (value: string) => MessageId;
|
|
32
|
+
declare const asUserId: (value: string) => UserId;
|
|
33
|
+
declare const asOrganizationId: (value: string) => OrganizationId;
|
|
34
|
+
declare const asClientMessageId: (value: string) => ClientMessageId;
|
|
35
|
+
type JsonValue = unknown;
|
|
36
|
+
type JsonObject = Readonly<Record<string, JsonValue>>;
|
|
37
|
+
type ConversationType = "direct" | "group" | "org";
|
|
38
|
+
type ParticipantRole = "owner" | "admin" | "member";
|
|
39
|
+
type MessageKind = "text" | "image" | "video" | "audio" | "file" | "system" | "action";
|
|
40
|
+
/**
|
|
41
|
+
* The delivery ladder. `sending` and `failed` exist only client-side: they are
|
|
42
|
+
* the outbox's states, and the DB's `status` column never holds them.
|
|
43
|
+
*/
|
|
44
|
+
type DeliveryState = "sending" | "sent" | "delivered" | "read" | "failed";
|
|
45
|
+
interface Conversation {
|
|
46
|
+
readonly id: ConversationId;
|
|
47
|
+
readonly type: ConversationType;
|
|
48
|
+
readonly groupName: string | null;
|
|
49
|
+
readonly groupImageUrl: string | null;
|
|
50
|
+
readonly createdBy: UserId | null;
|
|
51
|
+
readonly organizationId: OrganizationId;
|
|
52
|
+
readonly createdAt: string;
|
|
53
|
+
readonly updatedAt: string;
|
|
54
|
+
readonly metadata: JsonObject;
|
|
55
|
+
}
|
|
56
|
+
interface Participant {
|
|
57
|
+
readonly conversationId: ConversationId;
|
|
58
|
+
readonly userId: UserId;
|
|
59
|
+
readonly role: ParticipantRole;
|
|
60
|
+
readonly joinedAt: string | null;
|
|
61
|
+
readonly lastReadAt: string | null;
|
|
62
|
+
readonly isMuted: boolean;
|
|
63
|
+
readonly isArchived: boolean;
|
|
64
|
+
}
|
|
65
|
+
interface UserSummary {
|
|
66
|
+
readonly userId: UserId;
|
|
67
|
+
readonly displayName: string;
|
|
68
|
+
readonly email: string | null;
|
|
69
|
+
readonly avatarUrl: string | null;
|
|
70
|
+
/** True when this participant is an AI agent rather than a person (R4). */
|
|
71
|
+
readonly isAgent: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A structured action carried by a message. THE SEAM other packages extend —
|
|
75
|
+
* `@ai-matrx/meet` puts a call invitation here rather than inventing a message
|
|
76
|
+
* type (D1).
|
|
77
|
+
*
|
|
78
|
+
* `version` is not decoration: a renderer that does not understand a version
|
|
79
|
+
* renders NOTHING rather than guessing, which is what lets a new sender ship
|
|
80
|
+
* before every reader has caught up.
|
|
81
|
+
*/
|
|
82
|
+
interface MessageAction<TKind extends string = string, TPayload = JsonObject> {
|
|
83
|
+
readonly kind: TKind;
|
|
84
|
+
readonly version: number;
|
|
85
|
+
readonly payload: TPayload;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A typed reference to a platform entity, rendered as a live openable card.
|
|
89
|
+
* NO DEAD ENDS: everything a message names must open, so a reference always
|
|
90
|
+
* carries enough to resolve — never a bare label.
|
|
91
|
+
*/
|
|
92
|
+
interface MatrxReference {
|
|
93
|
+
readonly entityType: string;
|
|
94
|
+
readonly entityId: string;
|
|
95
|
+
readonly label: string;
|
|
96
|
+
readonly href?: string | undefined;
|
|
97
|
+
}
|
|
98
|
+
interface Attachment {
|
|
99
|
+
/** A DURABLE file ref, never a signed URL. Signed URLs expire; identities do not. */
|
|
100
|
+
readonly fileId: string;
|
|
101
|
+
readonly fileName: string;
|
|
102
|
+
readonly mimeType: string | null;
|
|
103
|
+
readonly sizeBytes: number | null;
|
|
104
|
+
readonly width: number | null;
|
|
105
|
+
readonly height: number | null;
|
|
106
|
+
}
|
|
107
|
+
interface Message {
|
|
108
|
+
readonly id: MessageId;
|
|
109
|
+
readonly conversationId: ConversationId;
|
|
110
|
+
readonly senderId: UserId;
|
|
111
|
+
readonly organizationId: OrganizationId;
|
|
112
|
+
readonly content: string;
|
|
113
|
+
readonly kind: MessageKind;
|
|
114
|
+
readonly deliveryState: DeliveryState;
|
|
115
|
+
readonly replyToId: MessageId | null;
|
|
116
|
+
readonly clientMessageId: ClientMessageId | null;
|
|
117
|
+
readonly createdAt: string;
|
|
118
|
+
readonly editedAt: string | null;
|
|
119
|
+
readonly deletedAt: string | null;
|
|
120
|
+
readonly deletedForEveryone: boolean;
|
|
121
|
+
readonly action: MessageAction | null;
|
|
122
|
+
readonly attachments: readonly Attachment[];
|
|
123
|
+
readonly references: readonly MatrxReference[];
|
|
124
|
+
readonly metadata: JsonObject;
|
|
125
|
+
/** Set only while this message is in the outbox and its last send failed. */
|
|
126
|
+
readonly failureReason?: string | undefined;
|
|
127
|
+
}
|
|
128
|
+
interface ConversationSummary {
|
|
129
|
+
readonly conversation: Conversation;
|
|
130
|
+
readonly participants: readonly UserSummary[];
|
|
131
|
+
readonly lastMessageContent: string | null;
|
|
132
|
+
readonly lastMessageSenderId: UserId | null;
|
|
133
|
+
readonly lastMessageAt: string | null;
|
|
134
|
+
readonly unreadCount: number;
|
|
135
|
+
readonly isMuted: boolean;
|
|
136
|
+
readonly isArchived: boolean;
|
|
137
|
+
/** Resolved for display: the group name, or the other participant's name. */
|
|
138
|
+
readonly displayName: string;
|
|
139
|
+
readonly displayImageUrl: string | null;
|
|
140
|
+
/** The keyset sort value. Pagination cursors are built from this + `id`. */
|
|
141
|
+
readonly sortAt: string;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* THE PAGINATION CURSOR. Stable ordering is terminated by a UNIQUE column
|
|
145
|
+
* (R5 scale honesty): ordering by a timestamp alone silently duplicates or
|
|
146
|
+
* skips rows whenever two rows share a millisecond, which in a large org is
|
|
147
|
+
* every page.
|
|
148
|
+
*/
|
|
149
|
+
interface ConversationCursor {
|
|
150
|
+
readonly beforeSortAt: string;
|
|
151
|
+
readonly beforeConversationId: ConversationId;
|
|
152
|
+
}
|
|
153
|
+
interface MessageCursor {
|
|
154
|
+
readonly beforeCreatedAt: string;
|
|
155
|
+
readonly beforeMessageId: MessageId;
|
|
156
|
+
}
|
|
157
|
+
interface Page<TItem, TCursor> {
|
|
158
|
+
readonly items: readonly TItem[];
|
|
159
|
+
readonly nextCursor: TCursor | null;
|
|
160
|
+
readonly hasMore: boolean;
|
|
161
|
+
}
|
|
162
|
+
/** What the host injects. Identity ONLY — every hard part is in the package. */
|
|
163
|
+
interface MessagingIdentity {
|
|
164
|
+
readonly userId: UserId;
|
|
165
|
+
readonly organizationId: OrganizationId;
|
|
166
|
+
}
|
|
167
|
+
interface DraftMessage {
|
|
168
|
+
readonly conversationId: ConversationId;
|
|
169
|
+
readonly content: string;
|
|
170
|
+
readonly kind?: MessageKind | undefined;
|
|
171
|
+
readonly replyToId?: MessageId | undefined;
|
|
172
|
+
readonly action?: MessageAction | undefined;
|
|
173
|
+
readonly attachments?: readonly Attachment[] | undefined;
|
|
174
|
+
readonly references?: readonly MatrxReference[] | undefined;
|
|
175
|
+
readonly metadata?: JsonObject | undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* ACTIONABLE MESSAGES — a message that carries something you can DO.
|
|
180
|
+
*
|
|
181
|
+
* A registry, never a `switch`. Two properties come from that choice, and both
|
|
182
|
+
* are load-bearing:
|
|
183
|
+
*
|
|
184
|
+
* - **Forward compatibility.** An unknown `kind` — or a known kind at a version
|
|
185
|
+
* this build does not understand — renders NOTHING and executes nothing. A
|
|
186
|
+
* `switch` with a `default: throw` means the day a new sender ships, every
|
|
187
|
+
* older reader's thread breaks. Silently rendering an unknown payload with a
|
|
188
|
+
* generic button is worse: it offers an action nobody can honor.
|
|
189
|
+
* - **Extensibility across packages.** `@ai-matrx/meet` registers its call
|
|
190
|
+
* invitation here rather than this package learning about meetings (D1).
|
|
191
|
+
*
|
|
192
|
+
* 🚨 **IDEMPOTENCE IS ENFORCED HERE, NOT PROMISED BY HANDLERS.** Two tabs, a
|
|
193
|
+
* double-click, and a retried tap must apply an action ONCE. The registry
|
|
194
|
+
* de-duplicates by `(kind, messageId, actorId)`: a second execution while the
|
|
195
|
+
* first is in flight AWAITS it and returns the same receipt, and a settled
|
|
196
|
+
* action returns its stored receipt without touching the server.
|
|
197
|
+
*
|
|
198
|
+
* 🚨 **THE PAYLOAD IS NOT AUTHORIZATION.** A handler must re-resolve the
|
|
199
|
+
* durable, caller-authorized request row server-side before it writes. The
|
|
200
|
+
* message's `action_data` says what was ASKED, never what is ALLOWED — trusting
|
|
201
|
+
* it turns any message into a privilege grant. Handlers declare this by
|
|
202
|
+
* construction: they receive the payload and the actor, and must go to the
|
|
203
|
+
* database themselves.
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
type ActionOutcome = "applied" | "already" | "declined" | "unavailable";
|
|
207
|
+
interface ActionReceipt {
|
|
208
|
+
readonly kind: string;
|
|
209
|
+
readonly messageId: MessageId;
|
|
210
|
+
readonly actorId: UserId;
|
|
211
|
+
readonly outcome: ActionOutcome;
|
|
212
|
+
readonly label: string;
|
|
213
|
+
readonly settledAt: string;
|
|
214
|
+
readonly detail?: string | undefined;
|
|
215
|
+
}
|
|
216
|
+
interface ActionContext {
|
|
217
|
+
readonly messageId: MessageId;
|
|
218
|
+
readonly actorId: UserId;
|
|
219
|
+
readonly organizationId: string;
|
|
220
|
+
/** Which of the choices the user picked, e.g. `approve` / `decline`. */
|
|
221
|
+
readonly choice: string;
|
|
222
|
+
}
|
|
223
|
+
interface ActionChoice {
|
|
224
|
+
readonly id: string;
|
|
225
|
+
readonly label: string;
|
|
226
|
+
readonly tone: "primary" | "neutral" | "danger";
|
|
227
|
+
}
|
|
228
|
+
interface ActionHandler<TPayload = JsonObject> {
|
|
229
|
+
readonly kind: string;
|
|
230
|
+
/** Versions this build understands. An unlisted version renders nothing. */
|
|
231
|
+
readonly versions: readonly number[];
|
|
232
|
+
/** What the chips say. Return `[]` to render the action as read-only. */
|
|
233
|
+
choices: (payload: TPayload) => readonly ActionChoice[];
|
|
234
|
+
/** One-line summary for the inbox preview and notifications. */
|
|
235
|
+
summarize?: (payload: TPayload) => string;
|
|
236
|
+
/**
|
|
237
|
+
* Do the thing. MUST re-resolve the authorizing row server-side; the payload
|
|
238
|
+
* is the request, not the permission. Returning `already` is how a handler
|
|
239
|
+
* reports that the server had settled it — the registry treats it as success.
|
|
240
|
+
*/
|
|
241
|
+
execute: (payload: TPayload, context: ActionContext) => Promise<ActionReceipt>;
|
|
242
|
+
}
|
|
243
|
+
interface ActionRegistry {
|
|
244
|
+
register<TPayload = JsonObject>(handler: ActionHandler<TPayload>): void;
|
|
245
|
+
/** The handler for an action, or null when this build cannot honor it. */
|
|
246
|
+
resolve(action: MessageAction): ActionHandler | null;
|
|
247
|
+
choicesFor(action: MessageAction): readonly ActionChoice[];
|
|
248
|
+
summarize(action: MessageAction): string | null;
|
|
249
|
+
/** Idempotent execution. Concurrent callers share one in-flight result. */
|
|
250
|
+
execute(action: MessageAction, context: ActionContext): Promise<ActionReceipt>;
|
|
251
|
+
/** The stored receipt for a settled action, if there is one. */
|
|
252
|
+
receiptFor(kind: string, messageId: MessageId, actorId: UserId): ActionReceipt | null;
|
|
253
|
+
/** Record a receipt observed from another client's broadcast. */
|
|
254
|
+
observeReceipt(receipt: ActionReceipt): void;
|
|
255
|
+
known(): readonly string[];
|
|
256
|
+
}
|
|
257
|
+
declare function createActionRegistry(): ActionRegistry;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* EFFECTIVE-ACTOR RESOLUTION — who a message is FROM, versus who wrote the row.
|
|
261
|
+
*
|
|
262
|
+
* `sender_id` is the AUDIT PRINCIPAL: the session the write happened under. It
|
|
263
|
+
* must never change, and it must never be what the UI renders when an agent
|
|
264
|
+
* acted through a human's session. Otherwise an automated message wears a
|
|
265
|
+
* colleague's face and name — the failure this module exists to prevent (R4
|
|
266
|
+
* puts agents in conversations, which makes it the normal case, not an edge).
|
|
267
|
+
*
|
|
268
|
+
* The effective actor is declared in the message's own metadata by whoever sent
|
|
269
|
+
* it. Nothing here infers an agent from heuristics on the content.
|
|
270
|
+
*/
|
|
271
|
+
|
|
272
|
+
interface ActorPresentation {
|
|
273
|
+
readonly displayName: string;
|
|
274
|
+
readonly avatarUrl: string | null;
|
|
275
|
+
/** True when the message was authored by an agent, not the human principal. */
|
|
276
|
+
readonly isAgent: boolean;
|
|
277
|
+
/** Set only for an agent: the human whose session it acted under. */
|
|
278
|
+
readonly onBehalfOfName: string | null;
|
|
279
|
+
/** The audit principal. Always the row's `sender_id`. Never rewritten. */
|
|
280
|
+
readonly principalUserId: Message["senderId"];
|
|
281
|
+
}
|
|
282
|
+
declare function resolveActor(message: Message, sender: UserSummary | null): ActorPresentation;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* AI-NATIVE, OUT OF THE BOX (R4).
|
|
286
|
+
*
|
|
287
|
+
* Four conversation intelligences — catch me up, summarize, extract action
|
|
288
|
+
* items, draft a reply — plus agents as participants. All of it executes on the
|
|
289
|
+
* platform's agent system through `@ai-matrx/agents`; this package never talks
|
|
290
|
+
* to a model, never holds a prompt, and never carries an agent definition.
|
|
291
|
+
*
|
|
292
|
+
* 🚨 **THE USER-INPUT LAW.** `user_input` is what a HUMAN typed. Machine
|
|
293
|
+
* content — a transcript, a participant roster, a cutoff timestamp — travels as
|
|
294
|
+
* NAMED VARIABLES. This is not style: the server treats `user_input` as the
|
|
295
|
+
* turn's human utterance (it is stored as such, shown as such, and shapes the
|
|
296
|
+
* agent's framing), so smuggling a transcript through it silently corrupts
|
|
297
|
+
* every conversation it touches. Every call below sends `variables` and either
|
|
298
|
+
* a short human-shaped `user_input` or none.
|
|
299
|
+
*
|
|
300
|
+
* 🚨 **AGENT DEFINITIONS LIVE IN THE DATABASE.** The package takes agent IDs as
|
|
301
|
+
* injected identity. When an id is not configured the capability reports
|
|
302
|
+
* `unavailable` WITH the remedy — it never silently no-ops, and the UI hides
|
|
303
|
+
* the action rather than offering a dead button (no dead ends).
|
|
304
|
+
*/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Which platform agent backs each capability. Every field is a DATABASE row id
|
|
308
|
+
* supplied by the host; an omitted one disables exactly that capability.
|
|
309
|
+
*/
|
|
310
|
+
interface MessagingAgents {
|
|
311
|
+
readonly catchUp?: string | undefined;
|
|
312
|
+
readonly summarize?: string | undefined;
|
|
313
|
+
readonly actionItems?: string | undefined;
|
|
314
|
+
readonly draftReply?: string | undefined;
|
|
315
|
+
}
|
|
316
|
+
interface MessagingAiOptions {
|
|
317
|
+
transport: MatrxTransport;
|
|
318
|
+
organizationId: string;
|
|
319
|
+
agents: MessagingAgents;
|
|
320
|
+
/** Stable source slugs so server-side analytics can see where a run came from. */
|
|
321
|
+
sourceApp?: string | undefined;
|
|
322
|
+
sourceFeature?: string | undefined;
|
|
323
|
+
/** Cap on how much transcript is sent. Default 200 messages. */
|
|
324
|
+
maxTranscriptMessages?: number | undefined;
|
|
325
|
+
}
|
|
326
|
+
type AiCapability = keyof MessagingAgents;
|
|
327
|
+
interface AiResult {
|
|
328
|
+
readonly capability: AiCapability;
|
|
329
|
+
readonly text: string;
|
|
330
|
+
readonly conversationId: string | null;
|
|
331
|
+
}
|
|
332
|
+
interface MessagingAi {
|
|
333
|
+
/** Which capabilities this host actually configured. Drives what the UI offers. */
|
|
334
|
+
available(): readonly AiCapability[];
|
|
335
|
+
isAvailable(capability: AiCapability): boolean;
|
|
336
|
+
catchMeUp(args: {
|
|
337
|
+
conversationId: ConversationId;
|
|
338
|
+
messages: readonly Message[];
|
|
339
|
+
participants: readonly UserSummary[];
|
|
340
|
+
since: string | null;
|
|
341
|
+
signal?: AbortSignal;
|
|
342
|
+
}): Promise<AiResult>;
|
|
343
|
+
summarize(args: {
|
|
344
|
+
conversationId: ConversationId;
|
|
345
|
+
messages: readonly Message[];
|
|
346
|
+
participants: readonly UserSummary[];
|
|
347
|
+
signal?: AbortSignal;
|
|
348
|
+
}): Promise<AiResult>;
|
|
349
|
+
extractActionItems(args: {
|
|
350
|
+
conversationId: ConversationId;
|
|
351
|
+
messages: readonly Message[];
|
|
352
|
+
participants: readonly UserSummary[];
|
|
353
|
+
signal?: AbortSignal;
|
|
354
|
+
}): Promise<AiResult>;
|
|
355
|
+
draftReply(args: {
|
|
356
|
+
conversationId: ConversationId;
|
|
357
|
+
messages: readonly Message[];
|
|
358
|
+
participants: readonly UserSummary[];
|
|
359
|
+
/** What the human asked for, if anything — this IS a human utterance. */
|
|
360
|
+
instruction?: string | undefined;
|
|
361
|
+
signal?: AbortSignal;
|
|
362
|
+
}): Promise<AiResult>;
|
|
363
|
+
}
|
|
364
|
+
declare function createMessagingAi(options: MessagingAiOptions): MessagingAi;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* IN-FLIGHT DEDUP + TTL CACHE — the cure for the request storm.
|
|
368
|
+
*
|
|
369
|
+
* The incident (matrx-frontend, 2026-08-21): every conversation row rendered
|
|
370
|
+
* called the profile lookup for its participants, every one of those calls hit
|
|
371
|
+
* the network independently, the transport wobbled, and the app captured
|
|
372
|
+
* **909 errors in 0.6 seconds**. The bug was never the transport — it was that
|
|
373
|
+
* N identical concurrent reads were N requests.
|
|
374
|
+
*
|
|
375
|
+
* Two guarantees, and both are load-bearing:
|
|
376
|
+
*
|
|
377
|
+
* 1. **Concurrent callers for the same key share ONE promise.** The second
|
|
378
|
+
* caller does not start a second request; it awaits the first.
|
|
379
|
+
* 2. **A failure does not poison the cache.** The rejected promise is evicted
|
|
380
|
+
* the moment it settles, so the next caller retries instead of inheriting a
|
|
381
|
+
* permanent error. (The naive version caches the rejection and the surface
|
|
382
|
+
* stays broken until reload — the reason this has a named test.)
|
|
383
|
+
*
|
|
384
|
+
* A resolved value is held for `ttlMs` and then re-read. This is a READ cache
|
|
385
|
+
* only: writes go straight through and invalidate.
|
|
386
|
+
*/
|
|
387
|
+
interface ReadCacheOptions {
|
|
388
|
+
/** How long a resolved value is served without a re-read. */
|
|
389
|
+
ttlMs: number;
|
|
390
|
+
/** Injected clock — the tests must not sleep. */
|
|
391
|
+
now?: () => number;
|
|
392
|
+
/** Bound on retained entries; the oldest is evicted first. */
|
|
393
|
+
maxEntries?: number;
|
|
394
|
+
}
|
|
395
|
+
interface ReadCache<T> {
|
|
396
|
+
/** Read through the cache. Identical concurrent keys share one call. */
|
|
397
|
+
read(key: string, load: () => Promise<T>): Promise<T>;
|
|
398
|
+
/** Drop one key (after a write that changes it). */
|
|
399
|
+
invalidate(key: string): void;
|
|
400
|
+
/** Drop everything (sign-out, org switch). */
|
|
401
|
+
clear(): void;
|
|
402
|
+
/** Diagnostics: how many resolved entries are held. */
|
|
403
|
+
size(): number;
|
|
404
|
+
}
|
|
405
|
+
declare function createReadCache<T>(options: ReadCacheOptions): ReadCache<T>;
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* THE MESSAGING CHANNEL NAMESPACES.
|
|
409
|
+
*
|
|
410
|
+
* D5: zero hand-rolled `.channel(` in this package. Every topic is declared
|
|
411
|
+
* through `@ai-matrx/realtime`'s registry, which refuses a colliding
|
|
412
|
+
* re-declaration and hands every connection attempt a unique instance topic.
|
|
413
|
+
*
|
|
414
|
+
* TWO channels, deliberately — not four. The origin ran `messages:`, `typing:`,
|
|
415
|
+
* `presence:` and a global list channel as four independent subscriptions, and
|
|
416
|
+
* three of the four hardening items were about them fighting each other. Here:
|
|
417
|
+
*
|
|
418
|
+
* - `messaging-inbox` (per user): the conversation list's own feed.
|
|
419
|
+
* - `messaging-conversation` (per conversation): messages, presence, AND
|
|
420
|
+
* typing on ONE channel, because they are one room. Sharing the channel is
|
|
421
|
+
* what makes "the person typing is also present" true by construction
|
|
422
|
+
* instead of by two subscriptions agreeing.
|
|
423
|
+
*
|
|
424
|
+
* `defineChannelNamespace` throws on a conflicting re-declaration, so these are
|
|
425
|
+
* created lazily through a `globalThis` slot: with dual ESM/CJS output the
|
|
426
|
+
* module can be evaluated twice in one process, and a second identical
|
|
427
|
+
* declaration must be a no-op rather than a crash.
|
|
428
|
+
*/
|
|
429
|
+
|
|
430
|
+
declare function inboxTopic(userId: UserId): string;
|
|
431
|
+
declare function conversationTopic(conversationId: ConversationId): string;
|
|
432
|
+
/** Broadcast event names. One place, so a sender and a receiver cannot drift. */
|
|
433
|
+
declare const MESSAGING_EVENTS: {
|
|
434
|
+
/** A freshly sent message, broadcast beside the Postgres Changes row so a
|
|
435
|
+
* receiver gets it on whichever path arrives first (both are deduped). */
|
|
436
|
+
readonly message: "mx.message";
|
|
437
|
+
/** A message edited or soft-deleted. */
|
|
438
|
+
readonly messageUpdated: "mx.message.updated";
|
|
439
|
+
/** An action receipt, so every viewer's chip settles at once. */
|
|
440
|
+
readonly actionSettled: "mx.action.settled";
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* THE OUTBOX — a message you typed is NEVER lost (R2, table-stakes law).
|
|
445
|
+
*
|
|
446
|
+
* This is the piece hosts always get wrong, so it is entirely in the package
|
|
447
|
+
* (C22). What "never lost" actually requires, and what each part defends:
|
|
448
|
+
*
|
|
449
|
+
* - **Durability across a reload.** The draft is persisted BEFORE the network
|
|
450
|
+
* call, not after it succeeds. A tab closed mid-send comes back with the
|
|
451
|
+
* message still queued. The storage port has a working default; a host that
|
|
452
|
+
* injects nothing still gets in-memory queuing, and it SAYS so rather than
|
|
453
|
+
* pretending to be durable.
|
|
454
|
+
* - **Exactly-once on retry.** Every entry carries a client-minted
|
|
455
|
+
* `clientMessageId` that is generated ONCE and reused by every attempt. A
|
|
456
|
+
* retry after a response that was actually delivered is deduped by the
|
|
457
|
+
* database's idempotency key instead of posting the message twice.
|
|
458
|
+
* - **Order.** Entries for one conversation send strictly in order; message 2
|
|
459
|
+
* never overtakes a retrying message 1.
|
|
460
|
+
* - **Bounded, honest failure.** Attempts back off; after `maxAttempts` the
|
|
461
|
+
* entry stays in the queue marked `failed` WITH a reason and a retry door.
|
|
462
|
+
* It is never silently dropped, and it never retries forever.
|
|
463
|
+
*/
|
|
464
|
+
|
|
465
|
+
interface OutboxEntry {
|
|
466
|
+
readonly id: string;
|
|
467
|
+
readonly clientMessageId: ClientMessageId;
|
|
468
|
+
readonly draft: DraftMessage;
|
|
469
|
+
readonly queuedAt: number;
|
|
470
|
+
readonly attempts: number;
|
|
471
|
+
readonly state: "queued" | "sending" | "failed";
|
|
472
|
+
readonly failureReason: string | null;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Persistence for pending sends. A default is always supplied — the package
|
|
476
|
+
* never leaves a port empty (THE ALL-INCLUSIVE LAW).
|
|
477
|
+
*/
|
|
478
|
+
interface OutboxStorage {
|
|
479
|
+
/** Human-readable, shown in diagnostics so "durable" is never assumed. */
|
|
480
|
+
readonly name: string;
|
|
481
|
+
readonly durable: boolean;
|
|
482
|
+
load(): readonly OutboxEntry[];
|
|
483
|
+
save(entries: readonly OutboxEntry[]): void;
|
|
484
|
+
}
|
|
485
|
+
declare function createMemoryOutboxStorage(): OutboxStorage;
|
|
486
|
+
interface WebStorageLike {
|
|
487
|
+
getItem(key: string): string | null;
|
|
488
|
+
setItem(key: string, value: string): void;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The real default in a browser. Falls back to memory — announcing itself —
|
|
492
|
+
* when storage is unavailable (private mode, a disabled-cookies browser, SSR).
|
|
493
|
+
*/
|
|
494
|
+
declare function createWebOutboxStorage(args: {
|
|
495
|
+
key?: string;
|
|
496
|
+
storage?: WebStorageLike | null;
|
|
497
|
+
onFallback?: (reason: string) => void;
|
|
498
|
+
}): OutboxStorage;
|
|
499
|
+
interface OutboxOptions {
|
|
500
|
+
/** Actually send. Returns the persisted message. */
|
|
501
|
+
send: (draft: DraftMessage, clientMessageId: ClientMessageId) => Promise<Message>;
|
|
502
|
+
/** Called when an entry lands. The store collapses it with the optimistic row. */
|
|
503
|
+
onSent: (entry: OutboxEntry, message: Message) => void;
|
|
504
|
+
onChange: (entries: readonly OutboxEntry[]) => void;
|
|
505
|
+
onDiagnostic?: (message: string) => void;
|
|
506
|
+
storage?: OutboxStorage;
|
|
507
|
+
maxAttempts?: number;
|
|
508
|
+
/** Backoff schedule per attempt, ms. The last value repeats. */
|
|
509
|
+
backoffMs?: readonly number[];
|
|
510
|
+
timers?: {
|
|
511
|
+
setTimeout: (run: () => void, ms: number) => unknown;
|
|
512
|
+
clearTimeout: (handle: unknown) => void;
|
|
513
|
+
now: () => number;
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
interface Outbox {
|
|
517
|
+
entries(): readonly OutboxEntry[];
|
|
518
|
+
/** Queue a draft. Returns the id the optimistic message must carry. */
|
|
519
|
+
enqueue(draft: DraftMessage): ClientMessageId;
|
|
520
|
+
/** Retry one failed entry, on the user's explicit ask. */
|
|
521
|
+
retry(entryId: string): void;
|
|
522
|
+
/** Drop one entry — the only sanctioned way a typed message leaves the queue. */
|
|
523
|
+
discard(entryId: string): void;
|
|
524
|
+
/** Network came back / tab woke: try everything that is waiting. */
|
|
525
|
+
flush(): void;
|
|
526
|
+
pendingFor(conversationId: ConversationId): readonly OutboxEntry[];
|
|
527
|
+
dispose(): void;
|
|
528
|
+
}
|
|
529
|
+
declare function createOutbox(options: OutboxOptions): Outbox;
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* THE STRUCTURAL SUPABASE SHAPE.
|
|
533
|
+
*
|
|
534
|
+
* D2 says messaging is Supabase-locked — there is no provider abstraction and
|
|
535
|
+
* never will be. That is a statement about the CONTRACT, not about the import
|
|
536
|
+
* graph: this package still accepts the client STRUCTURALLY and never imports
|
|
537
|
+
* `@supabase/supabase-js` at runtime.
|
|
538
|
+
*
|
|
539
|
+
* Two reasons, both learned the hard way across the fleet:
|
|
540
|
+
*
|
|
541
|
+
* 1. A host that ships two copies of `@supabase/supabase-js` (the Next.js
|
|
542
|
+
* server/browser split is the usual way) would get two different `Session`
|
|
543
|
+
* identities and a package that refuses the host's own client.
|
|
544
|
+
* 2. `supabase-js` is a heavy dependency with its own release train; a package
|
|
545
|
+
* that pins one is a version-drift generator (THE LATEST LAW's failure mode
|
|
546
|
+
* in miniature).
|
|
547
|
+
*
|
|
548
|
+
* `supabase-shape.test.ts` proves at COMPILE TIME that a real `SupabaseClient`
|
|
549
|
+
* satisfies these interfaces. If that file stops compiling, the interface here
|
|
550
|
+
* is wrong — fix it here, never with a cast at the call site, and never by
|
|
551
|
+
* asking the consumer to adapt.
|
|
552
|
+
*/
|
|
553
|
+
|
|
554
|
+
/** What PostgREST hands back. `error` is opaque on purpose — we normalize it. */
|
|
555
|
+
interface PostgrestLikeResponse<T> {
|
|
556
|
+
data: T | null;
|
|
557
|
+
error: {
|
|
558
|
+
message: string;
|
|
559
|
+
code?: string;
|
|
560
|
+
details?: string | null;
|
|
561
|
+
} | null;
|
|
562
|
+
}
|
|
563
|
+
interface PostgrestFilterLike<TRow> extends PromiseLike<PostgrestLikeResponse<TRow[]>> {
|
|
564
|
+
eq(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
565
|
+
neq(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
566
|
+
in(column: string, values: readonly unknown[]): PostgrestFilterLike<TRow>;
|
|
567
|
+
is(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
568
|
+
lt(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
569
|
+
gt(column: string, value: unknown): PostgrestFilterLike<TRow>;
|
|
570
|
+
or(filters: string): PostgrestFilterLike<TRow>;
|
|
571
|
+
order(column: string, options?: {
|
|
572
|
+
ascending?: boolean;
|
|
573
|
+
}): PostgrestFilterLike<TRow>;
|
|
574
|
+
limit(count: number): PostgrestFilterLike<TRow>;
|
|
575
|
+
select(columns?: string): PostgrestFilterLike<TRow>;
|
|
576
|
+
single(): PromiseLike<PostgrestLikeResponse<TRow>>;
|
|
577
|
+
maybeSingle(): PromiseLike<PostgrestLikeResponse<TRow>>;
|
|
578
|
+
}
|
|
579
|
+
interface PostgrestTableLike<TRow> {
|
|
580
|
+
select(columns?: string): PostgrestFilterLike<TRow>;
|
|
581
|
+
insert(values: unknown): PostgrestFilterLike<TRow>;
|
|
582
|
+
update(values: unknown): PostgrestFilterLike<TRow>;
|
|
583
|
+
upsert(values: unknown, options?: {
|
|
584
|
+
onConflict?: string;
|
|
585
|
+
}): PostgrestFilterLike<TRow>;
|
|
586
|
+
delete(): PostgrestFilterLike<TRow>;
|
|
587
|
+
}
|
|
588
|
+
interface SchemaLike {
|
|
589
|
+
from<TRow = Record<string, unknown>>(table: string): PostgrestTableLike<TRow>;
|
|
590
|
+
rpc<TResult = unknown>(fn: string, args?: Record<string, unknown>): PromiseLike<PostgrestLikeResponse<TResult>>;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The subset of the Supabase client this package uses. `schema()` matters:
|
|
594
|
+
* every messaging table lives in the `communication` schema, never `public`
|
|
595
|
+
* (the platform's canonical DB conventions, R8).
|
|
596
|
+
*/
|
|
597
|
+
interface SupabaseLike extends SchemaLike {
|
|
598
|
+
schema(name: string): SchemaLike;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* THE DATA CONTRACT — the ONE place a messaging table or RPC name exists.
|
|
603
|
+
*
|
|
604
|
+
* Every name below was verified against Matrx Main (`https://db.matrxserver.com`)
|
|
605
|
+
* on 2026-08-31, not guessed from a client. `matrx-dm` grew a parallel schema in
|
|
606
|
+
* its own project (`public.conversations` / `messages` / `message_reactions`);
|
|
607
|
+
* per R8 exactly ONE canonical schema survives and it is this one — the
|
|
608
|
+
* platform-conventional `communication.dm_*` tables with explicit
|
|
609
|
+
* `organization_id`, `version`, and soft-delete columns, behind auth-checked
|
|
610
|
+
* SECURITY DEFINER RPCs.
|
|
611
|
+
*
|
|
612
|
+
* Four rules hold this file together:
|
|
613
|
+
*
|
|
614
|
+
* 1. **Org is explicit on every write (R5).** Not defaulted by a trigger we
|
|
615
|
+
* hope fires, not inherited — passed, and refused in-package when absent.
|
|
616
|
+
* 2. **Authorization is the database's job.** There is no permission branch in
|
|
617
|
+
* this file. An RPC that says no is a `forbidden` MessagingError, never a
|
|
618
|
+
* client-side re-decision.
|
|
619
|
+
* 3. **Direct conversations are created ATOMICALLY, by RPC.** The banned
|
|
620
|
+
* pattern — read `find_dm_direct_conversation`, then insert — races two
|
|
621
|
+
* tabs into two conversations for the same pair. The RPC advisory-locks the
|
|
622
|
+
* unordered pair. Nothing here may reintroduce the read-then-insert shape.
|
|
623
|
+
* 4. **Pagination is keyset and terminated by a unique column.** Ordering by a
|
|
624
|
+
* timestamp alone duplicates or skips rows whenever two share a millisecond
|
|
625
|
+
* — which in a large org is every page.
|
|
626
|
+
*/
|
|
627
|
+
|
|
628
|
+
/** The schema. Messaging is never in `public`. */
|
|
629
|
+
declare const MESSAGING_SCHEMA = "communication";
|
|
630
|
+
declare const TABLES: {
|
|
631
|
+
readonly conversations: "dm_conversations";
|
|
632
|
+
readonly participants: "dm_conversation_participants";
|
|
633
|
+
readonly messages: "dm_messages";
|
|
634
|
+
};
|
|
635
|
+
declare const RPCS: {
|
|
636
|
+
/** Atomic direct-conversation creation. Advisory-locks the unordered pair. */
|
|
637
|
+
readonly getOrCreateDirect: "dm_get_or_create_direct_conversation";
|
|
638
|
+
/** Conversation list + participants + last message + unread, keyset paged. */
|
|
639
|
+
readonly conversationsWithDetails: "get_dm_conversations_with_details";
|
|
640
|
+
readonly unreadCount: "get_dm_unread_count";
|
|
641
|
+
readonly userInfo: "get_dm_user_info";
|
|
642
|
+
readonly isParticipant: "is_dm_participant";
|
|
643
|
+
};
|
|
644
|
+
/**
|
|
645
|
+
* The host's session source. A messaging read that lands between sign-in and
|
|
646
|
+
* token arrival must not become a red error — it retries ONCE after the host
|
|
647
|
+
* resolves a session, then reports `session-unavailable`.
|
|
648
|
+
*/
|
|
649
|
+
type SessionResolver = () => Promise<unknown>;
|
|
650
|
+
interface RepositoryOptions {
|
|
651
|
+
client: SupabaseLike;
|
|
652
|
+
identity: MessagingIdentity;
|
|
653
|
+
/** Called once before a retry when a read fails with a missing session. */
|
|
654
|
+
resolveSession?: SessionResolver | undefined;
|
|
655
|
+
/** Profile-lookup cache TTL. Default 5 minutes (the frontend's proven value). */
|
|
656
|
+
userTtlMs?: number | undefined;
|
|
657
|
+
now?: (() => number) | undefined;
|
|
658
|
+
}
|
|
659
|
+
interface MessagingRepository {
|
|
660
|
+
readonly identity: MessagingIdentity;
|
|
661
|
+
listConversations(args?: {
|
|
662
|
+
limit?: number;
|
|
663
|
+
cursor?: ConversationCursor | null;
|
|
664
|
+
}): Promise<Page<ConversationSummary, ConversationCursor>>;
|
|
665
|
+
getConversation(id: ConversationId): Promise<Conversation>;
|
|
666
|
+
listMessages(conversationId: ConversationId, args?: {
|
|
667
|
+
limit?: number;
|
|
668
|
+
cursor?: MessageCursor | null;
|
|
669
|
+
}): Promise<Page<Message, MessageCursor>>;
|
|
670
|
+
/** Messages created strictly after `since` — the reconnect backfill read. */
|
|
671
|
+
messagesSince(conversationId: ConversationId, since: string): Promise<readonly Message[]>;
|
|
672
|
+
getOrCreateDirectConversation(otherUserId: UserId): Promise<ConversationId>;
|
|
673
|
+
createGroupConversation(args: {
|
|
674
|
+
name: string;
|
|
675
|
+
memberIds: readonly UserId[];
|
|
676
|
+
}): Promise<ConversationId>;
|
|
677
|
+
insertMessage(draft: DraftMessage, clientMessageId: ClientMessageId): Promise<Message>;
|
|
678
|
+
editMessage(id: MessageId, content: string): Promise<Message>;
|
|
679
|
+
deleteMessage(id: MessageId, forEveryone: boolean): Promise<void>;
|
|
680
|
+
markRead(conversationId: ConversationId, at?: string): Promise<void>;
|
|
681
|
+
setConversationFlags(conversationId: ConversationId, flags: {
|
|
682
|
+
isMuted?: boolean;
|
|
683
|
+
isArchived?: boolean;
|
|
684
|
+
}): Promise<void>;
|
|
685
|
+
addMembers(conversationId: ConversationId, memberIds: readonly UserId[]): Promise<void>;
|
|
686
|
+
removeMember(conversationId: ConversationId, memberId: UserId): Promise<void>;
|
|
687
|
+
setMemberRole(conversationId: ConversationId, memberId: UserId, role: ParticipantRole): Promise<void>;
|
|
688
|
+
/** Cached + in-flight-deduped. N callers for one id make ONE request. */
|
|
689
|
+
getUser(userId: UserId): Promise<UserSummary | null>;
|
|
690
|
+
getUsers(userIds: readonly UserId[]): Promise<ReadonlyMap<UserId, UserSummary>>;
|
|
691
|
+
searchMessages(args: {
|
|
692
|
+
query: string;
|
|
693
|
+
conversationId?: ConversationId | null;
|
|
694
|
+
limit?: number;
|
|
695
|
+
}): Promise<readonly Message[]>;
|
|
696
|
+
invalidateUser(userId: UserId): void;
|
|
697
|
+
}
|
|
698
|
+
declare function createMessagingRepository(options: RepositoryOptions): MessagingRepository;
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* THE MESSAGE STORE — where "the same message twice" is made impossible.
|
|
702
|
+
*
|
|
703
|
+
* A message can reach this store by FOUR different paths, often in any order:
|
|
704
|
+
* the optimistic bubble, the insert's own response, the broadcast, and the
|
|
705
|
+
* Postgres Changes row. Every duplicate bug in every chat product is one of
|
|
706
|
+
* those four arriving out of order. The rules here, each with a regression test:
|
|
707
|
+
*
|
|
708
|
+
* 1. **Identity is `id` OR `clientMessageId`.** The confirmed row and the
|
|
709
|
+
* optimistic bubble are the SAME message; the client-minted key is what
|
|
710
|
+
* proves it. Matching on `(sender, content)` — the tempting shortcut — merges
|
|
711
|
+
* two genuinely different "ok" messages into one.
|
|
712
|
+
* 2. **Merge collapses; it never appends.** A confirmed row that finds an
|
|
713
|
+
* optimistic twin REPLACES it in place, keeping the twin's position so the
|
|
714
|
+
* bubble does not jump.
|
|
715
|
+
* 3. **Older never overwrites newer.** An out-of-order UPDATE carrying an older
|
|
716
|
+
* `edited_at`/`created_at` than the held copy is DROPPED. Without this an
|
|
717
|
+
* edit visibly reverts itself a second later.
|
|
718
|
+
* 4. **Order is `created_at` then `id`.** The unique tiebreaker is not
|
|
719
|
+
* decoration: two messages in the same millisecond otherwise swap places on
|
|
720
|
+
* every re-render.
|
|
721
|
+
* 5. **The active conversation's unread count is forced to zero.** A stale
|
|
722
|
+
* server count must never re-badge a conversation the user is reading — a
|
|
723
|
+
* safety net the origin needed in three separate places, so it lives in one
|
|
724
|
+
* place here.
|
|
725
|
+
*/
|
|
726
|
+
|
|
727
|
+
interface ConversationThread {
|
|
728
|
+
readonly conversationId: ConversationId;
|
|
729
|
+
readonly messages: readonly Message[];
|
|
730
|
+
readonly hasMoreOlder: boolean;
|
|
731
|
+
/** The newest `created_at` held — the reconnect backfill's high-water mark. */
|
|
732
|
+
readonly latestAt: string | null;
|
|
733
|
+
}
|
|
734
|
+
interface MessagingSnapshot {
|
|
735
|
+
readonly conversations: readonly ConversationSummary[];
|
|
736
|
+
readonly hasMoreConversations: boolean;
|
|
737
|
+
/**
|
|
738
|
+
* True once the inbox has actually been READ at least once.
|
|
739
|
+
*
|
|
740
|
+
* An empty list means two completely different things — "you have no
|
|
741
|
+
* conversations" and "we have not looked yet" — and a UI that cannot tell
|
|
742
|
+
* them apart shows a confident "No conversations yet" to someone who has
|
|
743
|
+
* hundreds. That is a screen telling a lie, so the distinction is a fact the
|
|
744
|
+
* store carries rather than something each surface guesses from a timer.
|
|
745
|
+
*/
|
|
746
|
+
readonly hasLoadedConversations: boolean;
|
|
747
|
+
readonly threads: ReadonlyMap<ConversationId, ConversationThread>;
|
|
748
|
+
readonly activeConversationId: ConversationId | null;
|
|
749
|
+
/** Conversations WITH unread, not total unread messages (the origin's semantics). */
|
|
750
|
+
readonly totalUnreadConversations: number;
|
|
751
|
+
}
|
|
752
|
+
interface MessagingStore {
|
|
753
|
+
snapshot(): MessagingSnapshot;
|
|
754
|
+
subscribe(listener: (snapshot: MessagingSnapshot) => void): () => void;
|
|
755
|
+
setConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
|
|
756
|
+
appendConversations(items: readonly ConversationSummary[], hasMore: boolean): void;
|
|
757
|
+
upsertConversation(item: ConversationSummary): void;
|
|
758
|
+
removeConversation(id: ConversationId): void;
|
|
759
|
+
setActiveConversation(id: ConversationId | null): void;
|
|
760
|
+
setThread(id: ConversationId, messages: readonly Message[], args?: {
|
|
761
|
+
hasMoreOlder?: boolean;
|
|
762
|
+
}): void;
|
|
763
|
+
prependOlder(id: ConversationId, messages: readonly Message[], hasMoreOlder: boolean): void;
|
|
764
|
+
/** The one door every incoming message uses. Returns what actually happened. */
|
|
765
|
+
ingest(message: Message): "added" | "merged" | "dropped-stale" | "dropped-duplicate";
|
|
766
|
+
ingestMany(messages: readonly Message[]): void;
|
|
767
|
+
removeMessage(conversationId: ConversationId, messageId: MessageId): void;
|
|
768
|
+
markConversationRead(id: ConversationId): void;
|
|
769
|
+
setUnreadCount(id: ConversationId, count: number): void;
|
|
770
|
+
}
|
|
771
|
+
declare function createMessagingStore(): MessagingStore;
|
|
772
|
+
/**
|
|
773
|
+
* The optimistic row a composer shows the instant Enter is pressed. It carries
|
|
774
|
+
* the outbox's client key, which is the whole reason the confirmed row can find
|
|
775
|
+
* and replace it rather than appearing beside it.
|
|
776
|
+
*/
|
|
777
|
+
declare function optimisticMessage(args: {
|
|
778
|
+
conversationId: ConversationId;
|
|
779
|
+
senderId: UserId;
|
|
780
|
+
organizationId: Message["organizationId"];
|
|
781
|
+
content: string;
|
|
782
|
+
clientMessageId: ClientMessageId;
|
|
783
|
+
kind?: Message["kind"];
|
|
784
|
+
replyToId?: MessageId | null;
|
|
785
|
+
action?: Message["action"];
|
|
786
|
+
attachments?: readonly Message["attachments"][number][];
|
|
787
|
+
references?: readonly Message["references"][number][];
|
|
788
|
+
now?: () => number;
|
|
789
|
+
}): Message;
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* THE MESSAGING ENGINE — the object a host mounts once and never reasons about.
|
|
793
|
+
*
|
|
794
|
+
* It owns the wiring that every chat implementation gets wrong: which channel
|
|
795
|
+
* carries what, what happens on reconnect, which of four arrival paths wins,
|
|
796
|
+
* when a read receipt is written, and what a failed send does. All of it is
|
|
797
|
+
* here so a consumer's job is `engine.send(draft)` and rendering a snapshot.
|
|
798
|
+
*
|
|
799
|
+
* THE RECONNECT RULE, inherited from `@ai-matrx/realtime` and honored here:
|
|
800
|
+
* realtime has NO REPLAY. Every channel this engine opens declares an
|
|
801
|
+
* `onBackfill` door, and those doors re-read from the database. A reconnect
|
|
802
|
+
* without a re-read leaves a permanently wrong thread that looks perfectly
|
|
803
|
+
* healthy — the single most expensive bug class in messaging.
|
|
804
|
+
*/
|
|
805
|
+
|
|
806
|
+
interface EngineDiagnostic {
|
|
807
|
+
readonly level: "info" | "warn" | "error";
|
|
808
|
+
readonly message: string;
|
|
809
|
+
readonly remedy?: string | undefined;
|
|
810
|
+
}
|
|
811
|
+
interface MessagingEngineOptions {
|
|
812
|
+
repository: MessagingRepository;
|
|
813
|
+
manager: RealtimeManager;
|
|
814
|
+
identity: MessagingIdentity;
|
|
815
|
+
outboxStorage?: OutboxStorage | undefined;
|
|
816
|
+
onDiagnostic?: ((event: EngineDiagnostic) => void) | undefined;
|
|
817
|
+
/** Fired for a message from someone else, for notification sinks. */
|
|
818
|
+
onIncoming?: ((message: Message) => void) | undefined;
|
|
819
|
+
conversationPageSize?: number | undefined;
|
|
820
|
+
messagePageSize?: number | undefined;
|
|
821
|
+
}
|
|
822
|
+
interface MessagingEngine {
|
|
823
|
+
readonly store: MessagingStore;
|
|
824
|
+
readonly outbox: Outbox;
|
|
825
|
+
readonly identity: MessagingIdentity;
|
|
826
|
+
/** Load page one of the inbox and start the inbox channel. */
|
|
827
|
+
start(): Promise<void>;
|
|
828
|
+
loadMoreConversations(): Promise<void>;
|
|
829
|
+
/** Open a conversation: load its thread and subscribe to its channel. */
|
|
830
|
+
openConversation(id: ConversationId): Promise<void>;
|
|
831
|
+
closeConversation(id: ConversationId): void;
|
|
832
|
+
loadOlderMessages(id: ConversationId): Promise<void>;
|
|
833
|
+
/** Queue a message. Returns immediately with the optimistic row on screen. */
|
|
834
|
+
send(draft: DraftMessage): ClientMessageId;
|
|
835
|
+
retry(entryId: string): void;
|
|
836
|
+
discard(entryId: string): void;
|
|
837
|
+
editMessage(id: MessageId, conversationId: ConversationId, content: string): Promise<void>;
|
|
838
|
+
deleteMessage(id: MessageId, conversationId: ConversationId, forEveryone: boolean): Promise<void>;
|
|
839
|
+
markRead(id: ConversationId): Promise<void>;
|
|
840
|
+
startDirectConversation(otherUserId: UserId): Promise<ConversationId>;
|
|
841
|
+
dispose(): void;
|
|
842
|
+
}
|
|
843
|
+
declare function createMessagingEngine(options: MessagingEngineOptions): MessagingEngine;
|
|
844
|
+
/** The realtime client session id, exposed for diagnostics surfaces. */
|
|
845
|
+
declare function messagingClientId(): string;
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* ONE error classification for the whole package (C22).
|
|
849
|
+
*
|
|
850
|
+
* The banned alternative is the host catching a PostgREST error and deciding
|
|
851
|
+
* for itself whether it was an auth blip, a permission denial, or a real bug —
|
|
852
|
+
* which is how the same three-branch `if` ends up copy-pasted into every
|
|
853
|
+
* consumer and drifts. Everything this package throws is a `MessagingError`
|
|
854
|
+
* with a stable `code`, and the two that hosts genuinely treat differently
|
|
855
|
+
* (`session-unavailable`, `forbidden`) say so in the type.
|
|
856
|
+
*
|
|
857
|
+
* The incident behind `session-unavailable`: a DM reader mounted before the
|
|
858
|
+
* access token existed, every RPC came back `42501`, and the app captured 909
|
|
859
|
+
* errors in 0.6 seconds. A missing session is a NORMAL lifecycle moment — it
|
|
860
|
+
* warns and retries; it is never a red error.
|
|
861
|
+
*/
|
|
862
|
+
type MessagingErrorCode = "session-unavailable" | "forbidden" | "not-found" | "conflict" | "transport" | "invalid-response" | "misconfigured" | "unknown";
|
|
863
|
+
declare class MessagingError extends Error {
|
|
864
|
+
readonly code: MessagingErrorCode;
|
|
865
|
+
/** The remedy. Nothing fails silently, and nothing fails without saying what to do. */
|
|
866
|
+
readonly remedy: string;
|
|
867
|
+
readonly cause?: unknown;
|
|
868
|
+
constructor(code: MessagingErrorCode, message: string, remedy: string, cause?: unknown);
|
|
869
|
+
/** True when retrying after the host re-establishes a session is the fix. */
|
|
870
|
+
get isRetryable(): boolean;
|
|
871
|
+
}
|
|
872
|
+
interface PostgrestErrorLike {
|
|
873
|
+
message: string;
|
|
874
|
+
code?: string | undefined;
|
|
875
|
+
details?: string | null | undefined;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* THE ONE PLACE a raw PostgREST/transport error becomes a typed one. Called at
|
|
879
|
+
* every boundary in `repository.ts`; nothing else in the package inspects a raw
|
|
880
|
+
* error, and no consumer should ever have to.
|
|
881
|
+
*/
|
|
882
|
+
declare function normalizeMessagingError(error: PostgrestErrorLike | Error | unknown, operation: string): MessagingError;
|
|
883
|
+
/** A response whose SHAPE is wrong — the ingress boundary, not the wire. */
|
|
884
|
+
declare function invalidResponse(operation: string, detail: string): MessagingError;
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Display formatting. In the package because every consumer otherwise rebuilds
|
|
888
|
+
* it slightly differently and the inbox and the thread disagree about what
|
|
889
|
+
* "yesterday" means.
|
|
890
|
+
*
|
|
891
|
+
* Every function takes an explicit `now` so the tests do not depend on the
|
|
892
|
+
* clock, and none of them touches `Intl` with an implicit locale — a package
|
|
893
|
+
* that silently formats in the build machine's locale is a bug that only shows
|
|
894
|
+
* up for users in other timezones.
|
|
895
|
+
*/
|
|
896
|
+
|
|
897
|
+
declare function isSameDay(a: Date, b: Date): boolean;
|
|
898
|
+
/** Compact stamp for a conversation row: `9:41 AM`, `Yesterday`, `Mar 4`. */
|
|
899
|
+
declare function formatConversationTime(isoString: string | null, now?: number, locale?: string): string;
|
|
900
|
+
/** The stamp under a bubble. */
|
|
901
|
+
declare function formatMessageTime(isoString: string, locale?: string): string;
|
|
902
|
+
/** The separator between day groups in a thread. */
|
|
903
|
+
declare function formatDateSeparator(isoString: string, now?: number, locale?: string): string;
|
|
904
|
+
declare function getInitials(name: string): string;
|
|
905
|
+
/**
|
|
906
|
+
* A stable palette index for an avatar with no image. Deterministic on the id,
|
|
907
|
+
* so the same person is the same color on every device and every reload — a
|
|
908
|
+
* random color per render is a surprisingly loud bug.
|
|
909
|
+
*/
|
|
910
|
+
declare function avatarPaletteIndex(seed: string, buckets?: number): number;
|
|
911
|
+
/**
|
|
912
|
+
* Group consecutive messages by the same sender within a window, the way every
|
|
913
|
+
* good chat UI does — one avatar and one name per burst.
|
|
914
|
+
*/
|
|
915
|
+
interface MessageGroup {
|
|
916
|
+
readonly senderId: UserId;
|
|
917
|
+
readonly messages: readonly Message[];
|
|
918
|
+
readonly dateSeparator: string | null;
|
|
919
|
+
}
|
|
920
|
+
declare function groupMessages(messages: readonly Message[], args?: {
|
|
921
|
+
now?: number;
|
|
922
|
+
windowMs?: number;
|
|
923
|
+
locale?: string;
|
|
924
|
+
}): readonly MessageGroup[];
|
|
925
|
+
/** "Ana is typing…" / "Ana and Bo are typing…" / "3 people are typing…" */
|
|
926
|
+
declare function formatTypists(names: readonly string[]): string | null;
|
|
927
|
+
declare function participantNames(participants: readonly UserSummary[], excluding: UserId): readonly string[];
|
|
928
|
+
/** "Active now" / "Active 5m ago" — presence, said honestly. */
|
|
929
|
+
declare function formatLastSeen(lastSeenMs: number | null, now?: number): string;
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* THE INGRESS BOUNDARY — where a database row becomes a domain object.
|
|
933
|
+
*
|
|
934
|
+
* Everything crossing this line is validated, because the alternative is a
|
|
935
|
+
* screen that lies. A conversation row whose `participants` aggregate came back
|
|
936
|
+
* as a string instead of an array used to render an inbox entry named
|
|
937
|
+
* `undefined` with a working click target; refusing the row here turns a silent
|
|
938
|
+
* wrong screen into a loud, remediable error (nothing fails silently).
|
|
939
|
+
*
|
|
940
|
+
* The rules:
|
|
941
|
+
* - A missing REQUIRED field is a refusal, never a `?? ""` that renders blank.
|
|
942
|
+
* - A missing OPTIONAL field is a null, never an invented default.
|
|
943
|
+
* - `metadata` / `action_data` are host-and-DB-owned JSON: typed over `unknown`
|
|
944
|
+
* and narrowed by shape, never cast (the data 0.2.1 `Json = unknown` lesson).
|
|
945
|
+
*/
|
|
946
|
+
|
|
947
|
+
declare function projectUserSummary(row: Record<string, unknown>): UserSummary;
|
|
948
|
+
declare function projectMessageAction(value: unknown): MessageAction | null;
|
|
949
|
+
declare function projectMessage(row: Record<string, unknown>, fallbackOrganizationId: OrganizationId): Message;
|
|
950
|
+
declare function projectParticipantRole(value: unknown): ParticipantRole;
|
|
951
|
+
declare function projectConversationSummary(row: Record<string, unknown>, viewerId: UserId, fallbackOrganizationId: OrganizationId): ConversationSummary;
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* MATRX REFERENCES — a message can name a platform entity, and everything it
|
|
955
|
+
* names must OPEN. No dead ends.
|
|
956
|
+
*
|
|
957
|
+
* Two transports, both supported because both already exist in the wild:
|
|
958
|
+
*
|
|
959
|
+
* 1. **Structured**, in `metadata.references` — what this package writes.
|
|
960
|
+
* 2. **Fenced**, as a ```matrx block in the content — the platform's
|
|
961
|
+
* reference-fence protocol, which arrives from senders outside messaging.
|
|
962
|
+
*
|
|
963
|
+
* Two failures this module exists to prevent:
|
|
964
|
+
*
|
|
965
|
+
* - **A raw fence rendered as a code block.** The reader sees JSON. `splitText`
|
|
966
|
+
* is what lets the renderer draw a card instead.
|
|
967
|
+
* - **A fence leaking into a preview.** The inbox row and the desktop
|
|
968
|
+
* notification are plain text; pasting the JSON there is the same defect one
|
|
969
|
+
* layer down. `summarizeText` is the one collapse, used by both.
|
|
970
|
+
*/
|
|
971
|
+
|
|
972
|
+
type TextSegment = {
|
|
973
|
+
readonly type: "text";
|
|
974
|
+
readonly value: string;
|
|
975
|
+
} | {
|
|
976
|
+
readonly type: "reference";
|
|
977
|
+
readonly reference: MatrxReference;
|
|
978
|
+
};
|
|
979
|
+
/** Every reference a message carries, from both transports, de-duplicated. */
|
|
980
|
+
declare function extractReferences(content: string, structured?: readonly MatrxReference[]): readonly MatrxReference[];
|
|
981
|
+
/**
|
|
982
|
+
* Split content into renderable segments. The renderer draws text for `text`
|
|
983
|
+
* and a live card for `reference`; a fence therefore CANNOT reach the screen as
|
|
984
|
+
* a code block.
|
|
985
|
+
*/
|
|
986
|
+
declare function splitText(content: string): readonly TextSegment[];
|
|
987
|
+
/**
|
|
988
|
+
* ONE plain-text collapse, used by the inbox preview AND the notification body.
|
|
989
|
+
* A fence becomes its human label; it never reaches either as raw JSON.
|
|
990
|
+
*/
|
|
991
|
+
declare function summarizeText(content: string, maxLength?: number): string;
|
|
992
|
+
/** Serialize picked references into a fence the platform's other readers accept. */
|
|
993
|
+
declare function composeFence(references: readonly MatrxReference[]): string;
|
|
994
|
+
|
|
995
|
+
export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, type AiCapability, type AiResult, type Attachment, type ClientMessageId, type Conversation, type ConversationCursor, type ConversationId, type ConversationSummary, type ConversationThread, type ConversationType, type DeliveryState, type DraftMessage, type EngineDiagnostic, type JsonObject, type JsonValue, MESSAGING_EVENTS, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingIdentity, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, type Participant, type ParticipantRole, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, type RepositoryOptions, type SchemaLike, type SessionResolver, type SupabaseLike, TABLES, type TextSegment, type UserId, type UserSummary, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, avatarPaletteIndex, composeFence, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createWebOutboxStorage, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, getInitials, groupMessages, inboxTopic, invalidResponse, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, splitText, summarizeText };
|