@ai-matrx/messaging 0.15.14 → 0.15.15

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.js CHANGED
@@ -235,6 +235,13 @@ function invalidResponse(operation, detail) {
235
235
  "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."
236
236
  );
237
237
  }
238
+ function writeNotApplied(operation, detail) {
239
+ return new MessagingError(
240
+ "write-not-applied",
241
+ `${operation}: ${detail}`,
242
+ "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."
243
+ );
244
+ }
238
245
 
239
246
  // src/core/references.ts
240
247
  var FENCE = /```matrx\s*\n([\s\S]*?)\n?```/g;
@@ -1016,6 +1023,7 @@ function insertOrdered(messages, message) {
1016
1023
  }
1017
1024
  function createMessagingStore() {
1018
1025
  let conversations = [];
1026
+ let conversationSummaries = /* @__PURE__ */ new Map();
1019
1027
  let hasMoreConversations = false;
1020
1028
  let hasLoadedConversations = false;
1021
1029
  let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
@@ -1024,12 +1032,15 @@ function createMessagingStore() {
1024
1032
  let activeConversationId = null;
1025
1033
  let conversationReadFailures = /* @__PURE__ */ new Map();
1026
1034
  let conversationsLoadError = null;
1035
+ const unreadRevisions = /* @__PURE__ */ new Map();
1036
+ const confirmedReadRevisions = /* @__PURE__ */ new Map();
1027
1037
  const listeners = /* @__PURE__ */ new Set();
1028
1038
  let cached = null;
1029
1039
  function snapshot() {
1030
1040
  if (cached !== null) return cached;
1031
1041
  cached = {
1032
1042
  conversations,
1043
+ conversationSummaries,
1033
1044
  hasMoreConversations,
1034
1045
  hasLoadedConversations,
1035
1046
  threads,
@@ -1048,11 +1059,33 @@ function createMessagingStore() {
1048
1059
  listeners.forEach((listener) => listener(next));
1049
1060
  }
1050
1061
  function normalizeConversations(items) {
1051
- const active = activeConversationId;
1052
- const zeroed = active === null ? items : items.map(
1053
- (item) => item.conversation.id === active && item.unreadCount !== 0 ? { ...item, unreadCount: 0 } : item
1062
+ return [...items].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
1063
+ }
1064
+ function replaceConversations(next) {
1065
+ const summaries = new Map(conversationSummaries);
1066
+ next.forEach((item) => {
1067
+ const id = item.conversation.id;
1068
+ if (summaries.get(id)?.unreadCount !== item.unreadCount) {
1069
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1070
+ }
1071
+ summaries.set(id, item);
1072
+ });
1073
+ conversationSummaries = summaries;
1074
+ conversations = next;
1075
+ }
1076
+ function replaceOneSummary(id, update) {
1077
+ const held = conversationSummaries.get(id);
1078
+ if (held === void 0) return;
1079
+ const replacement = update(held);
1080
+ if (held.unreadCount !== replacement.unreadCount) {
1081
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1082
+ }
1083
+ const summaries = new Map(conversationSummaries);
1084
+ summaries.set(id, replacement);
1085
+ conversationSummaries = summaries;
1086
+ conversations = conversations.map(
1087
+ (item) => item.conversation.id === id ? replacement : item
1054
1088
  );
1055
- return [...zeroed].sort((a, b) => a.sortAt < b.sortAt ? 1 : a.sortAt > b.sortAt ? -1 : 0);
1056
1089
  }
1057
1090
  function threadFor(id) {
1058
1091
  return threads.get(id) ?? {
@@ -1064,7 +1097,9 @@ function createMessagingStore() {
1064
1097
  }
1065
1098
  function writeThread(thread) {
1066
1099
  const next = new Map(threads);
1067
- const latest = thread.messages.at(-1);
1100
+ const latest = [...thread.messages].reverse().find(
1101
+ (message) => message.deliveryState !== "sending" && message.deliveryState !== "failed" && !message.id.startsWith("optimistic:")
1102
+ );
1068
1103
  next.set(thread.conversationId, {
1069
1104
  ...thread,
1070
1105
  latestAt: latest?.createdAt ?? thread.latestAt
@@ -1080,7 +1115,7 @@ function createMessagingStore() {
1080
1115
  };
1081
1116
  },
1082
1117
  setConversations(items, hasMore) {
1083
- conversations = normalizeConversations(items);
1118
+ replaceConversations(normalizeConversations(items));
1084
1119
  hasMoreConversations = hasMore;
1085
1120
  hasLoadedConversations = true;
1086
1121
  conversationsLoadError = null;
@@ -1094,7 +1129,7 @@ function createMessagingStore() {
1094
1129
  setArchiveFilter(next) {
1095
1130
  if (next === archiveFilter) return;
1096
1131
  archiveFilter = next;
1097
- conversations = [];
1132
+ replaceConversations([]);
1098
1133
  hasMoreConversations = false;
1099
1134
  hasLoadedConversations = false;
1100
1135
  emit();
@@ -1106,18 +1141,34 @@ function createMessagingStore() {
1106
1141
  appendConversations(items, hasMore) {
1107
1142
  const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
1108
1143
  items.forEach((item) => byId.set(item.conversation.id, item));
1109
- conversations = normalizeConversations([...byId.values()]);
1144
+ replaceConversations(normalizeConversations([...byId.values()]));
1110
1145
  hasMoreConversations = hasMore;
1111
1146
  emit();
1112
1147
  },
1113
1148
  upsertConversation(item) {
1114
1149
  const byId = new Map(conversations.map((entry) => [entry.conversation.id, entry]));
1115
1150
  byId.set(item.conversation.id, item);
1116
- conversations = normalizeConversations([...byId.values()]);
1151
+ replaceConversations(normalizeConversations([...byId.values()]));
1152
+ emit();
1153
+ },
1154
+ setConversationSummary(item) {
1155
+ const id = item.conversation.id;
1156
+ const held = conversationSummaries.get(id);
1157
+ if (held?.unreadCount !== item.unreadCount) {
1158
+ unreadRevisions.set(id, (unreadRevisions.get(id) ?? 0) + 1);
1159
+ }
1160
+ const summaries = new Map(conversationSummaries);
1161
+ summaries.set(id, item);
1162
+ conversationSummaries = summaries;
1117
1163
  emit();
1118
1164
  },
1119
1165
  removeConversation(id) {
1120
- conversations = conversations.filter((item) => item.conversation.id !== id);
1166
+ replaceConversations(conversations.filter((item) => item.conversation.id !== id));
1167
+ const summaries = new Map(conversationSummaries);
1168
+ summaries.delete(id);
1169
+ conversationSummaries = summaries;
1170
+ unreadRevisions.delete(id);
1171
+ confirmedReadRevisions.delete(id);
1121
1172
  const next = new Map(threads);
1122
1173
  next.delete(id);
1123
1174
  threads = next;
@@ -1125,7 +1176,6 @@ function createMessagingStore() {
1125
1176
  },
1126
1177
  setActiveConversation(id) {
1127
1178
  activeConversationId = id;
1128
- conversations = normalizeConversations(conversations);
1129
1179
  emit();
1130
1180
  },
1131
1181
  setConversationReadFailure(id, failure) {
@@ -1145,6 +1195,13 @@ function createMessagingStore() {
1145
1195
  });
1146
1196
  emit();
1147
1197
  },
1198
+ clearThread(id) {
1199
+ if (!threads.has(id)) return;
1200
+ const next = new Map(threads);
1201
+ next.delete(id);
1202
+ threads = next;
1203
+ emit();
1204
+ },
1148
1205
  prependOlder(id, messages, hasMoreOlder) {
1149
1206
  const thread = threadFor(id);
1150
1207
  const known = new Set(thread.messages.map((message) => message.id));
@@ -1197,40 +1254,48 @@ function createMessagingStore() {
1197
1254
  emit();
1198
1255
  },
1199
1256
  applyLastMessage(message, args = {}) {
1200
- const existing = conversations.find(
1201
- (item) => item.conversation.id === message.conversationId
1202
- );
1257
+ const existing = conversationSummaries.get(message.conversationId);
1203
1258
  if (existing === void 0) return;
1204
1259
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1205
1260
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1206
- const isActive = activeConversationId === message.conversationId;
1207
- const shouldCount = args.incrementUnread === true && !isActive;
1208
- conversations = normalizeConversations(
1209
- conversations.map(
1210
- (item) => item.conversation.id === message.conversationId ? {
1211
- ...item,
1212
- lastMessageContent: message.content,
1213
- lastMessageSenderId: message.senderId,
1214
- lastMessageAt: message.createdAt,
1215
- sortAt: message.createdAt,
1216
- unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1217
- } : item
1218
- )
1219
- );
1261
+ const shouldCount = args.incrementUnread === true;
1262
+ replaceOneSummary(message.conversationId, (item) => ({
1263
+ ...item,
1264
+ lastMessageContent: message.content,
1265
+ lastMessageSenderId: message.senderId,
1266
+ lastMessageAt: message.createdAt,
1267
+ sortAt: message.createdAt,
1268
+ unreadCount: shouldCount ? item.unreadCount + 1 : item.unreadCount
1269
+ }));
1270
+ conversations = normalizeConversations(conversations);
1220
1271
  emit();
1221
1272
  },
1222
- markConversationRead(id) {
1223
- conversations = conversations.map(
1224
- (item) => item.conversation.id === id ? { ...item, unreadCount: 0 } : item
1225
- );
1273
+ captureConversationRead(id, at) {
1274
+ const item = conversationSummaries.get(id);
1275
+ return {
1276
+ at,
1277
+ unreadCount: item?.unreadCount ?? 0,
1278
+ revision: unreadRevisions.get(id) ?? 0
1279
+ };
1280
+ },
1281
+ markConversationRead(id, cutoff, canonicalUnreadCount = 0) {
1282
+ if (cutoff.revision <= (confirmedReadRevisions.get(id) ?? -1)) return;
1283
+ confirmedReadRevisions.set(id, cutoff.revision);
1284
+ replaceOneSummary(id, (item) => ({
1285
+ ...item,
1286
+ // The repository re-counts after persisting the exact server cutoff.
1287
+ // If realtime advanced local state while that response was in flight,
1288
+ // retain at least the arrivals beyond the captured baseline.
1289
+ unreadCount: Math.max(
1290
+ 0,
1291
+ canonicalUnreadCount,
1292
+ item.unreadCount - cutoff.unreadCount
1293
+ )
1294
+ }));
1226
1295
  emit();
1227
1296
  },
1228
1297
  setUnreadCount(id, count) {
1229
- conversations = normalizeConversations(
1230
- conversations.map(
1231
- (item) => item.conversation.id === id ? { ...item, unreadCount: Math.max(0, count) } : item
1232
- )
1233
- );
1298
+ replaceOneSummary(id, (item) => ({ ...item, unreadCount: Math.max(0, count) }));
1234
1299
  emit();
1235
1300
  }
1236
1301
  };
@@ -1267,21 +1332,57 @@ function createMessagingEngine(options) {
1267
1332
  const conversationPageSize = options.conversationPageSize ?? 30;
1268
1333
  const messagePageSize = options.messagePageSize ?? 50;
1269
1334
  const openChannels = /* @__PURE__ */ new Map();
1335
+ const channelReadiness = /* @__PURE__ */ new Map();
1336
+ const settleChannelReadiness = /* @__PURE__ */ new Map();
1337
+ const readReceiptTails = /* @__PURE__ */ new Map();
1338
+ const persistedReadAt = /* @__PURE__ */ new Map();
1339
+ const initialReadDepth = /* @__PURE__ */ new Map();
1340
+ const initialReadGeneration = /* @__PURE__ */ new Map();
1341
+ const initialReadChanges = /* @__PURE__ */ new Map();
1270
1342
  let inboxChannel = null;
1271
1343
  let conversationCursor = null;
1272
1344
  let disposed = false;
1273
1345
  function report(event) {
1274
1346
  options.onDiagnostic?.(event);
1275
1347
  }
1276
- function reportError(error, operation) {
1348
+ function reportError(error, operation, context = {}) {
1277
1349
  const normalized = normalizeMessagingError(error, operation);
1278
1350
  report({
1279
1351
  // A missing session is a NORMAL lifecycle moment, not a red error. This
1280
1352
  // one line is the cure for "909 captured errors in 0.6s".
1281
1353
  level: normalized.code === "session-unavailable" ? "warn" : "error",
1282
1354
  message: normalized.message,
1283
- remedy: normalized.remedy
1355
+ remedy: normalized.remedy,
1356
+ code: normalized.code,
1357
+ operation,
1358
+ isRetryable: normalized.isRetryable,
1359
+ error: normalized,
1360
+ ...context
1284
1361
  });
1362
+ return normalized;
1363
+ }
1364
+ function persistRead(id, isLive = () => true) {
1365
+ const run = async () => {
1366
+ if (!isLive()) return;
1367
+ const at = store.snapshot().threads.get(id)?.latestAt ?? null;
1368
+ if (at === null || (persistedReadAt.get(id) ?? "") >= at) return;
1369
+ const cutoff = store.captureConversationRead(id, at);
1370
+ try {
1371
+ const canonicalUnreadCount = await repository.markRead(id, at);
1372
+ if (!isLive()) return;
1373
+ persistedReadAt.set(id, at);
1374
+ store.markConversationRead(id, cutoff, canonicalUnreadCount);
1375
+ } catch (error) {
1376
+ throw reportError(error, "markRead", { conversationId: id });
1377
+ }
1378
+ };
1379
+ const previous = readReceiptTails.get(id);
1380
+ const current = previous === void 0 ? run() : previous.catch(() => void 0).then(run);
1381
+ readReceiptTails.set(id, current);
1382
+ void current.finally(() => {
1383
+ if (readReceiptTails.get(id) === current) readReceiptTails.delete(id);
1384
+ }).catch(() => void 0);
1385
+ return current;
1285
1386
  }
1286
1387
  const outbox = createOutbox({
1287
1388
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
@@ -1366,6 +1467,137 @@ function createMessagingEngine(options) {
1366
1467
  reportError(error, "backfillConversation");
1367
1468
  }
1368
1469
  }
1470
+ function applyLiveMessageChange(id, change) {
1471
+ if ((initialReadDepth.get(id) ?? 0) > 0) {
1472
+ const changes = initialReadChanges.get(id) ?? [];
1473
+ changes.push(change);
1474
+ initialReadChanges.set(id, changes);
1475
+ }
1476
+ if (change.kind === "delete") {
1477
+ store.removeMessage(id, change.messageId);
1478
+ } else {
1479
+ store.ingest(change.message);
1480
+ }
1481
+ }
1482
+ function beginInitialRead(id) {
1483
+ const generation = (initialReadGeneration.get(id) ?? 0) + 1;
1484
+ initialReadGeneration.set(id, generation);
1485
+ initialReadDepth.set(id, (initialReadDepth.get(id) ?? 0) + 1);
1486
+ initialReadChanges.set(id, initialReadChanges.get(id) ?? []);
1487
+ return generation;
1488
+ }
1489
+ function finishInitialRead(id) {
1490
+ const depth = (initialReadDepth.get(id) ?? 1) - 1;
1491
+ if (depth > 0) {
1492
+ initialReadDepth.set(id, depth);
1493
+ return;
1494
+ }
1495
+ initialReadDepth.delete(id);
1496
+ initialReadChanges.delete(id);
1497
+ }
1498
+ function openIsLive(id, generation, channel) {
1499
+ return !disposed && initialReadGeneration.get(id) === generation && channel !== null && openChannels.get(id) === channel;
1500
+ }
1501
+ function installInitialPage(id, generation, page) {
1502
+ if (initialReadGeneration.get(id) !== generation) return false;
1503
+ const optimistic = (store.snapshot().threads.get(id)?.messages ?? []).filter(
1504
+ (item) => item.deliveryState === "sending" || item.deliveryState === "failed"
1505
+ );
1506
+ const confirmedClientKeys = new Set(
1507
+ page.items.flatMap(
1508
+ (item) => item.clientMessageId === null ? [] : [item.clientMessageId]
1509
+ )
1510
+ );
1511
+ store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1512
+ for (const change of initialReadChanges.get(id) ?? []) {
1513
+ if (change.kind === "delete") {
1514
+ store.removeMessage(id, change.messageId);
1515
+ } else {
1516
+ store.ingest(change.message);
1517
+ }
1518
+ }
1519
+ store.ingestMany(
1520
+ optimistic.filter(
1521
+ (item) => item.clientMessageId === null || !confirmedClientKeys.has(item.clientMessageId)
1522
+ )
1523
+ );
1524
+ return true;
1525
+ }
1526
+ function ensureConversationChannel(id) {
1527
+ const existing = openChannels.get(id);
1528
+ if (existing !== void 0) {
1529
+ return channelReadiness.get(id) ?? Promise.resolve(existing.status() === "connected");
1530
+ }
1531
+ let markReady;
1532
+ const ready = new Promise((resolve) => {
1533
+ markReady = resolve;
1534
+ });
1535
+ const handle = manager.open({
1536
+ topic: conversationTopic(id),
1537
+ // Presence callbacks are JOIN-TIME in supabase-js. Declare the read-only
1538
+ // room shape on the first holder so later holders do not create a gap.
1539
+ presence: { publish: false, state: {} },
1540
+ postgresChanges: [
1541
+ {
1542
+ event: "*",
1543
+ schema: "communication",
1544
+ table: "dm_messages",
1545
+ filter: `conversation_id=eq.${id}`,
1546
+ rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1547
+ fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1548
+ updatedAtField: "updated_at",
1549
+ updatedByField: "updated_by",
1550
+ onChange: ({ payload, row }) => {
1551
+ if (row === null) return;
1552
+ if (payload?.eventType === "DELETE" || typeof row["deleted_at"] === "string" && row["deleted_at"].length > 0) {
1553
+ const rawId = row["id"];
1554
+ if (typeof rawId !== "string" || rawId.length === 0) {
1555
+ reportError(
1556
+ new Error("A message deletion payload carried no message id."),
1557
+ "conversationMessageDelete",
1558
+ { conversationId: id }
1559
+ );
1560
+ return;
1561
+ }
1562
+ applyLiveMessageChange(id, {
1563
+ kind: "delete",
1564
+ messageId: asMessageId(rawId)
1565
+ });
1566
+ return;
1567
+ }
1568
+ const message = projectMessage(row, identity.organizationId);
1569
+ applyLiveMessageChange(id, { kind: "upsert", message });
1570
+ }
1571
+ }
1572
+ ],
1573
+ broadcast: [
1574
+ {
1575
+ event: MESSAGING_EVENTS.message,
1576
+ onMessage: ({ data }) => {
1577
+ try {
1578
+ const message = projectBroadcastMessage(data);
1579
+ applyLiveMessageChange(id, { kind: "upsert", message });
1580
+ } catch (error) {
1581
+ reportError(error, "broadcastMessage");
1582
+ }
1583
+ }
1584
+ }
1585
+ ],
1586
+ onStatusChange: (status) => {
1587
+ if (status === "connected") markReady?.(true);
1588
+ if (status === "disconnected") markReady?.(false);
1589
+ },
1590
+ onBackfill: () => {
1591
+ void backfillConversation(id);
1592
+ outbox.flush();
1593
+ }
1594
+ });
1595
+ openChannels.set(id, handle);
1596
+ channelReadiness.set(id, ready);
1597
+ settleChannelReadiness.set(id, (connected) => markReady?.(connected));
1598
+ if (handle.status() === "connected") markReady?.(true);
1599
+ return ready;
1600
+ }
1369
1601
  const engine = {
1370
1602
  store,
1371
1603
  outbox,
@@ -1391,7 +1623,9 @@ function createMessagingEngine(options) {
1391
1623
  onChange: ({ row }) => {
1392
1624
  if (row === null) return;
1393
1625
  const message = projectMessage(row, identity.organizationId);
1394
- const known = store.snapshot().conversations.some((item) => item.conversation.id === message.conversationId);
1626
+ const known = store.snapshot().conversationSummaries.has(
1627
+ message.conversationId
1628
+ );
1395
1629
  if (!known) {
1396
1630
  void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
1397
1631
  return;
@@ -1399,6 +1633,9 @@ function createMessagingEngine(options) {
1399
1633
  store.ingest(message);
1400
1634
  const isMine = message.senderId === identity.userId;
1401
1635
  store.applyLastMessage(message, { incrementUnread: !isMine });
1636
+ if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1637
+ void persistRead(message.conversationId).catch(() => void 0);
1638
+ }
1402
1639
  if (!isMine) options.onIncoming?.(message);
1403
1640
  }
1404
1641
  },
@@ -1463,18 +1700,35 @@ function createMessagingEngine(options) {
1463
1700
  },
1464
1701
  async openConversation(id) {
1465
1702
  store.setActiveConversation(id);
1703
+ const generation = beginInitialRead(id);
1704
+ let ownedChannel = null;
1466
1705
  try {
1706
+ const connected = await ensureConversationChannel(id);
1707
+ ownedChannel = openChannels.get(id) ?? null;
1708
+ if (!connected || !openIsLive(id, generation, ownedChannel)) return;
1467
1709
  const page = await repository.listMessages(id, { limit: messagePageSize });
1468
- if (page.items.length === 0) {
1469
- await repository.getConversation(id);
1470
- }
1710
+ if (!openIsLive(id, generation, ownedChannel)) return;
1711
+ if (!installInitialPage(id, generation, page)) return;
1712
+ const missingSummary = !store.snapshot().conversationSummaries.has(id);
1713
+ const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1714
+ if (!openIsLive(id, generation, ownedChannel)) return;
1471
1715
  store.setConversationReadFailure(id, null);
1472
- store.setThread(id, page.items, { hasMoreOlder: page.hasMore });
1716
+ if (directSummary !== null) store.setConversationSummary(directSummary);
1717
+ await persistRead(
1718
+ id,
1719
+ () => openIsLive(id, generation, ownedChannel)
1720
+ ).catch(() => void 0);
1473
1721
  } catch (error) {
1722
+ if (!openIsLive(id, generation, ownedChannel)) return;
1474
1723
  const normalized = normalizeMessagingError(error, "openConversation");
1475
1724
  const unavailable = normalized.code === "not-found" || normalized.code === "forbidden";
1476
1725
  store.setConversationReadFailure(id, { error: normalized, unavailable });
1477
1726
  if (unavailable) {
1727
+ openChannels.get(id)?.close();
1728
+ openChannels.delete(id);
1729
+ settleChannelReadiness.delete(id);
1730
+ channelReadiness.delete(id);
1731
+ store.clearThread(id);
1478
1732
  report({
1479
1733
  level: "warn",
1480
1734
  message: normalized.message,
@@ -1483,53 +1737,17 @@ function createMessagingEngine(options) {
1483
1737
  return;
1484
1738
  }
1485
1739
  reportError(normalized, "openConversation");
1740
+ } finally {
1741
+ finishInitialRead(id);
1486
1742
  }
1487
- if (openChannels.has(id) || disposed) return;
1488
- const handle = manager.open({
1489
- topic: conversationTopic(id),
1490
- // Presence callbacks are JOIN-TIME in supabase-js. Declare the
1491
- // read-only room shape on the first holder (the engine) so the
1492
- // composer/typist/online hooks can attach without forcing a rebuild
1493
- // after this durable channel has already subscribed.
1494
- presence: { publish: false, state: {} },
1495
- postgresChanges: [
1496
- {
1497
- event: "*",
1498
- schema: "communication",
1499
- table: "dm_messages",
1500
- filter: `conversation_id=eq.${id}`,
1501
- rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1502
- fingerprint: (row) => typeof row["content"] === "string" ? row["content"] : void 0,
1503
- updatedAtField: "updated_at",
1504
- updatedByField: "updated_by",
1505
- onChange: ({ row }) => {
1506
- if (row === null) return;
1507
- store.ingest(projectMessage(row, identity.organizationId));
1508
- }
1509
- }
1510
- ],
1511
- broadcast: [
1512
- {
1513
- event: MESSAGING_EVENTS.message,
1514
- onMessage: ({ data }) => {
1515
- try {
1516
- store.ingest(projectBroadcastMessage(data));
1517
- } catch (error) {
1518
- reportError(error, "broadcastMessage");
1519
- }
1520
- }
1521
- }
1522
- ],
1523
- onBackfill: () => {
1524
- void backfillConversation(id);
1525
- outbox.flush();
1526
- }
1527
- });
1528
- openChannels.set(id, handle);
1529
1743
  },
1530
1744
  closeConversation(id) {
1745
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1531
1746
  openChannels.get(id)?.close();
1532
1747
  openChannels.delete(id);
1748
+ settleChannelReadiness.get(id)?.(false);
1749
+ settleChannelReadiness.delete(id);
1750
+ channelReadiness.delete(id);
1533
1751
  if (store.snapshot().activeConversationId === id) {
1534
1752
  store.setActiveConversation(null);
1535
1753
  }
@@ -1581,12 +1799,7 @@ function createMessagingEngine(options) {
1581
1799
  }
1582
1800
  },
1583
1801
  async markRead(id) {
1584
- store.markConversationRead(id);
1585
- try {
1586
- await repository.markRead(id);
1587
- } catch (error) {
1588
- reportError(error, "markRead");
1589
- }
1802
+ await persistRead(id);
1590
1803
  },
1591
1804
  async startDirectConversation(otherUserId) {
1592
1805
  const id = await repository.getOrCreateDirectConversation(otherUserId);
@@ -1595,9 +1808,15 @@ function createMessagingEngine(options) {
1595
1808
  },
1596
1809
  dispose() {
1597
1810
  disposed = true;
1811
+ openChannels.forEach((_handle, id) => {
1812
+ initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1813
+ });
1598
1814
  outbox.dispose();
1599
1815
  openChannels.forEach((handle) => handle.close());
1600
1816
  openChannels.clear();
1817
+ settleChannelReadiness.forEach((settle) => settle(false));
1818
+ settleChannelReadiness.clear();
1819
+ channelReadiness.clear();
1601
1820
  inboxChannel?.close();
1602
1821
  inboxChannel = null;
1603
1822
  }
@@ -1854,6 +2073,7 @@ var RPCS = {
1854
2073
  isParticipant: "is_dm_participant"
1855
2074
  };
1856
2075
  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";
2076
+ var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
1857
2077
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
1858
2078
  function optionalOrg(organizationId) {
1859
2079
  return typeof organizationId === "string" && organizationId.length > 0 ? organizationId : null;
@@ -1923,6 +2143,14 @@ function createMessagingRepository(options) {
1923
2143
  function actingUser(operation) {
1924
2144
  return withSessionRetry(operation, () => actingUserId());
1925
2145
  }
2146
+ async function updateLiveParticipant(operation, conversationId, userId, patch) {
2147
+ const destination = await organizationOfExisting(conversationId);
2148
+ 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();
2149
+ if (error !== null) throw normalizeMessagingError(error, operation);
2150
+ if (data === null) {
2151
+ throw writeNotApplied(operation, "participant update returned no live row");
2152
+ }
2153
+ }
1926
2154
  async function rpc(fn, args, operation) {
1927
2155
  const { data, error } = await rpcDb().rpc(fn, args);
1928
2156
  if (error !== null) throw normalizeMessagingError(error, operation);
@@ -1945,6 +2173,33 @@ function createMessagingRepository(options) {
1945
2173
  });
1946
2174
  return found;
1947
2175
  }
2176
+ async function allLiveParticipants(conversationId) {
2177
+ const operation = "getConversationSummary";
2178
+ const pageSize = 1e3;
2179
+ const rows = [];
2180
+ for (let from = 0; ; from += pageSize) {
2181
+ const response = await withSessionRetry(
2182
+ operation,
2183
+ 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)
2184
+ );
2185
+ if (response.error !== null) {
2186
+ throw normalizeMessagingError(response.error, operation);
2187
+ }
2188
+ const total = response.count;
2189
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
2190
+ throw invalidResponse(operation, "participant read returned no exact count");
2191
+ }
2192
+ const page = response.data ?? [];
2193
+ rows.push(...page);
2194
+ if (rows.length >= total) return rows;
2195
+ if (page.length === 0) {
2196
+ throw invalidResponse(
2197
+ operation,
2198
+ `participant read stopped at ${rows.length} of ${total}`
2199
+ );
2200
+ }
2201
+ }
2202
+ }
1948
2203
  const conversationOrganizations = /* @__PURE__ */ new Map();
1949
2204
  async function organizationOfExisting(conversationId) {
1950
2205
  const known = conversationOrganizations.get(conversationId);
@@ -2056,6 +2311,78 @@ function createMessagingRepository(options) {
2056
2311
  );
2057
2312
  return summary.conversation;
2058
2313
  },
2314
+ async getConversationSummary(id) {
2315
+ const operation = "getConversationSummary";
2316
+ const me = await actingUser(operation);
2317
+ const [conversationResult, participantRows, latestResult, unreadRaw] = await withSessionRetry(
2318
+ operation,
2319
+ () => Promise.all([
2320
+ db().from(TABLES.conversations).select(CONVERSATION_COLUMNS).eq("id", id).single(),
2321
+ allLiveParticipants(id),
2322
+ 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(),
2323
+ rpc(
2324
+ RPCS.unreadCount,
2325
+ { p_conversation_id: id, p_user_id: me },
2326
+ operation
2327
+ )
2328
+ ])
2329
+ );
2330
+ if (conversationResult.error !== null) {
2331
+ throw normalizeMessagingError(conversationResult.error, operation);
2332
+ }
2333
+ if (latestResult.error !== null) {
2334
+ throw normalizeMessagingError(latestResult.error, operation);
2335
+ }
2336
+ const conversation = conversationResult.data;
2337
+ if (conversation === null) {
2338
+ throw invalidResponse(operation, `conversation ${id} returned no row`);
2339
+ }
2340
+ if (typeof unreadRaw !== "number" || !Number.isFinite(unreadRaw) || unreadRaw < 0) {
2341
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unreadRaw)}`);
2342
+ }
2343
+ const participantIds = participantRows.flatMap((row) => {
2344
+ const value = row["user_id"];
2345
+ return typeof value === "string" && value.length > 0 ? [projectUserSummary({ user_id: value }).userId] : [];
2346
+ });
2347
+ const profiles = await getUsers(participantIds);
2348
+ const participants = participantIds.map((userId) => {
2349
+ const profile = profiles.get(userId);
2350
+ return profile === void 0 ? {
2351
+ user_id: userId,
2352
+ display_name: userId
2353
+ } : {
2354
+ user_id: profile.userId,
2355
+ display_name: profile.displayName,
2356
+ email: profile.email,
2357
+ avatar_url: profile.avatarUrl,
2358
+ is_agent: profile.isAgent
2359
+ };
2360
+ });
2361
+ const viewer = participantRows.find((row) => row["user_id"] === me);
2362
+ const latest = latestResult.data;
2363
+ return projectConversationSummary(
2364
+ {
2365
+ conversation_id: conversation["id"],
2366
+ conversation_type: conversation["type"],
2367
+ group_name: conversation["group_name"],
2368
+ group_image_url: conversation["group_image_url"],
2369
+ conversation_created_at: conversation["created_at"],
2370
+ conversation_updated_at: conversation["updated_at"],
2371
+ organization_id: conversation["organization_id"],
2372
+ created_by: conversation["created_by"],
2373
+ metadata: conversation["metadata"],
2374
+ participants,
2375
+ last_message_content: latest?.["content"] ?? null,
2376
+ last_message_sender_id: latest?.["sender_id"] ?? null,
2377
+ last_message_at: latest?.["created_at"] ?? null,
2378
+ unread_count: Math.floor(unreadRaw),
2379
+ is_muted: viewer?.["is_muted"] ?? false,
2380
+ is_archived: viewer?.["is_archived"] ?? false
2381
+ },
2382
+ me,
2383
+ org
2384
+ );
2385
+ },
2059
2386
  async listMessages(conversationId, args = {}) {
2060
2387
  const limit = args.limit ?? 50;
2061
2388
  const operation = "listMessages";
@@ -2201,8 +2528,22 @@ function createMessagingRepository(options) {
2201
2528
  async markRead(conversationId, at) {
2202
2529
  const operation = "markRead";
2203
2530
  const me = await actingUser(operation);
2204
- 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);
2205
- if (error !== null) throw normalizeMessagingError(error, operation);
2531
+ await updateLiveParticipant(operation, conversationId, me, {
2532
+ last_read_at: at,
2533
+ updated_by: me
2534
+ });
2535
+ const unread = await withSessionRetry(
2536
+ operation,
2537
+ () => rpc(
2538
+ RPCS.unreadCount,
2539
+ { p_conversation_id: conversationId, p_user_id: me },
2540
+ operation
2541
+ )
2542
+ );
2543
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2544
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2545
+ }
2546
+ return Math.floor(unread);
2206
2547
  },
2207
2548
  async setConversationFlags(conversationId, flags) {
2208
2549
  const operation = "setConversationFlags";
@@ -2210,8 +2551,7 @@ function createMessagingRepository(options) {
2210
2551
  const patch = { updated_by: me };
2211
2552
  if (flags.isMuted !== void 0) patch["is_muted"] = flags.isMuted;
2212
2553
  if (flags.isArchived !== void 0) patch["is_archived"] = flags.isArchived;
2213
- const { error } = await db().from(TABLES.participants).update(patch).eq("conversation_id", conversationId).eq("user_id", me);
2214
- if (error !== null) throw normalizeMessagingError(error, operation);
2554
+ await updateLiveParticipant(operation, conversationId, me, patch);
2215
2555
  },
2216
2556
  async addMembers(conversationId, memberIds) {
2217
2557
  const operation = "addMembers";
@@ -2231,14 +2571,18 @@ function createMessagingRepository(options) {
2231
2571
  async removeMember(conversationId, memberId) {
2232
2572
  const operation = "removeMember";
2233
2573
  const me = await actingUser(operation);
2234
- 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);
2235
- if (error !== null) throw normalizeMessagingError(error, operation);
2574
+ await updateLiveParticipant(operation, conversationId, memberId, {
2575
+ deleted_at: (/* @__PURE__ */ new Date()).toISOString(),
2576
+ updated_by: me
2577
+ });
2236
2578
  },
2237
2579
  async setMemberRole(conversationId, memberId, role) {
2238
2580
  const operation = "setMemberRole";
2239
2581
  const me = await actingUser(operation);
2240
- const { error } = await db().from(TABLES.participants).update({ role, updated_by: me }).eq("conversation_id", conversationId).eq("user_id", memberId);
2241
- if (error !== null) throw normalizeMessagingError(error, operation);
2582
+ await updateLiveParticipant(operation, conversationId, memberId, {
2583
+ role,
2584
+ updated_by: me
2585
+ });
2242
2586
  },
2243
2587
  getUser(userId) {
2244
2588
  const operation = "getUser";
@@ -2327,6 +2671,7 @@ export {
2327
2671
  splitText,
2328
2672
  summarizeText,
2329
2673
  toMessagingArchiveFilter,
2330
- unreadCutoff
2674
+ unreadCutoff,
2675
+ writeNotApplied
2331
2676
  };
2332
2677
  //# sourceMappingURL=index.js.map