@ai-matrx/messaging 0.15.14 → 0.15.16

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 CHANGED
@@ -70,7 +70,8 @@ __export(src_exports, {
70
70
  splitText: () => splitText,
71
71
  summarizeText: () => summarizeText,
72
72
  toMessagingArchiveFilter: () => toMessagingArchiveFilter,
73
- unreadCutoff: () => unreadCutoff
73
+ unreadCutoff: () => unreadCutoff,
74
+ writeNotApplied: () => writeNotApplied
74
75
  });
75
76
  module.exports = __toCommonJS(src_exports);
76
77
 
@@ -308,6 +309,13 @@ function invalidResponse(operation, detail) {
308
309
  "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."
309
310
  );
310
311
  }
312
+ function writeNotApplied(operation, detail) {
313
+ return new MessagingError(
314
+ "write-not-applied",
315
+ `${operation}: ${detail}`,
316
+ "The intended live row was not changed. Re-read it before retrying; the row may be gone, soft-deleted, or hidden from this session by database authorization."
317
+ );
318
+ }
311
319
 
312
320
  // src/core/references.ts
313
321
  var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
@@ -1087,6 +1095,7 @@ function insertOrdered(messages, message) {
1087
1095
  }
1088
1096
  function createMessagingStore() {
1089
1097
  let conversations = [];
1098
+ let conversationSummaries = /* @__PURE__ */ new Map();
1090
1099
  let hasMoreConversations = false;
1091
1100
  let hasLoadedConversations = false;
1092
1101
  let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
@@ -1095,12 +1104,15 @@ function createMessagingStore() {
1095
1104
  let activeConversationId = null;
1096
1105
  let conversationReadFailures = /* @__PURE__ */ new Map();
1097
1106
  let conversationsLoadError = null;
1107
+ const unreadRevisions = /* @__PURE__ */ new Map();
1108
+ const confirmedReadRevisions = /* @__PURE__ */ new Map();
1098
1109
  const listeners = /* @__PURE__ */ new Set();
1099
1110
  let cached = null;
1100
1111
  function snapshot() {
1101
1112
  if (cached !== null) return cached;
1102
1113
  cached = {
1103
1114
  conversations,
1115
+ conversationSummaries,
1104
1116
  hasMoreConversations,
1105
1117
  hasLoadedConversations,
1106
1118
  threads,
@@ -1119,11 +1131,33 @@ function createMessagingStore() {
1119
1131
  listeners.forEach((listener) => listener(next));
1120
1132
  }
1121
1133
  function normalizeConversations(items) {
1122
- const active = activeConversationId;
1123
- const zeroed = active === null ? items : items.map(
1124
- (item) => item.conversation.id === active && item.unreadCount !== 0 ? { ...item, unreadCount: 0 } : item
1134
+ return [...items].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
1135
+ }
1136
+ function replaceConversations(next) {
1137
+ const summaries = new Map(conversationSummaries);
1138
+ next.forEach((item) => {
1139
+ const id = item.conversation.id;
1140
+ if (summaries.get(id)?.unreadCount !== item.unreadCount) {
1141
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1142
+ }
1143
+ summaries.set(id, item);
1144
+ });
1145
+ conversationSummaries = summaries;
1146
+ conversations = next;
1147
+ }
1148
+ function replaceOneSummary(id, update) {
1149
+ const held = conversationSummaries.get(id);
1150
+ if (held === void 0) return;
1151
+ const replacement = update(held);
1152
+ if (held.unreadCount !== replacement.unreadCount) {
1153
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1154
+ }
1155
+ const summaries = new Map(conversationSummaries);
1156
+ summaries.set(id, replacement);
1157
+ conversationSummaries = summaries;
1158
+ conversations = conversations.map(
1159
+ (item) => item.conversation.id === id ? replacement : item
1125
1160
  );
1126
- return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
1127
1161
  }
1128
1162
  function threadFor(id) {
1129
1163
  return threads.get(id) ?? {
@@ -1135,7 +1169,9 @@ function createMessagingStore() {
1135
1169
  }
1136
1170
  function writeThread(thread) {
1137
1171
  const next = new Map(threads);
1138
- const latest = thread.messages.at(-1);
1172
+ const latest = [...thread.messages].reverse().find(
1173
+ (message) => message.deliveryState !== "sending" && message.deliveryState !== "failed" && !message.id.startsWith("optimistic:")
1174
+ );
1139
1175
  next.set(thread.conversationId, {
1140
1176
  ...thread,
1141
1177
  latestAt: latest?.createdAt ?? thread.latestAt
@@ -1151,7 +1187,7 @@ function createMessagingStore() {
1151
1187
  };
1152
1188
  },
1153
1189
  setConversations(items, hasMore) {
1154
- conversations = normalizeConversations(items);
1190
+ replaceConversations(normalizeConversations(items));
1155
1191
  hasMoreConversations = hasMore;
1156
1192
  hasLoadedConversations = true;
1157
1193
  conversationsLoadError = null;
@@ -1165,7 +1201,7 @@ function createMessagingStore() {
1165
1201
  setArchiveFilter(next) {
1166
1202
  if (next === archiveFilter) return;
1167
1203
  archiveFilter = next;
1168
- conversations = [];
1204
+ replaceConversations([]);
1169
1205
  hasMoreConversations = false;
1170
1206
  hasLoadedConversations = false;
1171
1207
  emit();
@@ -1177,18 +1213,34 @@ function createMessagingStore() {
1177
1213
  appendConversations(items, hasMore) {
1178
1214
  const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
1179
1215
  items.forEach((item) => byId.set(item.conversation.id, item));
1180
- conversations = normalizeConversations([...byId.values()]);
1216
+ replaceConversations(normalizeConversations([...byId.values()]));
1181
1217
  hasMoreConversations = hasMore;
1182
1218
  emit();
1183
1219
  },
1184
1220
  upsertConversation(item) {
1185
1221
  const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
1186
1222
  byId.set(item.conversation.id, item);
1187
- conversations = normalizeConversations([...byId.values()]);
1223
+ replaceConversations(normalizeConversations([...byId.values()]));
1224
+ emit();
1225
+ },
1226
+ setConversationSummary(item) {
1227
+ const id = item.conversation.id;
1228
+ const held = conversationSummaries.get(id);
1229
+ if (held?.unreadCount !== item.unreadCount) {
1230
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1231
+ }
1232
+ const summaries = new Map(conversationSummaries);
1233
+ summaries.set(id, item);
1234
+ conversationSummaries = summaries;
1188
1235
  emit();
1189
1236
  },
1190
1237
  removeConversation(id) {
1191
- conversations = conversations.filter((item) => item.conversation.id !== id);
1238
+ replaceConversations(conversations.filter((item) => item.conversation.id !== id));
1239
+ const summaries = new Map(conversationSummaries);
1240
+ summaries.delete(id);
1241
+ conversationSummaries = summaries;
1242
+ unreadRevisions.delete(id);
1243
+ confirmedReadRevisions.delete(id);
1192
1244
  const next = new Map(threads);
1193
1245
  next.delete(id);
1194
1246
  threads = next;
@@ -1196,7 +1248,6 @@ function createMessagingStore() {
1196
1248
  },
1197
1249
  setActiveConversation(id) {
1198
1250
  activeConversationId = id;
1199
- conversations = normalizeConversations(conversations);
1200
1251
  emit();
1201
1252
  },
1202
1253
  setConversationReadFailure(id, failure) {
@@ -1216,6 +1267,13 @@ function createMessagingStore() {
1216
1267
  });
1217
1268
  emit();
1218
1269
  },
1270
+ clearThread(id) {
1271
+ if (!threads.has(id)) return;
1272
+ const next = new Map(threads);
1273
+ next.delete(id);
1274
+ threads = next;
1275
+ emit();
1276
+ },
1219
1277
  prependOlder(id, messages, hasMoreOlder) {
1220
1278
  const thread = threadFor(id);
1221
1279
  const known = new Set(thread.messages.map((message) => message.id));
@@ -1268,40 +1326,48 @@ function createMessagingStore() {
1268
1326
  emit();
1269
1327
  },
1270
1328
  applyLastMessage(message, args = {}) {
1271
- const existing = conversations.find(
1272
- (item) => item.conversation.id === message.conversationId
1273
- );
1329
+ const existing = conversationSummaries.get(message.conversationId);
1274
1330
  if (existing === void 0) return;
1275
1331
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1276
1332
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1277
- const isActive = activeConversationId === message.conversationId;
1278
- const shouldCount = args.incrementUnread === true && !isActive;
1279
- conversations = normalizeConversations(
1280
- conversations.map(
1281
- (item) => item.conversation.id === message.conversationId ? {
1282
- ...item,
1283
- lastMessageContent: message.content,
1284
- lastMessageSenderId: message.senderId,
1285
- lastMessageAt: message.createdAt,
1286
- sortAt: message.createdAt,
1287
- unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1288
- } : item
1289
- )
1290
- );
1333
+ const shouldCount = args.incrementUnread === true;
1334
+ replaceOneSummary(message.conversationId, (item) => ({
1335
+ ...item,
1336
+ lastMessageContent: message.content,
1337
+ lastMessageSenderId: message.senderId,
1338
+ lastMessageAt: message.createdAt,
1339
+ sortAt: message.createdAt,
1340
+ unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1341
+ }));
1342
+ conversations = normalizeConversations(conversations);
1291
1343
  emit();
1292
1344
  },
1293
- markConversationRead(id) {
1294
- conversations = conversations.map(
1295
- (item) => item.conversation.id === id ? { ...item, unreadCount: 0 } : item
1296
- );
1345
+ captureConversationRead(id, at) {
1346
+ const item = conversationSummaries.get(id);
1347
+ return {
1348
+ at,
1349
+ unreadCount: item?.unreadCount ?? 0,
1350
+ revision: unreadRevisions.get(id) ?? 0
1351
+ };
1352
+ },
1353
+ markConversationRead(id, cutoff, canonicalUnreadCount = 0) {
1354
+ if (cutoff.revision <= (confirmedReadRevisions.get(id) ?? -1)) return;
1355
+ confirmedReadRevisions.set(id, cutoff.revision);
1356
+ replaceOneSummary(id, (item) => ({
1357
+ ...item,
1358
+ // The repository re-counts after persisting the exact server cutoff.
1359
+ // If realtime advanced local state while that response was in flight,
1360
+ // retain at least the arrivals beyond the captured baseline.
1361
+ unreadCount: Math.max(
1362
+ 0,
1363
+ canonicalUnreadCount,
1364
+ item.unreadCount - cutoff.unreadCount
1365
+ )
1366
+ }));
1297
1367
  emit();
1298
1368
  },
1299
1369
  setUnreadCount(id, count) {
1300
- conversations = normalizeConversations(
1301
- conversations.map(
1302
- (item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
1303
- )
1304
- );
1370
+ replaceOneSummary(id, (item) => ({ ...item, unreadCount: Math.max(0, count) }));
1305
1371
  emit();
1306
1372
  }
1307
1373
  };
@@ -1338,21 +1404,57 @@ function createMessagingEngine(options) {
1338
1404
  const conversationPageSize = options.conversationPageSize ?? 30;
1339
1405
  const messagePageSize = options.messagePageSize ?? 50;
1340
1406
  const openChannels = /* @__PURE__ */ new Map();
1407
+ const channelReadiness = /* @__PURE__ */ new Map();
1408
+ const settleChannelReadiness = /* @__PURE__ */ new Map();
1409
+ const readReceiptTails = /* @__PURE__ */ new Map();
1410
+ const persistedReadAt = /* @__PURE__ */ new Map();
1411
+ const initialReadDepth = /* @__PURE__ */ new Map();
1412
+ const initialReadGeneration = /* @__PURE__ */ new Map();
1413
+ const initialReadChanges = /* @__PURE__ */ new Map();
1341
1414
  let inboxChannel = null;
1342
1415
  let conversationCursor = null;
1343
1416
  let disposed = false;
1344
1417
  function report(event) {
1345
1418
  options.onDiagnostic?.(event);
1346
1419
  }
1347
- function reportError(error, operation) {
1420
+ function reportError(error, operation, context = {}) {
1348
1421
  const normalized = normalizeMessagingError(error, operation);
1349
1422
  report({
1350
1423
  // A missing session is a NORMAL lifecycle moment, not a red error. This
1351
1424
  // one line is the cure for "909 captured errors in 0.6s".
1352
1425
  level: normalized.code === "session-unavailable" ? "warn" : "error",
1353
1426
  message: normalized.message,
1354
- remedy: normalized.remedy
1427
+ remedy: normalized.remedy,
1428
+ code: normalized.code,
1429
+ operation,
1430
+ isRetryable: normalized.isRetryable,
1431
+ error: normalized,
1432
+ ...context
1355
1433
  });
1434
+ return normalized;
1435
+ }
1436
+ function persistRead(id, isLive = () => true) {
1437
+ const run = async () => {
1438
+ if (!isLive()) return;
1439
+ const at = store.snapshot().threads.get(id)?.latestAt ?? null;
1440
+ if (at === null || (persistedReadAt.get(id) ?? "") >= at) return;
1441
+ const cutoff = store.captureConversationRead(id, at);
1442
+ try {
1443
+ const canonicalUnreadCount = await repository.markRead(id, at);
1444
+ if (!isLive()) return;
1445
+ persistedReadAt.set(id, at);
1446
+ store.markConversationRead(id, cutoff, canonicalUnreadCount);
1447
+ } catch (error) {
1448
+ throw reportError(error, "markRead", { conversationId: id });
1449
+ }
1450
+ };
1451
+ const previous = readReceiptTails.get(id);
1452
+ const current = previous === void 0 ? run() : previous.catch(() => void 0).then(run);
1453
+ readReceiptTails.set(id, current);
1454
+ void current.finally(() => {
1455
+ if (readReceiptTails.get(id) === current) readReceiptTails.delete(id);
1456
+ }).catch(() => void 0);
1457
+ return current;
1356
1458
  }
1357
1459
  const outbox = createOutbox({
1358
1460
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
@@ -1437,6 +1539,137 @@ function createMessagingEngine(options) {
1437
1539
  reportError(error, "backfillConversation");
1438
1540
  }
1439
1541
  }
1542
+ function applyLiveMessageChange(id, change) {
1543
+ if ((initialReadDepth.get(id) ?? 0) > 0) {
1544
+ const changes = initialReadChanges.get(id) ?? [];
1545
+ changes.push(change);
1546
+ initialReadChanges.set(id, changes);
1547
+ }
1548
+ if (change.kind === "delete") {
1549
+ store.removeMessage(id, change.messageId);
1550
+ } else {
1551
+ store.ingest(change.message);
1552
+ }
1553
+ }
1554
+ function beginInitialRead(id) {
1555
+ const generation = (initialReadGeneration.get(id) ?? 0) + 1;
1556
+ initialReadGeneration.set(id, generation);
1557
+ initialReadDepth.set(id, (initialReadDepth.get(id) ?? 0) + 1);
1558
+ initialReadChanges.set(id, initialReadChanges.get(id) ?? []);
1559
+ return generation;
1560
+ }
1561
+ function finishInitialRead(id) {
1562
+ const depth = (initialReadDepth.get(id) ?? 1) - 1;
1563
+ if (depth > 0) {
1564
+ initialReadDepth.set(id, depth);
1565
+ return;
1566
+ }
1567
+ initialReadDepth.delete(id);
1568
+ initialReadChanges.delete(id);
1569
+ }
1570
+ function openIsLive(id, generation, channel) {
1571
+ return !disposed && initialReadGeneration.get(id) === generation && channel !== null && openChannels.get(id) === channel;
1572
+ }
1573
+ function installInitialPage(id, generation, page) {
1574
+ if (initialReadGeneration.get(id) !== generation) return false;
1575
+ const optimistic = (store.snapshot().threads.get(id)?.messages ?? []).filter(
1576
+ (item) => item.deliveryState === "sending" || item.deliveryState === "failed"
1577
+ );
1578
+ const confirmedClientKeys = new Set(
1579
+ page.items.flatMap(
1580
+ (item) => item.clientMessageId === null ? [] : [item.clientMessageId]
1581
+ )
1582
+ );
1583
+ store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1584
+ for (const change of initialReadChanges.get(id) ?? []) {
1585
+ if (change.kind === "delete") {
1586
+ store.removeMessage(id, change.messageId);
1587
+ } else {
1588
+ store.ingest(change.message);
1589
+ }
1590
+ }
1591
+ store.ingestMany(
1592
+ optimistic.filter(
1593
+ (item) => item.clientMessageId === null || !confirmedClientKeys.has(item.clientMessageId)
1594
+ )
1595
+ );
1596
+ return true;
1597
+ }
1598
+ function ensureConversationChannel(id) {
1599
+ const existing = openChannels.get(id);
1600
+ if (existing !== void 0) {
1601
+ return channelReadiness.get(id) ?? Promise.resolve(existing.status() === "connected");
1602
+ }
1603
+ let markReady;
1604
+ const ready = new Promise((resolve) => {
1605
+ markReady = resolve;
1606
+ });
1607
+ const handle = manager.open({
1608
+ topic: conversationTopic(id),
1609
+ // Presence callbacks are JOIN-TIME in supabase-js. Declare the read-only
1610
+ // room shape on the first holder so later holders do not create a gap.
1611
+ presence: { publish: false, state: {} },
1612
+ postgresChanges: [
1613
+ {
1614
+ event: "*",
1615
+ schema: "communication",
1616
+ table: "dm_messages",
1617
+ filter: `conversation_id=eq.${id}`,
1618
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1619
+ fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1620
+ updatedAtField: "updated_at",
1621
+ updatedByField: "updated_by",
1622
+ onChange: ({ payload, row }) => {
1623
+ if (row === null) return;
1624
+ if (payload?.eventType === "DELETE" || typeof row["deleted_at"] === "string" && row["deleted_at"].length > 0) {
1625
+ const rawId = row["id"];
1626
+ if (typeof rawId !== "string" || rawId.length === 0) {
1627
+ reportError(
1628
+ new Error("A message deletion payload carried no message id."),
1629
+ "conversationMessageDelete",
1630
+ { conversationId: id }
1631
+ );
1632
+ return;
1633
+ }
1634
+ applyLiveMessageChange(id, {
1635
+ kind: "delete",
1636
+ messageId: asMessageId(rawId)
1637
+ });
1638
+ return;
1639
+ }
1640
+ const message = projectMessage(row, identity.organizationId);
1641
+ applyLiveMessageChange(id, { kind: "upsert", message });
1642
+ }
1643
+ }
1644
+ ],
1645
+ broadcast: [
1646
+ {
1647
+ event: MESSAGING_EVENTS.message,
1648
+ onMessage: ({ data }) => {
1649
+ try {
1650
+ const message = projectBroadcastMessage(data);
1651
+ applyLiveMessageChange(id, { kind: "upsert", message });
1652
+ } catch (error) {
1653
+ reportError(error, "broadcastMessage");
1654
+ }
1655
+ }
1656
+ }
1657
+ ],
1658
+ onStatusChange: (status) => {
1659
+ if (status === "connected") markReady?.(true);
1660
+ if (status === "disconnected") markReady?.(false);
1661
+ },
1662
+ onBackfill: () => {
1663
+ void backfillConversation(id);
1664
+ outbox.flush();
1665
+ }
1666
+ });
1667
+ openChannels.set(id, handle);
1668
+ channelReadiness.set(id, ready);
1669
+ settleChannelReadiness.set(id, (connected) => markReady?.(connected));
1670
+ if (handle.status() === "connected") markReady?.(true);
1671
+ return ready;
1672
+ }
1440
1673
  const engine = {
1441
1674
  store,
1442
1675
  outbox,
@@ -1462,7 +1695,9 @@ function createMessagingEngine(options) {
1462
1695
  onChange: ({ row }) => {
1463
1696
  if (row === null) return;
1464
1697
  const message = projectMessage(row, identity.organizationId);
1465
- const known = store.snapshot().conversations.some((item) => item.conversation.id === message.conversationId);
1698
+ const known = store.snapshot().conversationSummaries.has(
1699
+ message.conversationId
1700
+ );
1466
1701
  if (!known) {
1467
1702
  void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
1468
1703
  return;
@@ -1470,6 +1705,9 @@ function createMessagingEngine(options) {
1470
1705
  store.ingest(message);
1471
1706
  const isMine = message.senderId === identity.userId;
1472
1707
  store.applyLastMessage(message, { incrementUnread: !isMine });
1708
+ if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1709
+ void persistRead(message.conversationId).catch(() => void 0);
1710
+ }
1473
1711
  if (!isMine) options.onIncoming?.(message);
1474
1712
  }
1475
1713
  },
@@ -1534,18 +1772,35 @@ function createMessagingEngine(options) {
1534
1772
  },
1535
1773
  async openConversation(id) {
1536
1774
  store.setActiveConversation(id);
1775
+ const generation = beginInitialRead(id);
1776
+ let ownedChannel = null;
1537
1777
  try {
1778
+ const connected = await ensureConversationChannel(id);
1779
+ ownedChannel = openChannels.get(id) ?? null;
1780
+ if (!connected || !openIsLive(id, generation, ownedChannel)) return;
1538
1781
  const page = await repository.listMessages(id, { limit: messagePageSize });
1539
- if (page.items.length === 0) {
1540
- await repository.getConversation(id);
1541
- }
1782
+ if (!openIsLive(id, generation, ownedChannel)) return;
1783
+ if (!installInitialPage(id, generation, page)) return;
1784
+ const missingSummary = !store.snapshot().conversationSummaries.has(id);
1785
+ const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1786
+ if (!openIsLive(id, generation, ownedChannel)) return;
1542
1787
  store.setConversationReadFailure(id, null);
1543
- store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1788
+ if (directSummary !== null) store.setConversationSummary(directSummary);
1789
+ await persistRead(
1790
+ id,
1791
+ () => openIsLive(id, generation, ownedChannel)
1792
+ ).catch(() => void 0);
1544
1793
  } catch (error) {
1794
+ if (!openIsLive(id, generation, ownedChannel)) return;
1545
1795
  const normalized = normalizeMessagingError(error, "openConversation");
1546
1796
  const unavailable = normalized.code === "not-found" || normalized.code === "forbidden";
1547
1797
  store.setConversationReadFailure(id, { error: normalized, unavailable });
1548
1798
  if (unavailable) {
1799
+ openChannels.get(id)?.close();
1800
+ openChannels.delete(id);
1801
+ settleChannelReadiness.delete(id);
1802
+ channelReadiness.delete(id);
1803
+ store.clearThread(id);
1549
1804
  report({
1550
1805
  level: "warn",
1551
1806
  message: normalized.message,
@@ -1554,53 +1809,17 @@ function createMessagingEngine(options) {
1554
1809
  return;
1555
1810
  }
1556
1811
  reportError(normalized, "openConversation");
1812
+ } finally {
1813
+ finishInitialRead(id);
1557
1814
  }
1558
- if (openChannels.has(id) || disposed) return;
1559
- const handle = manager.open({
1560
- topic: conversationTopic(id),
1561
- // Presence callbacks are JOIN-TIME in supabase-js. Declare the
1562
- // read-only room shape on the first holder (the engine) so the
1563
- // composer/typist/online hooks can attach without forcing a rebuild
1564
- // after this durable channel has already subscribed.
1565
- presence: { publish: false, state: {} },
1566
- postgresChanges: [
1567
- {
1568
- event: "*",
1569
- schema: "communication",
1570
- table: "dm_messages",
1571
- filter: `conversation_id=eq.${id}`,
1572
- rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1573
- fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1574
- updatedAtField: "updated_at",
1575
- updatedByField: "updated_by",
1576
- onChange: ({ row }) => {
1577
- if (row === null) return;
1578
- store.ingest(projectMessage(row, identity.organizationId));
1579
- }
1580
- }
1581
- ],
1582
- broadcast: [
1583
- {
1584
- event: MESSAGING_EVENTS.message,
1585
- onMessage: ({ data }) => {
1586
- try {
1587
- store.ingest(projectBroadcastMessage(data));
1588
- } catch (error) {
1589
- reportError(error, "broadcastMessage");
1590
- }
1591
- }
1592
- }
1593
- ],
1594
- onBackfill: () => {
1595
- void backfillConversation(id);
1596
- outbox.flush();
1597
- }
1598
- });
1599
- openChannels.set(id, handle);
1600
1815
  },
1601
1816
  closeConversation(id) {
1817
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1602
1818
  openChannels.get(id)?.close();
1603
1819
  openChannels.delete(id);
1820
+ settleChannelReadiness.get(id)?.(false);
1821
+ settleChannelReadiness.delete(id);
1822
+ channelReadiness.delete(id);
1604
1823
  if (store.snapshot().activeConversationId === id) {
1605
1824
  store.setActiveConversation(null);
1606
1825
  }
@@ -1652,12 +1871,7 @@ function createMessagingEngine(options) {
1652
1871
  }
1653
1872
  },
1654
1873
  async markRead(id) {
1655
- store.markConversationRead(id);
1656
- try {
1657
- await repository.markRead(id);
1658
- } catch (error) {
1659
- reportError(error, "markRead");
1660
- }
1874
+ await persistRead(id);
1661
1875
  },
1662
1876
  async startDirectConversation(otherUserId) {
1663
1877
  const id = await repository.getOrCreateDirectConversation(otherUserId);
@@ -1666,9 +1880,15 @@ function createMessagingEngine(options) {
1666
1880
  },
1667
1881
  dispose() {
1668
1882
  disposed = true;
1883
+ openChannels.forEach((_handle, id) => {
1884
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1885
+ });
1669
1886
  outbox.dispose();
1670
1887
  openChannels.forEach((handle) => handle.close());
1671
1888
  openChannels.clear();
1889
+ settleChannelReadiness.forEach((settle) => settle(false));
1890
+ settleChannelReadiness.clear();
1891
+ channelReadiness.clear();
1672
1892
  inboxChannel?.close();
1673
1893
  inboxChannel = null;
1674
1894
  }
@@ -1925,6 +2145,7 @@ var RPCS = {
1925
2145
  isParticipant: "is_dm_participant"
1926
2146
  };
1927
2147
  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";
2148
+ var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
1928
2149
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
1929
2150
  function optionalOrg(organizationId) {
1930
2151
  return typeof organizationId === "string" && organizationId.length > 0 ? organizationId : null;
@@ -1994,6 +2215,14 @@ function createMessagingRepository(options) {
1994
2215
  function actingUser(operation) {
1995
2216
  return withSessionRetry(operation, () => actingUserId());
1996
2217
  }
2218
+ async function updateLiveParticipant(operation, conversationId, userId, patch) {
2219
+ const destination = await organizationOfExisting(conversationId);
2220
+ const { data, error } = await db().from(TABLES.participants).update({ ...patch, organization_id: destination }).eq("conversation_id", conversationId).eq("user_id", userId).eq("organization_id", destination).is("deleted_at", null).select("id").maybeSingle();
2221
+ if (error !== null) throw normalizeMessagingError(error, operation);
2222
+ if (data === null) {
2223
+ throw writeNotApplied(operation, "participant update returned no live row");
2224
+ }
2225
+ }
1997
2226
  async function rpc(fn, args, operation) {
1998
2227
  const { data, error } = await rpcDb().rpc(fn, args);
1999
2228
  if (error !== null) throw normalizeMessagingError(error, operation);
@@ -2016,6 +2245,33 @@ function createMessagingRepository(options) {
2016
2245
  });
2017
2246
  return found;
2018
2247
  }
2248
+ async function allLiveParticipants(conversationId) {
2249
+ const operation = "getConversationSummary";
2250
+ const pageSize = 1e3;
2251
+ const rows = [];
2252
+ for (let from = 0; ; from += pageSize) {
2253
+ const response = await withSessionRetry(
2254
+ operation,
2255
+ async () => db().from(TABLES.participants).select(PARTICIPANT_COLUMNS, { count: "exact" }).eq("conversation_id", conversationId).is("deleted_at", null).order("user_id", { ascending: true }).range(from, from + pageSize - 1)
2256
+ );
2257
+ if (response.error !== null) {
2258
+ throw normalizeMessagingError(response.error, operation);
2259
+ }
2260
+ const total = response.count;
2261
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
2262
+ throw invalidResponse(operation, "participant read returned no exact count");
2263
+ }
2264
+ const page = response.data ?? [];
2265
+ rows.push(...page);
2266
+ if (rows.length >= total) return rows;
2267
+ if (page.length === 0) {
2268
+ throw invalidResponse(
2269
+ operation,
2270
+ `participant read stopped at ${rows.length} of ${total}`
2271
+ );
2272
+ }
2273
+ }
2274
+ }
2019
2275
  const conversationOrganizations = /* @__PURE__ */ new Map();
2020
2276
  async function organizationOfExisting(conversationId) {
2021
2277
  const known = conversationOrganizations.get(conversationId);
@@ -2127,6 +2383,78 @@ function createMessagingRepository(options) {
2127
2383
  );
2128
2384
  return summary.conversation;
2129
2385
  },
2386
+ async getConversationSummary(id) {
2387
+ const operation = "getConversationSummary";
2388
+ const me = await actingUser(operation);
2389
+ const [conversationResult, participantRows, latestResult, unreadRaw] = await withSessionRetry(
2390
+ operation,
2391
+ () => Promise.all([
2392
+ db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single(),
2393
+ allLiveParticipants(id),
2394
+ db().from(TABLES.messages).select(MESSAGE_COLUMNS).eq("conversation_id", id).is("deleted_at", null).order("created_at", { ascending: false }).order("id", { ascending: false }).limit(1).maybeSingle(),
2395
+ rpc(
2396
+ RPCS.unreadCount,
2397
+ { p_conversation_id: id, p_user_id: me },
2398
+ operation
2399
+ )
2400
+ ])
2401
+ );
2402
+ if (conversationResult.error !== null) {
2403
+ throw normalizeMessagingError(conversationResult.error, operation);
2404
+ }
2405
+ if (latestResult.error !== null) {
2406
+ throw normalizeMessagingError(latestResult.error, operation);
2407
+ }
2408
+ const conversation = conversationResult.data;
2409
+ if (conversation === null) {
2410
+ throw invalidResponse(operation, `conversation ${id} returned no row`);
2411
+ }
2412
+ if (typeof unreadRaw !== "number" || !Number.isFinite(unreadRaw) || unreadRaw < 0) {
2413
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unreadRaw)}`);
2414
+ }
2415
+ const participantIds = participantRows.flatMap((row) => {
2416
+ const value = row["user_id"];
2417
+ return typeof value === "string" && value.length > 0 ? [projectUserSummary({ user_id: value }).userId] : [];
2418
+ });
2419
+ const profiles = await getUsers(participantIds);
2420
+ const participants = participantIds.map((userId) => {
2421
+ const profile = profiles.get(userId);
2422
+ return profile === void 0 ? {
2423
+ user_id: userId,
2424
+ display_name: userId
2425
+ } : {
2426
+ user_id: profile.userId,
2427
+ display_name: profile.displayName,
2428
+ email: profile.email,
2429
+ avatar_url: profile.avatarUrl,
2430
+ is_agent: profile.isAgent
2431
+ };
2432
+ });
2433
+ const viewer = participantRows.find((row) => row["user_id"] === me);
2434
+ const latest = latestResult.data;
2435
+ return projectConversationSummary(
2436
+ {
2437
+ conversation_id: conversation["id"],
2438
+ conversation_type: conversation["type"],
2439
+ group_name: conversation["group_name"],
2440
+ group_image_url: conversation["group_image_url"],
2441
+ conversation_created_at: conversation["created_at"],
2442
+ conversation_updated_at: conversation["updated_at"],
2443
+ organization_id: conversation["organization_id"],
2444
+ created_by: conversation["created_by"],
2445
+ metadata: conversation["metadata"],
2446
+ participants,
2447
+ last_message_content: latest?.["content"] ?? null,
2448
+ last_message_sender_id: latest?.["sender_id"] ?? null,
2449
+ last_message_at: latest?.["created_at"] ?? null,
2450
+ unread_count: Math.floor(unreadRaw),
2451
+ is_muted: viewer?.["is_muted"] ?? false,
2452
+ is_archived: viewer?.["is_archived"] ?? false
2453
+ },
2454
+ me,
2455
+ org
2456
+ );
2457
+ },
2130
2458
  async listMessages(conversationId, args = {}) {
2131
2459
  const limit = args.limit ?? 50;
2132
2460
  const operation = "listMessages";
@@ -2272,8 +2600,22 @@ function createMessagingRepository(options) {
2272
2600
  async markRead(conversationId, at) {
2273
2601
  const operation = "markRead";
2274
2602
  const me = await actingUser(operation);
2275
- const { error } = await db().from(TABLES.participants).update({ last_read_at: at ?? (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", me);
2276
- if (error !== null) throw normalizeMessagingError(error, operation);
2603
+ await updateLiveParticipant(operation, conversationId, me, {
2604
+ last_read_at: at,
2605
+ updated_by: me
2606
+ });
2607
+ const unread = await withSessionRetry(
2608
+ operation,
2609
+ () => rpc(
2610
+ RPCS.unreadCount,
2611
+ { p_conversation_id: conversationId, p_user_id: me },
2612
+ operation
2613
+ )
2614
+ );
2615
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2616
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2617
+ }
2618
+ return Math.floor(unread);
2277
2619
  },
2278
2620
  async setConversationFlags(conversationId, flags) {
2279
2621
  const operation = "setConversationFlags";
@@ -2281,8 +2623,7 @@ function createMessagingRepository(options) {
2281
2623
  const patch = { updated_by: me };
2282
2624
  if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
2283
2625
  if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
2284
- const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", me);
2285
- if (error !== null) throw normalizeMessagingError(error, operation);
2626
+ await updateLiveParticipant(operation, conversationId, me, patch);
2286
2627
  },
2287
2628
  async addMembers(conversationId, memberIds) {
2288
2629
  const operation = "addMembers";
@@ -2302,14 +2643,18 @@ function createMessagingRepository(options) {
2302
2643
  async removeMember(conversationId, memberId) {
2303
2644
  const operation = "removeMember";
2304
2645
  const me = await actingUser(operation);
2305
- const { error } = await db().from(TABLES.participants).update({ deleted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2306
- if (error !== null) throw normalizeMessagingError(error, operation);
2646
+ await updateLiveParticipant(operation, conversationId, memberId, {
2647
+ deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
2648
+ updated_by: me
2649
+ });
2307
2650
  },
2308
2651
  async setMemberRole(conversationId, memberId, role) {
2309
2652
  const operation = "setMemberRole";
2310
2653
  const me = await actingUser(operation);
2311
- const { error } = await db().from(TABLES.participants).update({ role, updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2312
- if (error !== null) throw normalizeMessagingError(error, operation);
2654
+ await updateLiveParticipant(operation, conversationId, memberId, {
2655
+ role,
2656
+ updated_by: me
2657
+ });
2313
2658
  },
2314
2659
  getUser(userId) {
2315
2660
  const operation = "getUser";