@ai-matrx/messaging 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1893 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ MESSAGING_EVENTS: () => MESSAGING_EVENTS,
24
+ MESSAGING_SCHEMA: () => MESSAGING_SCHEMA,
25
+ MessagingError: () => MessagingError,
26
+ RPCS: () => RPCS,
27
+ TABLES: () => TABLES,
28
+ asClientMessageId: () => asClientMessageId,
29
+ asConversationId: () => asConversationId,
30
+ asMessageId: () => asMessageId,
31
+ asOrganizationId: () => asOrganizationId,
32
+ asUserId: () => asUserId,
33
+ avatarPaletteIndex: () => avatarPaletteIndex,
34
+ composeFence: () => composeFence,
35
+ conversationTopic: () => conversationTopic,
36
+ createActionRegistry: () => createActionRegistry,
37
+ createMemoryOutboxStorage: () => createMemoryOutboxStorage,
38
+ createMessagingAi: () => createMessagingAi,
39
+ createMessagingEngine: () => createMessagingEngine,
40
+ createMessagingRepository: () => createMessagingRepository,
41
+ createMessagingStore: () => createMessagingStore,
42
+ createOutbox: () => createOutbox,
43
+ createReadCache: () => createReadCache,
44
+ createWebOutboxStorage: () => createWebOutboxStorage,
45
+ extractReferences: () => extractReferences,
46
+ formatConversationTime: () => formatConversationTime,
47
+ formatDateSeparator: () => formatDateSeparator,
48
+ formatLastSeen: () => formatLastSeen,
49
+ formatMessageTime: () => formatMessageTime,
50
+ formatTypists: () => formatTypists,
51
+ getInitials: () => getInitials,
52
+ groupMessages: () => groupMessages,
53
+ inboxTopic: () => inboxTopic,
54
+ invalidResponse: () => invalidResponse,
55
+ isSameDay: () => isSameDay,
56
+ messagingClientId: () => messagingClientId,
57
+ normalizeMessagingError: () => normalizeMessagingError,
58
+ optimisticMessage: () => optimisticMessage,
59
+ participantNames: () => participantNames,
60
+ projectConversationSummary: () => projectConversationSummary,
61
+ projectMessage: () => projectMessage,
62
+ projectMessageAction: () => projectMessageAction,
63
+ projectParticipantRole: () => projectParticipantRole,
64
+ projectUserSummary: () => projectUserSummary,
65
+ resolveActor: () => resolveActor,
66
+ splitText: () => splitText,
67
+ summarizeText: () => summarizeText
68
+ });
69
+ module.exports = __toCommonJS(src_exports);
70
+
71
+ // src/core/actions.ts
72
+ function receiptKey(kind, messageId, actorId) {
73
+ return `${kind}::${messageId}::${actorId}`;
74
+ }
75
+ function createActionRegistry() {
76
+ const handlers = /* @__PURE__ */ new Map();
77
+ const receipts = /* @__PURE__ */ new Map();
78
+ const inFlight = /* @__PURE__ */ new Map();
79
+ const registry = {
80
+ register(handler) {
81
+ handlers.set(handler.kind, handler);
82
+ },
83
+ resolve(action) {
84
+ const handler = handlers.get(action.kind);
85
+ if (handler === void 0) return null;
86
+ return handler.versions.includes(action.version) ? handler : null;
87
+ },
88
+ choicesFor(action) {
89
+ const handler = registry.resolve(action);
90
+ return handler === null ? [] : handler.choices(action.payload);
91
+ },
92
+ summarize(action) {
93
+ const handler = registry.resolve(action);
94
+ if (handler?.summarize === void 0) return null;
95
+ return handler.summarize(action.payload);
96
+ },
97
+ execute(action, context) {
98
+ const key = receiptKey(action.kind, context.messageId, context.actorId);
99
+ const settled = receipts.get(key);
100
+ if (settled !== void 0) return Promise.resolve(settled);
101
+ const running = inFlight.get(key);
102
+ if (running !== void 0) return running;
103
+ const handler = registry.resolve(action);
104
+ if (handler === null) {
105
+ const receipt = {
106
+ kind: action.kind,
107
+ messageId: context.messageId,
108
+ actorId: context.actorId,
109
+ outcome: "unavailable",
110
+ label: "Not available in this app version",
111
+ settledAt: (/* @__PURE__ */ new Date()).toISOString(),
112
+ detail: `No handler registered for "${action.kind}" v${action.version}. Update the app, or register a handler on <MessagingProvider actions={...}>.`
113
+ };
114
+ return Promise.resolve(receipt);
115
+ }
116
+ const started = handler.execute(action.payload, context).then((receipt) => {
117
+ if (receipt.outcome === "applied" || receipt.outcome === "already") {
118
+ receipts.set(key, receipt);
119
+ }
120
+ return receipt;
121
+ }).finally(() => {
122
+ inFlight.delete(key);
123
+ });
124
+ inFlight.set(key, started);
125
+ return started;
126
+ },
127
+ receiptFor(kind, messageId, actorId) {
128
+ return receipts.get(receiptKey(kind, messageId, actorId)) ?? null;
129
+ },
130
+ observeReceipt(receipt) {
131
+ if (receipt.outcome === "applied" || receipt.outcome === "already") {
132
+ receipts.set(receiptKey(receipt.kind, receipt.messageId, receipt.actorId), receipt);
133
+ }
134
+ },
135
+ known() {
136
+ return [...handlers.keys()];
137
+ }
138
+ };
139
+ return registry;
140
+ }
141
+
142
+ // src/core/actor.ts
143
+ function readActorHint(metadata) {
144
+ const raw = metadata["actor"];
145
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
146
+ const record = raw;
147
+ const agentId = record["agentId"] ?? record["agent_id"];
148
+ if (typeof agentId !== "string" || agentId.length === 0) return null;
149
+ const name = record["agentName"] ?? record["agent_name"];
150
+ const avatar = record["agentAvatarUrl"] ?? record["agent_avatar_url"];
151
+ return {
152
+ agentId,
153
+ ...typeof name === "string" && name.length > 0 ? { agentName: name } : {},
154
+ ...typeof avatar === "string" && avatar.length > 0 ? { agentAvatarUrl: avatar } : {}
155
+ };
156
+ }
157
+ function resolveActor(message, sender) {
158
+ const hint = readActorHint(message.metadata);
159
+ const humanName = sender?.displayName ?? message.senderId;
160
+ if (hint !== null) {
161
+ return {
162
+ // An agent NEVER inherits the human's name or avatar. When the agent did
163
+ // not name itself, it is labeled generically — an honest "Agent" beats a
164
+ // colleague's face on a message they did not write.
165
+ displayName: hint.agentName ?? "Agent",
166
+ avatarUrl: hint.agentAvatarUrl ?? null,
167
+ isAgent: true,
168
+ onBehalfOfName: humanName,
169
+ principalUserId: message.senderId
170
+ };
171
+ }
172
+ if (sender?.isAgent === true) {
173
+ return {
174
+ displayName: sender.displayName,
175
+ avatarUrl: sender.avatarUrl,
176
+ isAgent: true,
177
+ onBehalfOfName: null,
178
+ principalUserId: message.senderId
179
+ };
180
+ }
181
+ return {
182
+ displayName: humanName,
183
+ avatarUrl: sender?.avatarUrl ?? null,
184
+ isAgent: false,
185
+ onBehalfOfName: null,
186
+ principalUserId: message.senderId
187
+ };
188
+ }
189
+
190
+ // src/core/ai.ts
191
+ var import_matrx = require("@ai-matrx/agents/matrx");
192
+
193
+ // src/core/errors.ts
194
+ var MessagingError = class extends Error {
195
+ code;
196
+ /** The remedy. Nothing fails silently, and nothing fails without saying what to do. */
197
+ remedy;
198
+ cause;
199
+ constructor(code, message, remedy, cause) {
200
+ super(message);
201
+ this.name = "MessagingError";
202
+ this.code = code;
203
+ this.remedy = remedy;
204
+ if (cause !== void 0) this.cause = cause;
205
+ }
206
+ /** True when retrying after the host re-establishes a session is the fix. */
207
+ get isRetryable() {
208
+ return this.code === "session-unavailable" || this.code === "transport";
209
+ }
210
+ };
211
+ var FORBIDDEN_CODES = /* @__PURE__ */ new Set(["42501", "PGRST301", "PGRST302"]);
212
+ var NOT_FOUND_CODES = /* @__PURE__ */ new Set(["PGRST116", "PGRST205"]);
213
+ var CONFLICT_CODES = /* @__PURE__ */ new Set(["23505"]);
214
+ var SESSION_MARKERS = [
215
+ "auth session missing",
216
+ "jwt expired",
217
+ "no api key found",
218
+ "refresh_token_not_found",
219
+ "invalid claim: missing sub claim"
220
+ ];
221
+ function normalizeMessagingError(error, operation) {
222
+ if (error instanceof MessagingError) return error;
223
+ const raw = error;
224
+ const message = typeof raw?.message === "string" ? raw.message : String(error);
225
+ const code = typeof raw?.code === "string" ? raw.code : void 0;
226
+ const lowered = message.toLowerCase();
227
+ if (raw?.name === "SessionUnavailableError" || SESSION_MARKERS.some((marker) => lowered.includes(marker))) {
228
+ return new MessagingError(
229
+ "session-unavailable",
230
+ `${operation}: no Supabase session available (${message})`,
231
+ "Normal during sign-in and token refresh. The package retries once after the host's session source resolves; log it as a warning, never as an error.",
232
+ error
233
+ );
234
+ }
235
+ if (code !== void 0 && FORBIDDEN_CODES.has(code)) {
236
+ return new MessagingError(
237
+ "forbidden",
238
+ `${operation}: denied by the database (${code}: ${message})`,
239
+ "Authorization is RLS + auth-checked RPCs (R5). Fix the policy or the caller's membership \u2014 never work around it with a service-role client in a browser.",
240
+ error
241
+ );
242
+ }
243
+ if (code !== void 0 && NOT_FOUND_CODES.has(code)) {
244
+ return new MessagingError(
245
+ "not-found",
246
+ `${operation}: not found (${code}: ${message})`,
247
+ "The row is gone, or RLS hides it from this user. Re-read the conversation list.",
248
+ error
249
+ );
250
+ }
251
+ if (code !== void 0 && CONFLICT_CODES.has(code)) {
252
+ return new MessagingError(
253
+ "conflict",
254
+ `${operation}: unique violation (${code}: ${message})`,
255
+ "A concurrent writer won. For messages this is the client_message_id idempotency key doing its job \u2014 re-read the row rather than retrying the insert.",
256
+ error
257
+ );
258
+ }
259
+ return new MessagingError(
260
+ "transport",
261
+ `${operation}: ${message}`,
262
+ "Transient transport failure. The package retries reads; a write is surfaced to the outbox so the typed message is never lost.",
263
+ error
264
+ );
265
+ }
266
+ function invalidResponse(operation, detail) {
267
+ return new MessagingError(
268
+ "invalid-response",
269
+ `${operation}: ${detail}`,
270
+ "The database returned a shape this package does not accept. Rendering it would put a lie on the screen, so it is refused here. Fix the RPC's return contract."
271
+ );
272
+ }
273
+
274
+ // src/core/references.ts
275
+ var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
276
+ function parseFenceBody(body) {
277
+ let parsed;
278
+ try {
279
+ parsed = JSON.parse(body);
280
+ } catch {
281
+ return [];
282
+ }
283
+ const entries = Array.isArray(parsed) ? parsed : [parsed];
284
+ const references = [];
285
+ for (const entry of entries) {
286
+ if (typeof entry !== "object" || entry === null) continue;
287
+ const record = entry;
288
+ const entityType = record["entityType"] ?? record["entity_type"] ?? record["type"];
289
+ const entityId = record["entityId"] ?? record["entity_id"] ?? record["id"];
290
+ if (typeof entityType !== "string" || typeof entityId !== "string") continue;
291
+ if (entityType.length === 0 || entityId.length === 0) continue;
292
+ const label = record["label"] ?? record["title"] ?? record["name"];
293
+ const href = record["href"] ?? record["url"];
294
+ references.push({
295
+ entityType,
296
+ entityId,
297
+ label: typeof label === "string" && label.length > 0 ? label : entityId,
298
+ ...typeof href === "string" && href.length > 0 ? { href } : {}
299
+ });
300
+ }
301
+ return references;
302
+ }
303
+ function extractReferences(content, structured = []) {
304
+ const seen = /* @__PURE__ */ new Map();
305
+ const add = (reference) => {
306
+ seen.set(`${reference.entityType}:${reference.entityId}`, reference);
307
+ };
308
+ structured.forEach(add);
309
+ for (const match of content.matchAll(FENCE)) {
310
+ parseFenceBody(match[1] ?? "").forEach(add);
311
+ }
312
+ return [...seen.values()];
313
+ }
314
+ function splitText(content) {
315
+ const segments = [];
316
+ let cursor = 0;
317
+ for (const match of content.matchAll(FENCE)) {
318
+ const start = match.index ?? 0;
319
+ if (start > cursor) {
320
+ segments.push({ type: "text", value: content.slice(cursor, start) });
321
+ }
322
+ parseFenceBody(match[1] ?? "").forEach((reference) => {
323
+ segments.push({ type: "reference", reference });
324
+ });
325
+ cursor = start + match[0].length;
326
+ }
327
+ if (cursor < content.length) {
328
+ segments.push({ type: "text", value: content.slice(cursor) });
329
+ }
330
+ return segments.filter(
331
+ (segment) => segment.type === "reference" || segment.value.trim().length > 0
332
+ );
333
+ }
334
+ function summarizeText(content, maxLength = 140) {
335
+ const parts = splitText(content).map(
336
+ (segment) => segment.type === "text" ? segment.value : segment.reference.label
337
+ );
338
+ const flattened = parts.join(" ").replace(/\s+/g, " ").trim();
339
+ if (flattened.length <= maxLength) return flattened;
340
+ return `${flattened.slice(0, maxLength - 1).trimEnd()}\u2026`;
341
+ }
342
+ function composeFence(references) {
343
+ if (references.length === 0) return "";
344
+ return `\`\`\`matrx
345
+ ${JSON.stringify(references, null, 2)}
346
+ \`\`\``;
347
+ }
348
+
349
+ // src/core/ai.ts
350
+ function nameOf(participants, senderId) {
351
+ return participants.find((p) => p.userId === senderId)?.displayName ?? senderId;
352
+ }
353
+ function buildTranscript(messages, participants, limit, since) {
354
+ const relevant = messages.filter((message) => {
355
+ if (message.deletedAt !== null) return false;
356
+ if (since === null) return true;
357
+ return message.createdAt > since;
358
+ });
359
+ const windowed = relevant.slice(-limit);
360
+ return windowed.map((message) => ({
361
+ at: message.createdAt,
362
+ author: nameOf(participants, message.senderId),
363
+ // The transcript is TEXT. A reference fence becomes its label, never JSON —
364
+ // the same collapse the inbox preview uses.
365
+ text: summarizeText(message.content, 2e3)
366
+ }));
367
+ }
368
+ function createMessagingAi(options) {
369
+ const limit = options.maxTranscriptMessages ?? 200;
370
+ function agentFor(capability) {
371
+ const agentId = options.agents[capability];
372
+ if (typeof agentId !== "string" || agentId.length === 0) {
373
+ throw new MessagingError(
374
+ "misconfigured",
375
+ `Messaging AI capability "${capability}" has no agent configured`,
376
+ `Pass agents={{ ${capability}: "<agent-id>" }} to <MessagingProvider>. Agent definitions live in the database, never in this package \u2014 the id is the only part a host injects. Until then the UI hides this action rather than offering a button that cannot work.`
377
+ );
378
+ }
379
+ return agentId;
380
+ }
381
+ async function run(capability, variables, userInput, signal) {
382
+ const agentId = agentFor(capability);
383
+ const completed = await (0, import_matrx.runAgentToCompletion)(
384
+ options.transport,
385
+ agentId,
386
+ {
387
+ ...(0, import_matrx.newEphemeralConversationStart)(),
388
+ organization_id: options.organizationId,
389
+ source_app: options.sourceApp ?? "ai-matrx",
390
+ source_feature: options.sourceFeature ?? `messaging.${capability}`,
391
+ initiation: "user",
392
+ // THE USER-INPUT LAW: structured content is NEVER here.
393
+ ...userInput !== null ? { user_input: userInput } : {},
394
+ variables
395
+ },
396
+ signal !== void 0 ? { signal } : {}
397
+ );
398
+ return {
399
+ capability,
400
+ text: completed.text.trim(),
401
+ conversationId: completed.conversationId
402
+ };
403
+ }
404
+ function variablesFor(args) {
405
+ const transcript = buildTranscript(
406
+ args.messages,
407
+ args.participants,
408
+ limit,
409
+ args.since ?? null
410
+ );
411
+ return {
412
+ conversation_id: args.conversationId,
413
+ participants: args.participants.map((participant) => ({
414
+ user_id: participant.userId,
415
+ display_name: participant.displayName,
416
+ is_agent: participant.isAgent
417
+ })),
418
+ transcript: transcript.map((entry) => ({
419
+ at: entry.at,
420
+ author: entry.author,
421
+ text: entry.text
422
+ })),
423
+ transcript_message_count: transcript.length,
424
+ // Honest about the cut, so an agent can say "showing the last 200".
425
+ transcript_truncated: args.messages.length > transcript.length,
426
+ ...args.since != null ? { unread_since: args.since } : {}
427
+ };
428
+ }
429
+ return {
430
+ available: () => ["catchUp", "summarize", "actionItems", "draftReply"].filter(
431
+ (capability) => {
432
+ const id = options.agents[capability];
433
+ return typeof id === "string" && id.length > 0;
434
+ }
435
+ ),
436
+ isAvailable(capability) {
437
+ const id = options.agents[capability];
438
+ return typeof id === "string" && id.length > 0;
439
+ },
440
+ catchMeUp: (args) => run("catchUp", variablesFor(args), null, args.signal),
441
+ summarize: (args) => run("summarize", variablesFor(args), null, args.signal),
442
+ extractActionItems: (args) => run("actionItems", variablesFor(args), null, args.signal),
443
+ draftReply: (args) => run(
444
+ "draftReply",
445
+ variablesFor(args),
446
+ // The ONE genuine human utterance in this module: what the user asked
447
+ // the drafter for. Everything else rode `variables`.
448
+ args.instruction !== void 0 && args.instruction.trim().length > 0 ? args.instruction.trim() : null,
449
+ args.signal
450
+ )
451
+ };
452
+ }
453
+
454
+ // src/core/cache.ts
455
+ function createReadCache(options) {
456
+ const now = options.now ?? (() => Date.now());
457
+ const maxEntries = options.maxEntries ?? 500;
458
+ const resolved = /* @__PURE__ */ new Map();
459
+ const inFlight = /* @__PURE__ */ new Map();
460
+ function evictIfNeeded() {
461
+ while (resolved.size > maxEntries) {
462
+ const oldest = resolved.keys().next();
463
+ if (oldest.done === true) return;
464
+ resolved.delete(oldest.value);
465
+ }
466
+ }
467
+ return {
468
+ read(key, load) {
469
+ const hit = resolved.get(key);
470
+ if (hit !== void 0 && hit.expiresAt > now()) {
471
+ return Promise.resolve(hit.value);
472
+ }
473
+ if (hit !== void 0) resolved.delete(key);
474
+ const pending = inFlight.get(key);
475
+ if (pending !== void 0) return pending;
476
+ const started = load().then((value) => {
477
+ resolved.set(key, { value, expiresAt: now() + options.ttlMs });
478
+ evictIfNeeded();
479
+ return value;
480
+ }).finally(() => {
481
+ inFlight.delete(key);
482
+ });
483
+ inFlight.set(key, started);
484
+ return started;
485
+ },
486
+ invalidate(key) {
487
+ resolved.delete(key);
488
+ inFlight.delete(key);
489
+ },
490
+ clear() {
491
+ resolved.clear();
492
+ inFlight.clear();
493
+ },
494
+ size() {
495
+ return resolved.size;
496
+ }
497
+ };
498
+ }
499
+
500
+ // src/core/channels.ts
501
+ var import_realtime = require("@ai-matrx/realtime");
502
+
503
+ // src/core/slot.ts
504
+ var NAMESPACE = "ai-matrx.messaging";
505
+ function globalSlot(name, create) {
506
+ const key = /* @__PURE__ */ Symbol.for(`${NAMESPACE}.${name}`);
507
+ const host = globalThis;
508
+ const existing = host[key];
509
+ if (existing !== void 0) return existing;
510
+ const created = create();
511
+ host[key] = created;
512
+ return created;
513
+ }
514
+
515
+ // src/core/channels.ts
516
+ function namespaces() {
517
+ return globalSlot("channel-namespaces", () => ({
518
+ inbox: (0, import_realtime.defineChannelNamespace)({
519
+ namespace: "messaging-inbox",
520
+ parts: ["userId"],
521
+ description: "One user's conversation list: new messages anywhere they participate, membership changes, and read-state updates."
522
+ }),
523
+ conversation: (0, import_realtime.defineChannelNamespace)({
524
+ namespace: "messaging-conversation",
525
+ parts: ["conversationId"],
526
+ description: "One conversation: message inserts/updates, presence, and typing \u2014 deliberately one channel, because they are one room."
527
+ })
528
+ }));
529
+ }
530
+ function inboxTopic(userId) {
531
+ return namespaces().inbox.topic({ userId });
532
+ }
533
+ function conversationTopic(conversationId) {
534
+ return namespaces().conversation.topic({ conversationId });
535
+ }
536
+ var MESSAGING_EVENTS = {
537
+ /** A freshly sent message, broadcast beside the Postgres Changes row so a
538
+ * receiver gets it on whichever path arrives first (both are deduped). */
539
+ message: "mx.message",
540
+ /** A message edited or soft-deleted. */
541
+ messageUpdated: "mx.message.updated",
542
+ /** An action receipt, so every viewer's chip settles at once. */
543
+ actionSettled: "mx.action.settled"
544
+ };
545
+
546
+ // src/core/engine.ts
547
+ var import_realtime3 = require("@ai-matrx/realtime");
548
+
549
+ // src/core/outbox.ts
550
+ var import_realtime2 = require("@ai-matrx/realtime");
551
+ function createMemoryOutboxStorage() {
552
+ let held = [];
553
+ return {
554
+ name: "memory",
555
+ durable: false,
556
+ load: () => held,
557
+ save: (entries) => {
558
+ held = entries;
559
+ }
560
+ };
561
+ }
562
+ function createWebOutboxStorage(args) {
563
+ const key = args.key ?? "ai-matrx.messaging.outbox";
564
+ let storage = args.storage ?? null;
565
+ if (storage === null) {
566
+ try {
567
+ const candidate = globalThis.localStorage;
568
+ storage = candidate ?? null;
569
+ } catch {
570
+ storage = null;
571
+ }
572
+ }
573
+ if (storage === null) {
574
+ args.onFallback?.(
575
+ "localStorage is unavailable, so queued messages will NOT survive a reload. Inject an OutboxStorage on <MessagingProvider> to restore durability."
576
+ );
577
+ return createMemoryOutboxStorage();
578
+ }
579
+ const backing = storage;
580
+ return {
581
+ name: "web-storage",
582
+ durable: true,
583
+ load() {
584
+ try {
585
+ const raw = backing.getItem(key);
586
+ if (raw === null) return [];
587
+ const parsed = JSON.parse(raw);
588
+ return Array.isArray(parsed) ? parsed : [];
589
+ } catch {
590
+ return [];
591
+ }
592
+ },
593
+ save(entries) {
594
+ try {
595
+ backing.setItem(key, JSON.stringify(entries));
596
+ } catch {
597
+ args.onFallback?.(
598
+ "Writing the outbox to localStorage failed (quota or private mode); queued messages are in memory only for this session."
599
+ );
600
+ }
601
+ }
602
+ };
603
+ }
604
+ var DEFAULT_BACKOFF = [0, 1e3, 3e3, 8e3, 2e4];
605
+ function createOutbox(options) {
606
+ const timers = options.timers ?? {
607
+ setTimeout: (run, ms) => setTimeout(run, ms),
608
+ clearTimeout: (handle) => clearTimeout(handle),
609
+ now: () => Date.now()
610
+ };
611
+ const storage = options.storage ?? createMemoryOutboxStorage();
612
+ const maxAttempts = options.maxAttempts ?? DEFAULT_BACKOFF.length;
613
+ const backoff = options.backoffMs ?? DEFAULT_BACKOFF;
614
+ let entries = [...storage.load()].map((entry) => ({
615
+ ...entry,
616
+ // Anything found mid-`sending` after a reload is genuinely unknown: it may
617
+ // or may not have reached the database. It goes back to `queued` and the
618
+ // idempotency key makes the re-send safe — that is exactly what the key is
619
+ // for. Marking it failed instead would strand a message that was typed.
620
+ state: entry.state === "sending" ? "queued" : entry.state
621
+ }));
622
+ let timer = null;
623
+ let disposed = false;
624
+ function persist() {
625
+ storage.save(entries);
626
+ options.onChange(entries);
627
+ }
628
+ function delayFor(attempts) {
629
+ return backoff[Math.min(attempts, backoff.length - 1)] ?? 0;
630
+ }
631
+ function schedule(ms) {
632
+ if (disposed || timer !== null) return;
633
+ timer = timers.setTimeout(() => {
634
+ timer = null;
635
+ void pump();
636
+ }, ms);
637
+ }
638
+ async function pump() {
639
+ if (disposed) return;
640
+ const next = entries.find((entry) => entry.state === "queued");
641
+ if (next === void 0) return;
642
+ entries = entries.map(
643
+ (entry) => entry.id === next.id ? { ...entry, state: "sending" } : entry
644
+ );
645
+ persist();
646
+ try {
647
+ const message = await options.send(next.draft, next.clientMessageId);
648
+ entries = entries.filter((entry) => entry.id !== next.id);
649
+ persist();
650
+ options.onSent(next, message);
651
+ schedule(0);
652
+ } catch (error) {
653
+ const attempts = next.attempts + 1;
654
+ const reason = error instanceof MessagingError ? error.message : String(error?.message ?? error);
655
+ const exhausted = attempts >= maxAttempts;
656
+ entries = entries.map(
657
+ (entry) => entry.id === next.id ? {
658
+ ...entry,
659
+ attempts,
660
+ state: exhausted ? "failed" : "queued",
661
+ failureReason: reason
662
+ } : entry
663
+ );
664
+ persist();
665
+ if (exhausted) {
666
+ options.onDiagnostic?.(
667
+ `Message could not be sent after ${attempts} attempts: ${reason}. It is still in the outbox \u2014 offer the user Retry or Discard; never drop it.`
668
+ );
669
+ schedule(0);
670
+ } else {
671
+ schedule(delayFor(attempts));
672
+ }
673
+ }
674
+ }
675
+ const outbox = {
676
+ entries: () => entries,
677
+ enqueue(draft) {
678
+ const clientMessageId = `mx-${(0, import_realtime2.randomId)()}`;
679
+ entries = [
680
+ ...entries,
681
+ {
682
+ id: (0, import_realtime2.randomId)(),
683
+ clientMessageId,
684
+ draft,
685
+ queuedAt: timers.now(),
686
+ attempts: 0,
687
+ state: "queued",
688
+ failureReason: null
689
+ }
690
+ ];
691
+ persist();
692
+ schedule(0);
693
+ return clientMessageId;
694
+ },
695
+ retry(entryId) {
696
+ entries = entries.map(
697
+ (entry) => entry.id === entryId ? { ...entry, state: "queued", attempts: 0, failureReason: null } : entry
698
+ );
699
+ persist();
700
+ schedule(0);
701
+ },
702
+ discard(entryId) {
703
+ entries = entries.filter((entry) => entry.id !== entryId);
704
+ persist();
705
+ },
706
+ flush() {
707
+ entries = entries.map(
708
+ (entry) => entry.state === "failed" ? { ...entry, state: "queued", attempts: 0 } : entry
709
+ );
710
+ persist();
711
+ schedule(0);
712
+ },
713
+ pendingFor(conversationId) {
714
+ return entries.filter((entry) => entry.draft.conversationId === conversationId);
715
+ },
716
+ dispose() {
717
+ disposed = true;
718
+ if (timer !== null) timers.clearTimeout(timer);
719
+ timer = null;
720
+ }
721
+ };
722
+ if (entries.length > 0) {
723
+ options.onDiagnostic?.(
724
+ `Restored ${entries.length} unsent message(s) from the ${storage.name} outbox.`
725
+ );
726
+ schedule(0);
727
+ }
728
+ return outbox;
729
+ }
730
+
731
+ // src/core/projection.ts
732
+ function str(row, key) {
733
+ const value = row[key];
734
+ return typeof value === "string" && value.length > 0 ? value : null;
735
+ }
736
+ function requiredStr(row, key, operation) {
737
+ const value = str(row, key);
738
+ if (value === null) {
739
+ throw invalidResponse(operation, `required field "${key}" was ${JSON.stringify(row[key])}`);
740
+ }
741
+ return value;
742
+ }
743
+ function bool(row, key, fallback) {
744
+ const value = row[key];
745
+ return typeof value === "boolean" ? value : fallback;
746
+ }
747
+ function jsonObject(value) {
748
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
749
+ }
750
+ var CONVERSATION_TYPES = /* @__PURE__ */ new Set(["direct", "group", "org"]);
751
+ var MESSAGE_KINDS = /* @__PURE__ */ new Set([
752
+ "text",
753
+ "image",
754
+ "video",
755
+ "audio",
756
+ "file",
757
+ "system",
758
+ "action"
759
+ ]);
760
+ var ROLES = /* @__PURE__ */ new Set(["owner", "admin", "member"]);
761
+ var DELIVERY_STATES = /* @__PURE__ */ new Set([
762
+ "sending",
763
+ "sent",
764
+ "delivered",
765
+ "read",
766
+ "failed"
767
+ ]);
768
+ function projectUserSummary(row) {
769
+ const userId = requiredStr(row, "user_id", "projectUserSummary");
770
+ const displayName = str(row, "display_name") ?? str(row, "email") ?? userId;
771
+ return {
772
+ userId,
773
+ displayName,
774
+ email: str(row, "email"),
775
+ avatarUrl: str(row, "avatar_url"),
776
+ isAgent: bool(row, "is_agent", false)
777
+ };
778
+ }
779
+ function projectParticipants(value, operation) {
780
+ if (value === null || value === void 0) return [];
781
+ if (!Array.isArray(value)) {
782
+ throw invalidResponse(
783
+ operation,
784
+ `"participants" was ${typeof value}, not an array \u2014 the RPC's jsonb aggregate is malformed`
785
+ );
786
+ }
787
+ const summaries = [];
788
+ for (const entry of value) {
789
+ if (typeof entry !== "object" || entry === null) continue;
790
+ const record = entry;
791
+ const id = str(record, "user_id") ?? str(record, "id");
792
+ if (id === null) continue;
793
+ summaries.push(
794
+ projectUserSummary({
795
+ ...record,
796
+ user_id: id
797
+ })
798
+ );
799
+ }
800
+ return summaries;
801
+ }
802
+ function projectMessageAction(value) {
803
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
804
+ const record = value;
805
+ const kind = record["kind"];
806
+ if (typeof kind !== "string" || kind.length === 0) return null;
807
+ const version = record["version"];
808
+ return {
809
+ kind,
810
+ // A payload without a version is version 1 — the shape that predates the
811
+ // envelope. Refusing it would silently blank every message sent before the
812
+ // envelope existed.
813
+ version: typeof version === "number" && Number.isFinite(version) ? version : 1,
814
+ payload: jsonObject(record["payload"])
815
+ };
816
+ }
817
+ function projectAttachments(metadata) {
818
+ const raw = metadata["attachments"];
819
+ if (!Array.isArray(raw)) return [];
820
+ const attachments = [];
821
+ for (const entry of raw) {
822
+ if (typeof entry !== "object" || entry === null) continue;
823
+ const record = entry;
824
+ const fileId = str(record, "fileId") ?? str(record, "file_id");
825
+ if (fileId === null) continue;
826
+ const size = record["sizeBytes"] ?? record["size_bytes"];
827
+ const width = record["width"];
828
+ const height = record["height"];
829
+ attachments.push({
830
+ fileId,
831
+ fileName: str(record, "fileName") ?? str(record, "file_name") ?? fileId,
832
+ mimeType: str(record, "mimeType") ?? str(record, "mime_type"),
833
+ sizeBytes: typeof size === "number" ? size : null,
834
+ width: typeof width === "number" ? width : null,
835
+ height: typeof height === "number" ? height : null
836
+ });
837
+ }
838
+ return attachments;
839
+ }
840
+ function projectReferences(metadata) {
841
+ const raw = metadata["references"];
842
+ if (!Array.isArray(raw)) return [];
843
+ const references = [];
844
+ for (const entry of raw) {
845
+ if (typeof entry !== "object" || entry === null) continue;
846
+ const record = entry;
847
+ const entityType = str(record, "entityType") ?? str(record, "entity_type");
848
+ const entityId = str(record, "entityId") ?? str(record, "entity_id");
849
+ if (entityType === null || entityId === null) continue;
850
+ const href = str(record, "href");
851
+ references.push({
852
+ entityType,
853
+ entityId,
854
+ label: str(record, "label") ?? entityId,
855
+ ...href !== null ? { href } : {}
856
+ });
857
+ }
858
+ return references;
859
+ }
860
+ function projectMessage(row, fallbackOrganizationId) {
861
+ const operation = "projectMessage";
862
+ const metadata = jsonObject(row["metadata"]);
863
+ const kindRaw = str(row, "message_type") ?? "text";
864
+ const stateRaw = str(row, "status") ?? "sent";
865
+ const replyTo = str(row, "reply_to_id");
866
+ const clientMessageId = str(row, "client_message_id");
867
+ const action = projectMessageAction(row["action_data"]);
868
+ return {
869
+ id: requiredStr(row, "id", operation),
870
+ conversationId: requiredStr(row, "conversation_id", operation),
871
+ senderId: requiredStr(row, "sender_id", operation),
872
+ organizationId: str(row, "organization_id") ?? fallbackOrganizationId,
873
+ content: typeof row["content"] === "string" ? row["content"] : "",
874
+ kind: MESSAGE_KINDS.has(kindRaw) ? kindRaw : "text",
875
+ // A row read from the DB is at least `sent`. `sending`/`failed` are outbox
876
+ // states and can never be projected from a persisted row.
877
+ deliveryState: DELIVERY_STATES.has(stateRaw) && stateRaw !== "sending" && stateRaw !== "failed" ? stateRaw : "sent",
878
+ replyToId: replyTo === null ? null : replyTo,
879
+ clientMessageId: clientMessageId === null ? null : clientMessageId,
880
+ createdAt: requiredStr(row, "created_at", operation),
881
+ editedAt: str(row, "edited_at"),
882
+ deletedAt: str(row, "deleted_at"),
883
+ deletedForEveryone: bool(row, "deleted_for_everyone", false),
884
+ action,
885
+ attachments: projectAttachments(metadata),
886
+ references: projectReferences(metadata),
887
+ metadata
888
+ };
889
+ }
890
+ function projectParticipantRole(value) {
891
+ return typeof value === "string" && ROLES.has(value) ? value : "member";
892
+ }
893
+ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
894
+ const operation = "projectConversationSummary";
895
+ const id = requiredStr(row, "conversation_id", operation);
896
+ const typeRaw = str(row, "conversation_type") ?? "direct";
897
+ const type = CONVERSATION_TYPES.has(typeRaw) ? typeRaw : "direct";
898
+ const participants = projectParticipants(row["participants"], operation);
899
+ const groupName = str(row, "group_name");
900
+ const groupImageUrl = str(row, "group_image_url");
901
+ const createdBy = str(row, "created_by");
902
+ const updatedAt = str(row, "conversation_updated_at") ?? str(row, "updated_at");
903
+ const createdAt = str(row, "conversation_created_at") ?? str(row, "created_at") ?? updatedAt;
904
+ const lastMessageAt = str(row, "last_message_at");
905
+ const lastSender = str(row, "last_message_sender_id");
906
+ const unreadRaw = row["unread_count"];
907
+ if (createdAt === null || updatedAt === null) {
908
+ throw invalidResponse(operation, `conversation ${id} carried no timestamps`);
909
+ }
910
+ const others = participants.filter((participant) => participant.userId !== viewerId);
911
+ const displayName = type === "direct" ? others[0]?.displayName ?? "Direct message" : groupName ?? "Group conversation";
912
+ const displayImageUrl = type === "direct" ? others[0]?.avatarUrl ?? null : groupImageUrl;
913
+ return {
914
+ conversation: {
915
+ id,
916
+ type,
917
+ groupName,
918
+ groupImageUrl,
919
+ createdBy: createdBy === null ? null : createdBy,
920
+ organizationId: str(row, "organization_id") ?? fallbackOrganizationId,
921
+ createdAt,
922
+ updatedAt,
923
+ metadata: jsonObject(row["metadata"])
924
+ },
925
+ participants,
926
+ lastMessageContent: str(row, "last_message_content"),
927
+ lastMessageSenderId: lastSender === null ? null : lastSender,
928
+ lastMessageAt,
929
+ unreadCount: typeof unreadRaw === "number" && Number.isFinite(unreadRaw) && unreadRaw > 0 ? Math.floor(unreadRaw) : 0,
930
+ isMuted: bool(row, "is_muted", false),
931
+ isArchived: bool(row, "is_archived", false),
932
+ displayName,
933
+ displayImageUrl,
934
+ // The keyset sort value: the last message if there is one, else the
935
+ // conversation's own update stamp. Empty conversations must still sort.
936
+ sortAt: lastMessageAt ?? updatedAt
937
+ };
938
+ }
939
+
940
+ // src/core/store.ts
941
+ function timeOf(message) {
942
+ const stamp = message.editedAt ?? message.createdAt;
943
+ const parsed = Date.parse(stamp);
944
+ return Number.isFinite(parsed) ? parsed : 0;
945
+ }
946
+ function compare(a, b) {
947
+ if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
948
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
949
+ }
950
+ function sameMessage(a, b) {
951
+ if (a.id === b.id) return true;
952
+ const key = b.clientMessageId;
953
+ return key !== null && a.clientMessageId === key;
954
+ }
955
+ function insertOrdered(messages, message) {
956
+ const next = [...messages];
957
+ let index = next.length;
958
+ while (index > 0) {
959
+ const candidate = next[index - 1];
960
+ if (candidate === void 0 || compare(candidate, message) <= 0) break;
961
+ index -= 1;
962
+ }
963
+ next.splice(index, 0, message);
964
+ return next;
965
+ }
966
+ function createMessagingStore() {
967
+ let conversations = [];
968
+ let hasMoreConversations = false;
969
+ let hasLoadedConversations = false;
970
+ let threads = /* @__PURE__ */ new Map();
971
+ let activeConversationId = null;
972
+ const listeners = /* @__PURE__ */ new Set();
973
+ let cached = null;
974
+ function snapshot() {
975
+ if (cached !== null) return cached;
976
+ cached = {
977
+ conversations,
978
+ hasMoreConversations,
979
+ hasLoadedConversations,
980
+ threads,
981
+ activeConversationId,
982
+ totalUnreadConversations: conversations.filter((item) => item.unreadCount > 0).length
983
+ };
984
+ return cached;
985
+ }
986
+ function emit() {
987
+ cached = null;
988
+ const next = snapshot();
989
+ listeners.forEach((listener) => listener(next));
990
+ }
991
+ function normalizeConversations(items) {
992
+ const active = activeConversationId;
993
+ const zeroed = active === null ? items : items.map(
994
+ (item) => item.conversation.id === active && item.unreadCount !== 0 ? { ...item, unreadCount: 0 } : item
995
+ );
996
+ return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
997
+ }
998
+ function threadFor(id) {
999
+ return threads.get(id) ?? {
1000
+ conversationId: id,
1001
+ messages: [],
1002
+ hasMoreOlder: false,
1003
+ latestAt: null
1004
+ };
1005
+ }
1006
+ function writeThread(thread) {
1007
+ const next = new Map(threads);
1008
+ const latest = thread.messages.at(-1);
1009
+ next.set(thread.conversationId, {
1010
+ ...thread,
1011
+ latestAt: latest?.createdAt ?? thread.latestAt
1012
+ });
1013
+ threads = next;
1014
+ }
1015
+ const store = {
1016
+ snapshot,
1017
+ subscribe(listener) {
1018
+ listeners.add(listener);
1019
+ return () => {
1020
+ listeners.delete(listener);
1021
+ };
1022
+ },
1023
+ setConversations(items, hasMore) {
1024
+ conversations = normalizeConversations(items);
1025
+ hasMoreConversations = hasMore;
1026
+ hasLoadedConversations = true;
1027
+ emit();
1028
+ },
1029
+ appendConversations(items, hasMore) {
1030
+ const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
1031
+ items.forEach((item) => byId.set(item.conversation.id, item));
1032
+ conversations = normalizeConversations([...byId.values()]);
1033
+ hasMoreConversations = hasMore;
1034
+ emit();
1035
+ },
1036
+ upsertConversation(item) {
1037
+ const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
1038
+ byId.set(item.conversation.id, item);
1039
+ conversations = normalizeConversations([...byId.values()]);
1040
+ emit();
1041
+ },
1042
+ removeConversation(id) {
1043
+ conversations = conversations.filter((item) => item.conversation.id !== id);
1044
+ const next = new Map(threads);
1045
+ next.delete(id);
1046
+ threads = next;
1047
+ emit();
1048
+ },
1049
+ setActiveConversation(id) {
1050
+ activeConversationId = id;
1051
+ conversations = normalizeConversations(conversations);
1052
+ emit();
1053
+ },
1054
+ setThread(id, messages, args = {}) {
1055
+ writeThread({
1056
+ conversationId: id,
1057
+ messages: [...messages].sort(compare),
1058
+ hasMoreOlder: args.hasMoreOlder ?? false,
1059
+ latestAt: null
1060
+ });
1061
+ emit();
1062
+ },
1063
+ prependOlder(id, messages, hasMoreOlder) {
1064
+ const thread = threadFor(id);
1065
+ const known = new Set(thread.messages.map((message) => message.id));
1066
+ const fresh = messages.filter((message) => !known.has(message.id));
1067
+ writeThread({
1068
+ ...thread,
1069
+ messages: [...fresh, ...thread.messages].sort(compare),
1070
+ hasMoreOlder
1071
+ });
1072
+ emit();
1073
+ },
1074
+ ingest(message) {
1075
+ const thread = threadFor(message.conversationId);
1076
+ const index = thread.messages.findIndex((held2) => sameMessage(held2, message));
1077
+ if (index === -1) {
1078
+ writeThread({ ...thread, messages: insertOrdered(thread.messages, message) });
1079
+ emit();
1080
+ return "added";
1081
+ }
1082
+ const held = thread.messages[index];
1083
+ if (held === void 0) return "dropped-duplicate";
1084
+ const heldIsOptimistic = held.deliveryState === "sending" || held.deliveryState === "failed";
1085
+ if (!heldIsOptimistic && timeOf(message) < timeOf(held)) return "dropped-stale";
1086
+ if (!heldIsOptimistic && held.id === message.id && timeOf(message) === timeOf(held)) {
1087
+ return "dropped-duplicate";
1088
+ }
1089
+ const merged = {
1090
+ ...held,
1091
+ ...message,
1092
+ // Never lose the client key — it is what future echoes match on.
1093
+ clientMessageId: message.clientMessageId ?? held.clientMessageId
1094
+ };
1095
+ const messages = [...thread.messages];
1096
+ messages[index] = merged;
1097
+ writeThread({ ...thread, messages: messages.sort(compare) });
1098
+ emit();
1099
+ return "merged";
1100
+ },
1101
+ ingestMany(messages) {
1102
+ messages.forEach((message) => {
1103
+ store.ingest(message);
1104
+ });
1105
+ },
1106
+ removeMessage(conversationId, messageId) {
1107
+ const thread = threadFor(conversationId);
1108
+ writeThread({
1109
+ ...thread,
1110
+ messages: thread.messages.filter((message) => message.id !== messageId)
1111
+ });
1112
+ emit();
1113
+ },
1114
+ applyLastMessage(message, args = {}) {
1115
+ const existing = conversations.find(
1116
+ (item) => item.conversation.id === message.conversationId
1117
+ );
1118
+ if (existing === void 0) return;
1119
+ if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1120
+ if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1121
+ const isActive = activeConversationId === message.conversationId;
1122
+ const shouldCount = args.incrementUnread === true && !isActive;
1123
+ conversations = normalizeConversations(
1124
+ conversations.map(
1125
+ (item) => item.conversation.id === message.conversationId ? {
1126
+ ...item,
1127
+ lastMessageContent: message.content,
1128
+ lastMessageSenderId: message.senderId,
1129
+ lastMessageAt: message.createdAt,
1130
+ sortAt: message.createdAt,
1131
+ unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1132
+ } : item
1133
+ )
1134
+ );
1135
+ emit();
1136
+ },
1137
+ markConversationRead(id) {
1138
+ conversations = conversations.map(
1139
+ (item) => item.conversation.id === id ? { ...item, unreadCount: 0 } : item
1140
+ );
1141
+ emit();
1142
+ },
1143
+ setUnreadCount(id, count) {
1144
+ conversations = normalizeConversations(
1145
+ conversations.map(
1146
+ (item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
1147
+ )
1148
+ );
1149
+ emit();
1150
+ }
1151
+ };
1152
+ return store;
1153
+ }
1154
+ function optimisticMessage(args) {
1155
+ const at = new Date(args.now?.() ?? Date.now()).toISOString();
1156
+ return {
1157
+ // A temporary id that can never collide with a uuid from the database.
1158
+ id: `optimistic:${args.clientMessageId}`,
1159
+ conversationId: args.conversationId,
1160
+ senderId: args.senderId,
1161
+ organizationId: args.organizationId,
1162
+ content: args.content,
1163
+ kind: args.kind ?? "text",
1164
+ deliveryState: "sending",
1165
+ replyToId: args.replyToId ?? null,
1166
+ clientMessageId: args.clientMessageId,
1167
+ createdAt: at,
1168
+ editedAt: null,
1169
+ deletedAt: null,
1170
+ deletedForEveryone: false,
1171
+ action: args.action ?? null,
1172
+ attachments: args.attachments ?? [],
1173
+ references: args.references ?? [],
1174
+ metadata: {}
1175
+ };
1176
+ }
1177
+
1178
+ // src/core/engine.ts
1179
+ function createMessagingEngine(options) {
1180
+ const { repository, manager, identity } = options;
1181
+ const store = createMessagingStore();
1182
+ const conversationPageSize = options.conversationPageSize ?? 30;
1183
+ const messagePageSize = options.messagePageSize ?? 50;
1184
+ const openChannels = /* @__PURE__ */ new Map();
1185
+ let inboxChannel = null;
1186
+ let conversationCursor = null;
1187
+ let disposed = false;
1188
+ function report(event) {
1189
+ options.onDiagnostic?.(event);
1190
+ }
1191
+ function reportError(error, operation) {
1192
+ const normalized = normalizeMessagingError(error, operation);
1193
+ report({
1194
+ // A missing session is a NORMAL lifecycle moment, not a red error. This
1195
+ // one line is the cure for "909 captured errors in 0.6s".
1196
+ level: normalized.code === "session-unavailable" ? "warn" : "error",
1197
+ message: normalized.message,
1198
+ remedy: normalized.remedy
1199
+ });
1200
+ }
1201
+ const outbox = createOutbox({
1202
+ ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
1203
+ send: (draft, clientMessageId) => repository.insertMessage(draft, clientMessageId),
1204
+ onSent: (entry, message) => {
1205
+ store.ingest(message);
1206
+ broadcastMessage(message);
1207
+ store.applyLastMessage(message);
1208
+ void entry;
1209
+ },
1210
+ onChange: (entries) => {
1211
+ entries.forEach((entry) => {
1212
+ store.ingest(
1213
+ optimisticMessage({
1214
+ conversationId: entry.draft.conversationId,
1215
+ senderId: identity.userId,
1216
+ organizationId: identity.organizationId,
1217
+ content: entry.draft.content,
1218
+ clientMessageId: entry.clientMessageId,
1219
+ ...entry.draft.kind !== void 0 ? { kind: entry.draft.kind } : {},
1220
+ ...entry.draft.replyToId !== void 0 ? { replyToId: entry.draft.replyToId } : {},
1221
+ ...entry.draft.action !== void 0 ? { action: entry.draft.action } : {},
1222
+ ...entry.draft.attachments !== void 0 ? { attachments: [...entry.draft.attachments] } : {},
1223
+ ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {}
1224
+ })
1225
+ );
1226
+ });
1227
+ },
1228
+ onDiagnostic: (message) => report({ level: "warn", message })
1229
+ });
1230
+ function broadcastMessage(message) {
1231
+ openChannels.get(message.conversationId)?.send(MESSAGING_EVENTS.message, message);
1232
+ }
1233
+ async function reloadInbox() {
1234
+ const page = await repository.listConversations({ limit: conversationPageSize });
1235
+ conversationCursor = page.nextCursor;
1236
+ store.setConversations(page.items, page.hasMore);
1237
+ }
1238
+ async function backfillConversation(id) {
1239
+ const thread = store.snapshot().threads.get(id);
1240
+ const since = thread?.latestAt ?? null;
1241
+ try {
1242
+ if (since === null) {
1243
+ const page = await repository.listMessages(id, { limit: messagePageSize });
1244
+ store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1245
+ return;
1246
+ }
1247
+ const missed = await repository.messagesSince(id, since);
1248
+ store.ingestMany(missed);
1249
+ if (missed.length > 0) {
1250
+ report({
1251
+ level: "info",
1252
+ message: `Recovered ${missed.length} message(s) missed while disconnected.`
1253
+ });
1254
+ }
1255
+ } catch (error) {
1256
+ reportError(error, "backfillConversation");
1257
+ }
1258
+ }
1259
+ const engine = {
1260
+ store,
1261
+ outbox,
1262
+ identity,
1263
+ async start() {
1264
+ await reloadInbox();
1265
+ if (disposed || inboxChannel !== null) return;
1266
+ inboxChannel = manager.open({
1267
+ topic: inboxTopic(identity.userId),
1268
+ postgresChanges: [
1269
+ {
1270
+ event: "INSERT",
1271
+ schema: "communication",
1272
+ table: "dm_messages",
1273
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1274
+ onChange: ({ row }) => {
1275
+ if (row === null) return;
1276
+ const message = projectMessage(row, identity.organizationId);
1277
+ const known = store.snapshot().conversations.some((item) => item.conversation.id === message.conversationId);
1278
+ if (!known) {
1279
+ void reloadInbox();
1280
+ return;
1281
+ }
1282
+ store.ingest(message);
1283
+ const isMine = message.senderId === identity.userId;
1284
+ store.applyLastMessage(message, { incrementUnread: !isMine });
1285
+ if (!isMine) options.onIncoming?.(message);
1286
+ }
1287
+ },
1288
+ {
1289
+ event: "*",
1290
+ schema: "communication",
1291
+ table: "dm_conversation_participants",
1292
+ filter: `user_id=eq.${identity.userId}`,
1293
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1294
+ onChange: () => {
1295
+ void reloadInbox();
1296
+ }
1297
+ }
1298
+ ],
1299
+ // THE BACKFILL DOOR for the inbox.
1300
+ onBackfill: () => {
1301
+ void reloadInbox().catch((error) => reportError(error, "inboxBackfill"));
1302
+ outbox.flush();
1303
+ }
1304
+ });
1305
+ },
1306
+ async loadMoreConversations() {
1307
+ if (conversationCursor === null) return;
1308
+ try {
1309
+ const page = await repository.listConversations({
1310
+ limit: conversationPageSize,
1311
+ cursor: conversationCursor
1312
+ });
1313
+ conversationCursor = page.nextCursor;
1314
+ store.appendConversations(page.items, page.hasMore);
1315
+ } catch (error) {
1316
+ reportError(error, "loadMoreConversations");
1317
+ }
1318
+ },
1319
+ async openConversation(id) {
1320
+ store.setActiveConversation(id);
1321
+ try {
1322
+ const page = await repository.listMessages(id, { limit: messagePageSize });
1323
+ store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1324
+ } catch (error) {
1325
+ reportError(error, "openConversation");
1326
+ }
1327
+ if (openChannels.has(id) || disposed) return;
1328
+ const handle = manager.open({
1329
+ topic: conversationTopic(id),
1330
+ postgresChanges: [
1331
+ {
1332
+ event: "*",
1333
+ schema: "communication",
1334
+ table: "dm_messages",
1335
+ filter: `conversation_id=eq.${id}`,
1336
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1337
+ fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1338
+ updatedAtField: "updated_at",
1339
+ updatedByField: "updated_by",
1340
+ onChange: ({ row }) => {
1341
+ if (row === null) return;
1342
+ store.ingest(projectMessage(row, identity.organizationId));
1343
+ }
1344
+ }
1345
+ ],
1346
+ broadcast: [
1347
+ {
1348
+ event: MESSAGING_EVENTS.message,
1349
+ onMessage: ({ data }) => {
1350
+ if (typeof data !== "object" || data === null) return;
1351
+ store.ingest(data);
1352
+ }
1353
+ }
1354
+ ],
1355
+ onBackfill: () => {
1356
+ void backfillConversation(id);
1357
+ outbox.flush();
1358
+ }
1359
+ });
1360
+ openChannels.set(id, handle);
1361
+ },
1362
+ closeConversation(id) {
1363
+ openChannels.get(id)?.close();
1364
+ openChannels.delete(id);
1365
+ if (store.snapshot().activeConversationId === id) {
1366
+ store.setActiveConversation(null);
1367
+ }
1368
+ },
1369
+ async loadOlderMessages(id) {
1370
+ const thread = store.snapshot().threads.get(id);
1371
+ const oldest = thread?.messages[0];
1372
+ if (thread === void 0 || oldest === void 0 || !thread.hasMoreOlder) return;
1373
+ try {
1374
+ const page = await repository.listMessages(id, {
1375
+ limit: messagePageSize,
1376
+ cursor: { beforeCreatedAt: oldest.createdAt, beforeMessageId: oldest.id }
1377
+ });
1378
+ store.prependOlder(id, page.items, page.hasMore);
1379
+ } catch (error) {
1380
+ reportError(error, "loadOlderMessages");
1381
+ }
1382
+ },
1383
+ send(draft) {
1384
+ if (draft.content.trim().length === 0 && (draft.attachments ?? []).length === 0) {
1385
+ throw new MessagingError(
1386
+ "misconfigured",
1387
+ "send: empty draft",
1388
+ "A message needs text or at least one attachment. The composer disables Send for an empty draft rather than queueing nothing."
1389
+ );
1390
+ }
1391
+ return outbox.enqueue(draft);
1392
+ },
1393
+ retry: (entryId) => outbox.retry(entryId),
1394
+ discard: (entryId) => outbox.discard(entryId),
1395
+ async editMessage(id, conversationId, content) {
1396
+ try {
1397
+ const message = await repository.editMessage(id, content);
1398
+ store.ingest(message);
1399
+ broadcastMessage(message);
1400
+ } catch (error) {
1401
+ reportError(error, "editMessage");
1402
+ throw normalizeMessagingError(error, "editMessage");
1403
+ }
1404
+ void conversationId;
1405
+ },
1406
+ async deleteMessage(id, conversationId, forEveryone) {
1407
+ try {
1408
+ await repository.deleteMessage(id, forEveryone);
1409
+ store.removeMessage(conversationId, id);
1410
+ } catch (error) {
1411
+ reportError(error, "deleteMessage");
1412
+ throw normalizeMessagingError(error, "deleteMessage");
1413
+ }
1414
+ },
1415
+ async markRead(id) {
1416
+ store.markConversationRead(id);
1417
+ try {
1418
+ await repository.markRead(id);
1419
+ } catch (error) {
1420
+ reportError(error, "markRead");
1421
+ }
1422
+ },
1423
+ async startDirectConversation(otherUserId) {
1424
+ const id = await repository.getOrCreateDirectConversation(otherUserId);
1425
+ await reloadInbox();
1426
+ return id;
1427
+ },
1428
+ dispose() {
1429
+ disposed = true;
1430
+ outbox.dispose();
1431
+ openChannels.forEach((handle) => handle.close());
1432
+ openChannels.clear();
1433
+ inboxChannel?.close();
1434
+ inboxChannel = null;
1435
+ }
1436
+ };
1437
+ return engine;
1438
+ }
1439
+ function messagingClientId() {
1440
+ return (0, import_realtime3.clientSessionId)();
1441
+ }
1442
+
1443
+ // src/core/format.ts
1444
+ var MINUTE = 6e4;
1445
+ var HOUR = 60 * MINUTE;
1446
+ var DAY = 24 * HOUR;
1447
+ function isSameDay(a, b) {
1448
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
1449
+ }
1450
+ function formatConversationTime(isoString, now = Date.now(), locale) {
1451
+ if (isoString === null) return "";
1452
+ const parsed = Date.parse(isoString);
1453
+ if (!Number.isFinite(parsed)) return "";
1454
+ const then = new Date(parsed);
1455
+ const today = new Date(now);
1456
+ if (isSameDay(then, today)) {
1457
+ return then.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
1458
+ }
1459
+ const yesterday = new Date(now - DAY);
1460
+ if (isSameDay(then, yesterday)) return "Yesterday";
1461
+ if (now - parsed < 7 * DAY) return then.toLocaleDateString(locale, { weekday: "short" });
1462
+ return then.toLocaleDateString(locale, { month: "short", day: "numeric" });
1463
+ }
1464
+ function formatMessageTime(isoString, locale) {
1465
+ const parsed = Date.parse(isoString);
1466
+ if (!Number.isFinite(parsed)) return "";
1467
+ return new Date(parsed).toLocaleTimeString(locale, {
1468
+ hour: "numeric",
1469
+ minute: "2-digit"
1470
+ });
1471
+ }
1472
+ function formatDateSeparator(isoString, now = Date.now(), locale) {
1473
+ const parsed = Date.parse(isoString);
1474
+ if (!Number.isFinite(parsed)) return "";
1475
+ const then = new Date(parsed);
1476
+ const today = new Date(now);
1477
+ if (isSameDay(then, today)) return "Today";
1478
+ if (isSameDay(then, new Date(now - DAY))) return "Yesterday";
1479
+ if (then.getFullYear() === today.getFullYear()) {
1480
+ return then.toLocaleDateString(locale, { month: "long", day: "numeric" });
1481
+ }
1482
+ return then.toLocaleDateString(locale, {
1483
+ year: "numeric",
1484
+ month: "long",
1485
+ day: "numeric"
1486
+ });
1487
+ }
1488
+ function getInitials(name) {
1489
+ const parts = name.trim().split(/\s+/).filter(Boolean);
1490
+ if (parts.length === 0) return "?";
1491
+ const first = parts[0]?.[0] ?? "";
1492
+ const last = parts.length > 1 ? parts.at(-1)?.[0] ?? "" : "";
1493
+ return `${first}${last}`.toUpperCase() || "?";
1494
+ }
1495
+ function avatarPaletteIndex(seed, buckets = 8) {
1496
+ let hash = 0;
1497
+ for (let index = 0; index < seed.length; index += 1) {
1498
+ hash = hash * 31 + seed.charCodeAt(index) | 0;
1499
+ }
1500
+ return Math.abs(hash) % buckets;
1501
+ }
1502
+ function groupMessages(messages, args = {}) {
1503
+ const windowMs = args.windowMs ?? 5 * MINUTE;
1504
+ const now = args.now ?? Date.now();
1505
+ const groups = [];
1506
+ let current = null;
1507
+ let previousDay = null;
1508
+ for (const message of messages) {
1509
+ const day = message.createdAt.slice(0, 10);
1510
+ const startsNewDay = day !== previousDay;
1511
+ previousDay = day;
1512
+ const last = current?.messages.at(-1);
1513
+ const withinWindow = last !== void 0 && Math.abs(Date.parse(message.createdAt) - Date.parse(last.createdAt)) <= windowMs;
1514
+ if (current !== null && current.senderId === message.senderId && withinWindow && !startsNewDay) {
1515
+ current.messages.push(message);
1516
+ continue;
1517
+ }
1518
+ if (current !== null) groups.push(current);
1519
+ current = {
1520
+ senderId: message.senderId,
1521
+ messages: [message],
1522
+ dateSeparator: startsNewDay ? formatDateSeparator(message.createdAt, now, args.locale) : null
1523
+ };
1524
+ }
1525
+ if (current !== null) groups.push(current);
1526
+ return groups;
1527
+ }
1528
+ function formatTypists(names) {
1529
+ if (names.length === 0) return null;
1530
+ if (names.length === 1) return `${names[0]} is typing\u2026`;
1531
+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing\u2026`;
1532
+ return `${names.length} people are typing\u2026`;
1533
+ }
1534
+ function participantNames(participants, excluding) {
1535
+ return participants.filter((participant) => participant.userId !== excluding).map((participant) => participant.displayName);
1536
+ }
1537
+ function formatLastSeen(lastSeenMs, now = Date.now()) {
1538
+ if (lastSeenMs === null) return "";
1539
+ const elapsed = now - lastSeenMs;
1540
+ if (elapsed < 2 * MINUTE) return "Active now";
1541
+ if (elapsed < HOUR) return `Active ${Math.round(elapsed / MINUTE)}m ago`;
1542
+ if (elapsed < DAY) return `Active ${Math.round(elapsed / HOUR)}h ago`;
1543
+ return `Active ${Math.round(elapsed / DAY)}d ago`;
1544
+ }
1545
+
1546
+ // src/core/repository.ts
1547
+ var MESSAGING_SCHEMA = "communication";
1548
+ var TABLES = {
1549
+ conversations: "dm_conversations",
1550
+ participants: "dm_conversation_participants",
1551
+ messages: "dm_messages"
1552
+ };
1553
+ var RPCS = {
1554
+ /** Atomic direct-conversation creation. Advisory-locks the unordered pair. */
1555
+ getOrCreateDirect: "dm_get_or_create_direct_conversation",
1556
+ /** Conversation list + participants + last message + unread, keyset paged. */
1557
+ conversationsWithDetails: "get_dm_conversations_with_details",
1558
+ unreadCount: "get_dm_unread_count",
1559
+ userInfo: "get_dm_user_info",
1560
+ isParticipant: "is_dm_participant"
1561
+ };
1562
+ var MESSAGE_COLUMNS = "id,conversation_id,sender_id,organization_id,content,message_type,status,reply_to_id,client_message_id,action_data,media_url,media_thumbnail_url,media_metadata,created_at,edited_at,deleted_at,deleted_for_everyone,metadata";
1563
+ var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
1564
+ function requireOrg(organizationId, operation) {
1565
+ if (typeof organizationId !== "string" || organizationId.length === 0) {
1566
+ throw new MessagingError(
1567
+ "misconfigured",
1568
+ `${operation}: no organization_id`,
1569
+ "Every conversation and message write carries an explicit organization_id (R5). Pass a real org on <MessagingProvider>; the package refuses the write rather than letting an unscoped row reach the database."
1570
+ );
1571
+ }
1572
+ return organizationId;
1573
+ }
1574
+ function createMessagingRepository(options) {
1575
+ const { client, identity } = options;
1576
+ const org = requireOrg(identity.organizationId, "createMessagingRepository");
1577
+ const userCache = createReadCache({
1578
+ ttlMs: options.userTtlMs ?? 5 * 6e4,
1579
+ ...options.now !== void 0 ? { now: options.now } : {}
1580
+ });
1581
+ const db = () => client.schema(MESSAGING_SCHEMA);
1582
+ async function withSessionRetry(operation, run) {
1583
+ try {
1584
+ return await run();
1585
+ } catch (error) {
1586
+ const normalized = normalizeMessagingError(error, operation);
1587
+ if (normalized.code !== "session-unavailable" || options.resolveSession === void 0) {
1588
+ throw normalized;
1589
+ }
1590
+ await options.resolveSession();
1591
+ try {
1592
+ return await run();
1593
+ } catch (retryError) {
1594
+ throw normalizeMessagingError(retryError, operation);
1595
+ }
1596
+ }
1597
+ }
1598
+ async function rpc(fn, args, operation) {
1599
+ const { data, error } = await db().rpc(fn, args);
1600
+ if (error !== null) throw normalizeMessagingError(error, operation);
1601
+ return data;
1602
+ }
1603
+ async function getUsers(userIds) {
1604
+ const unique = [...new Set(userIds)];
1605
+ const found = /* @__PURE__ */ new Map();
1606
+ const results = await Promise.all(
1607
+ unique.map(async (id) => {
1608
+ try {
1609
+ return await repository.getUser(id);
1610
+ } catch {
1611
+ return null;
1612
+ }
1613
+ })
1614
+ );
1615
+ results.forEach((summary) => {
1616
+ if (summary !== null) found.set(summary.userId, summary);
1617
+ });
1618
+ return found;
1619
+ }
1620
+ const repository = {
1621
+ identity,
1622
+ async listConversations(args = {}) {
1623
+ const limit = args.limit ?? 30;
1624
+ const operation = "listConversations";
1625
+ const rows = await withSessionRetry(
1626
+ operation,
1627
+ () => rpc(
1628
+ RPCS.conversationsWithDetails,
1629
+ {
1630
+ p_user_id: identity.userId,
1631
+ p_limit: limit + 1,
1632
+ p_before_sort_at: args.cursor?.beforeSortAt ?? null,
1633
+ p_before_conversation_id: args.cursor?.beforeConversationId ?? null
1634
+ },
1635
+ operation
1636
+ )
1637
+ );
1638
+ if (rows !== null && !Array.isArray(rows)) {
1639
+ throw invalidResponse(operation, `${RPCS.conversationsWithDetails} did not return rows`);
1640
+ }
1641
+ const projected = (rows ?? []).map(
1642
+ (row) => projectConversationSummary(row, identity.userId, org)
1643
+ );
1644
+ const hasMore = projected.length > limit;
1645
+ const items = hasMore ? projected.slice(0, limit) : projected;
1646
+ const last = items.at(-1);
1647
+ return {
1648
+ items,
1649
+ hasMore,
1650
+ nextCursor: hasMore && last !== void 0 ? { beforeSortAt: last.sortAt, beforeConversationId: last.conversation.id } : null
1651
+ };
1652
+ },
1653
+ async getConversation(id) {
1654
+ const operation = "getConversation";
1655
+ const { data, error } = await withSessionRetry(
1656
+ operation,
1657
+ async () => db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single()
1658
+ );
1659
+ if (error !== null) throw normalizeMessagingError(error, operation);
1660
+ if (data === null) throw invalidResponse(operation, `conversation ${id} returned no row`);
1661
+ const summary = projectConversationSummary(
1662
+ {
1663
+ conversation_id: data["id"],
1664
+ conversation_type: data["type"],
1665
+ group_name: data["group_name"],
1666
+ group_image_url: data["group_image_url"],
1667
+ conversation_created_at: data["created_at"],
1668
+ conversation_updated_at: data["updated_at"],
1669
+ organization_id: data["organization_id"],
1670
+ created_by: data["created_by"],
1671
+ metadata: data["metadata"],
1672
+ participants: [],
1673
+ unread_count: 0
1674
+ },
1675
+ identity.userId,
1676
+ org
1677
+ );
1678
+ return summary.conversation;
1679
+ },
1680
+ async listMessages(conversationId, args = {}) {
1681
+ const limit = args.limit ?? 50;
1682
+ const operation = "listMessages";
1683
+ const rows = await withSessionRetry(operation, async () => {
1684
+ let query = db().from(TABLES.messages).select(MESSAGE_COLUMNS).eq("conversation_id", conversationId);
1685
+ const cursor = args.cursor;
1686
+ if (cursor != null) {
1687
+ query = query.or(
1688
+ `created_at.lt.${cursor.beforeCreatedAt},and(created_at.eq.${cursor.beforeCreatedAt},id.lt.${cursor.beforeMessageId})`
1689
+ );
1690
+ }
1691
+ const { data, error } = await query.order("created_at", { ascending: false }).order("id", { ascending: false }).limit(limit + 1);
1692
+ if (error !== null) throw normalizeMessagingError(error, operation);
1693
+ return data ?? [];
1694
+ });
1695
+ const hasMore = rows.length > limit;
1696
+ const page = hasMore ? rows.slice(0, limit) : rows;
1697
+ const oldest = page.at(-1);
1698
+ const items = page.map((row) => projectMessage(row, org)).reverse();
1699
+ return {
1700
+ items,
1701
+ hasMore,
1702
+ nextCursor: hasMore && oldest !== void 0 ? {
1703
+ beforeCreatedAt: String(oldest["created_at"]),
1704
+ beforeMessageId: String(oldest["id"])
1705
+ } : null
1706
+ };
1707
+ },
1708
+ async messagesSince(conversationId, since) {
1709
+ const operation = "messagesSince";
1710
+ const rows = await withSessionRetry(operation, async () => {
1711
+ const { data, error } = await db().from(TABLES.messages).select(MESSAGE_COLUMNS).eq("conversation_id", conversationId).gt("created_at", since).order("created_at", { ascending: true }).limit(500);
1712
+ if (error !== null) throw normalizeMessagingError(error, operation);
1713
+ return data ?? [];
1714
+ });
1715
+ return rows.map((row) => projectMessage(row, org));
1716
+ },
1717
+ async getOrCreateDirectConversation(otherUserId) {
1718
+ const operation = "getOrCreateDirectConversation";
1719
+ const id = await withSessionRetry(
1720
+ operation,
1721
+ () => rpc(
1722
+ RPCS.getOrCreateDirect,
1723
+ {
1724
+ // The RPC's own guard requires an `authenticated` caller to pass
1725
+ // THEMSELVES as user1; passing them in the other slot is a denial,
1726
+ // not a preference.
1727
+ p_user1_id: identity.userId,
1728
+ p_user2_id: otherUserId,
1729
+ p_organization_id: org
1730
+ },
1731
+ operation
1732
+ )
1733
+ );
1734
+ if (typeof id !== "string" || id.length === 0) {
1735
+ throw invalidResponse(operation, `${RPCS.getOrCreateDirect} returned no conversation id`);
1736
+ }
1737
+ return id;
1738
+ },
1739
+ async createGroupConversation({ name, memberIds }) {
1740
+ const operation = "createGroupConversation";
1741
+ const { data, error } = await withSessionRetry(
1742
+ operation,
1743
+ async () => db().from(TABLES.conversations).insert({
1744
+ type: "group",
1745
+ group_name: name,
1746
+ organization_id: org,
1747
+ created_by: identity.userId
1748
+ }).select("id").single()
1749
+ );
1750
+ if (error !== null) throw normalizeMessagingError(error, operation);
1751
+ const conversationId = data?.["id"];
1752
+ if (typeof conversationId !== "string") {
1753
+ throw invalidResponse(operation, "insert returned no conversation id");
1754
+ }
1755
+ const members = [.../* @__PURE__ */ new Set([identity.userId, ...memberIds])];
1756
+ const { error: memberError } = await db().from(TABLES.participants).insert(
1757
+ members.map((userId) => ({
1758
+ conversation_id: conversationId,
1759
+ user_id: userId,
1760
+ role: userId === identity.userId ? "owner" : "member",
1761
+ organization_id: org,
1762
+ created_by: identity.userId
1763
+ }))
1764
+ );
1765
+ if (memberError !== null) throw normalizeMessagingError(memberError, operation);
1766
+ return conversationId;
1767
+ },
1768
+ async insertMessage(draft, clientMessageId) {
1769
+ const operation = "insertMessage";
1770
+ const { data, error } = await db().from(TABLES.messages).insert({
1771
+ conversation_id: draft.conversationId,
1772
+ sender_id: identity.userId,
1773
+ organization_id: org,
1774
+ created_by: identity.userId,
1775
+ content: draft.content,
1776
+ message_type: draft.kind ?? "text",
1777
+ status: "sent",
1778
+ reply_to_id: draft.replyToId ?? null,
1779
+ // THE IDEMPOTENCY KEY. It is what makes a retried send exactly-once
1780
+ // and what lets a receiver collapse the optimistic bubble with the
1781
+ // confirmed row instead of showing the message twice.
1782
+ client_message_id: clientMessageId,
1783
+ action_data: draft.action ?? null,
1784
+ metadata: {
1785
+ ...draft.metadata ?? {},
1786
+ ...draft.attachments !== void 0 && draft.attachments.length > 0 ? { attachments: draft.attachments } : {},
1787
+ ...draft.references !== void 0 && draft.references.length > 0 ? { references: draft.references } : {}
1788
+ }
1789
+ }).select(MESSAGE_COLUMNS).single();
1790
+ if (error !== null) throw normalizeMessagingError(error, operation);
1791
+ if (data === null) throw invalidResponse(operation, "insert returned no row");
1792
+ return projectMessage(data, org);
1793
+ },
1794
+ async editMessage(id, content) {
1795
+ const operation = "editMessage";
1796
+ const { data, error } = await db().from(TABLES.messages).update({
1797
+ content,
1798
+ edited_at: (/* @__PURE__ */ new Date()).toISOString(),
1799
+ updated_by: identity.userId
1800
+ }).eq("id", id).select(MESSAGE_COLUMNS).single();
1801
+ if (error !== null) throw normalizeMessagingError(error, operation);
1802
+ if (data === null) throw invalidResponse(operation, "update returned no row");
1803
+ return projectMessage(data, org);
1804
+ },
1805
+ async deleteMessage(id, forEveryone) {
1806
+ const operation = "deleteMessage";
1807
+ const { error } = await db().from(TABLES.messages).update({
1808
+ deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
1809
+ deleted_for_everyone: forEveryone,
1810
+ updated_by: identity.userId
1811
+ }).eq("id", id);
1812
+ if (error !== null) throw normalizeMessagingError(error, operation);
1813
+ },
1814
+ async markRead(conversationId, at) {
1815
+ const operation = "markRead";
1816
+ const { error } = await db().from(TABLES.participants).update({ last_read_at: at ?? (/* @__PURE__ */ new Date()).toISOString(), updated_by: identity.userId }).eq("conversation_id", conversationId).eq("user_id", identity.userId);
1817
+ if (error !== null) throw normalizeMessagingError(error, operation);
1818
+ },
1819
+ async setConversationFlags(conversationId, flags) {
1820
+ const operation = "setConversationFlags";
1821
+ const patch = { updated_by: identity.userId };
1822
+ if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
1823
+ if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
1824
+ const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", identity.userId);
1825
+ if (error !== null) throw normalizeMessagingError(error, operation);
1826
+ },
1827
+ async addMembers(conversationId, memberIds) {
1828
+ const operation = "addMembers";
1829
+ const { error } = await db().from(TABLES.participants).insert(
1830
+ memberIds.map((userId) => ({
1831
+ conversation_id: conversationId,
1832
+ user_id: userId,
1833
+ role: "member",
1834
+ organization_id: org,
1835
+ created_by: identity.userId
1836
+ }))
1837
+ );
1838
+ if (error !== null) throw normalizeMessagingError(error, operation);
1839
+ },
1840
+ async removeMember(conversationId, memberId) {
1841
+ const operation = "removeMember";
1842
+ const { error } = await db().from(TABLES.participants).update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_by: identity.userId }).eq("conversation_id", conversationId).eq("user_id", memberId);
1843
+ if (error !== null) throw normalizeMessagingError(error, operation);
1844
+ },
1845
+ async setMemberRole(conversationId, memberId, role) {
1846
+ const operation = "setMemberRole";
1847
+ const { error } = await db().from(TABLES.participants).update({ role, updated_by: identity.userId }).eq("conversation_id", conversationId).eq("user_id", memberId);
1848
+ if (error !== null) throw normalizeMessagingError(error, operation);
1849
+ },
1850
+ getUser(userId) {
1851
+ const operation = "getUser";
1852
+ return userCache.read(userId, async () => {
1853
+ const rows = await withSessionRetry(
1854
+ operation,
1855
+ () => rpc(
1856
+ RPCS.userInfo,
1857
+ { p_user_id: userId },
1858
+ operation
1859
+ )
1860
+ );
1861
+ const row = Array.isArray(rows) ? rows[0] : null;
1862
+ return row === void 0 || row === null ? null : projectUserSummary(row);
1863
+ });
1864
+ },
1865
+ getUsers,
1866
+ async searchMessages({ query, conversationId = null, limit = 50 }) {
1867
+ const operation = "searchMessages";
1868
+ const trimmed = query.trim();
1869
+ if (trimmed.length === 0) return [];
1870
+ const rows = await withSessionRetry(operation, async () => {
1871
+ let base = db().from(TABLES.messages).select(MESSAGE_COLUMNS).is("deleted_at", null);
1872
+ if (conversationId !== null) base = base.eq("conversation_id", conversationId);
1873
+ const safe = trimmed.replace(/[%,()*]/g, " ").trim();
1874
+ const { data, error } = await base.or(`content.ilike.*${safe}*`).order("created_at", { ascending: false }).limit(limit);
1875
+ if (error !== null) throw normalizeMessagingError(error, operation);
1876
+ return data ?? [];
1877
+ });
1878
+ return rows.map((row) => projectMessage(row, org));
1879
+ },
1880
+ invalidateUser(userId) {
1881
+ userCache.invalidate(userId);
1882
+ }
1883
+ };
1884
+ return repository;
1885
+ }
1886
+
1887
+ // src/core/types.ts
1888
+ var asConversationId = (value) => value;
1889
+ var asMessageId = (value) => value;
1890
+ var asUserId = (value) => value;
1891
+ var asOrganizationId = (value) => value;
1892
+ var asClientMessageId = (value) => value;
1893
+ //# sourceMappingURL=index.cjs.map