@ai-matrx/messaging 0.10.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog — `@ai-matrx/messaging`
2
2
 
3
+ ## 0.11.0 — 2026-09-10
4
+
5
+ ### THE ARCHIVED-ITEMS LAW reaches conversations
6
+
7
+ > "everything should have an archive filter, and the default should always hide archived, but
8
+ > seeing archived items should be one or two clicks away … this is a system wide decision for
9
+ > every single item everywhere in our system, for every single table and every single page."
10
+ > — Arman, 2026-09-09 (`common-docs/policies/archived-items.md`)
11
+
12
+ This package could ARCHIVE a conversation — `setConversationFlags({ isArchived: true })` — and
13
+ had no read side for one. No filter, no parameter, no affordance anywhere could show it again,
14
+ and `get_dm_conversations_with_details` hardcoded the exclusion with no parameter of its own.
15
+ An archived conversation was not hidden. It was unreachable.
16
+
17
+ - **`MessagingArchiveFilter`** — the platform tri-state (`active` | `archived` | `all`), the
18
+ same three words as matrx-frontend's `lib/entity-list`, the `agx_`/`wfx_` list RPCs and
19
+ `@ai-matrx/design-system`'s `ArchiveFilter`. Shipped with
20
+ `DEFAULT_MESSAGING_ARCHIVE_FILTER` (`active`) and `toMessagingArchiveFilter()`, which
21
+ narrows an untrusted value and falls back to the default rather than silently widening a
22
+ list to archived rows. Three states, never a boolean: a boolean cannot say "archived only".
23
+ - **`repository.listConversations({ archived })`** passes `p_archived` to the RPC, which
24
+ gained that parameter on 2026-09-09 (register row R1). The axis is a REQUEST to the reader,
25
+ never a client-side sieve — so `hasMore`, the cursor and any count describe the rows the
26
+ caller actually asked for.
27
+ - **`repository.countArchivedConversations()`** answers the reveal's label. It is CAPPED and
28
+ says so: `{ count, exact }`, and `exact: false` must render "12+", never a confident "12".
29
+ - **The engine** carries the axis on every read — first page, "load more", and the reconnect
30
+ backfill — with `setArchiveFilter()` re-reading page one and refreshing the count. Flipping
31
+ the filter returns the store to NOT-LOADED, not to "loaded and empty": leaving the old rows
32
+ up renders active conversations under an "Archived" heading, and an empty "loaded" list
33
+ tells someone with forty archived chats she has none.
34
+ - **`ConversationList`** renders the reveal — **"Archived (N)"** → the archive → "← Back to
35
+ active conversations". One click, and it appears only when it would do something: while
36
+ hiding, when something is there to reveal; while showing, always, so the way back is never
37
+ missing. A button that changes nothing is a dead end, not an affordance.
38
+ - **`useConversations()`** returns `archiveFilter`, `setArchiveFilter` and `archivedCount`.
39
+
40
+ ### Consumer action
41
+
42
+ - **Nothing breaks.** Every existing signature is unchanged and every default is the old
43
+ behaviour: a host that passes nothing still hides archived conversations.
44
+ - **Delete your local archive split.** A host that worked around this gap by fetching
45
+ everything and partitioning `ConversationSummary.isArchived` in its own component is now
46
+ reading rows the server would not have sent, and its counts and pagination describe a
47
+ different set from the one on screen. Drop the local split and the `include_archived`-style
48
+ boolean beside it; `ConversationList` carries the control, and a host list built on
49
+ `useConversations()` reads `archiveFilter` / `setArchiveFilter` / `archivedCount`.
50
+ - **`<MessagingProvider archiveFilter={…}>` is the knob** (root law 6, and clause 6 of the
51
+ law itself): the initial state is an org/user setting, not this package's taste. Wire it to
52
+ whatever your setting resolves to; omit it and you get the platform default, `active`.
53
+
54
+ ## 0.10.5 — 2026-09-09
55
+
56
+ ### Fixed
57
+
58
+ - Membership and new-conversation realtime refresh failures now reach the engine diagnostic
59
+ handler with their original permission classification instead of escaping as unhandled
60
+ promises. Inbox reads do not start after disposal, and late completions do not update the
61
+ disposed engine's store.
62
+
63
+ ### Consumer action (C28)
64
+
65
+ Upgrade the package. No host API changes or database permission changes.
66
+
3
67
  ## 0.10.4 — 2026-09-08
4
68
 
5
69
  ### Fixed
package/README.md CHANGED
@@ -333,6 +333,39 @@ import { createMessagingEngine, createMessagingRepository } from "@ai-matrx/mess
333
333
 
334
334
  ---
335
335
 
336
+ ## Archived conversations
337
+
338
+ THE ARCHIVED-ITEMS LAW (Arman, 2026-09-09 — `common-docs/policies/archived-items.md`): every
339
+ list over an entity that can be archived carries an archive filter, the default hides archived
340
+ rows, and revealing them is one or two clicks. Three states, and no more:
341
+
342
+ | value | what the list shows |
343
+ |---|---|
344
+ | `active` | only un-archived conversations (the default) |
345
+ | `archived` | only archived conversations |
346
+ | `all` | every conversation |
347
+
348
+ **The filter is a REQUEST to the reader, never a client-side sieve.** It travels as
349
+ `p_archived` on `get_dm_conversations_with_details`, so `hasMore`, the pagination cursor and
350
+ every count describe the rows the caller actually asked for. A host that fetches everything and
351
+ partitions `ConversationSummary.isArchived` in its own component is reading rows the server
352
+ would not have sent, and its counts describe a different set from the one on screen.
353
+
354
+ `ConversationList` already carries the control — **"Archived (N)"**, one click, and a way back.
355
+ The count comes from `countArchivedConversations()` and is CAPPED: `exact: false` renders "N+",
356
+ never a confident total. A host building its own list reads `archiveFilter`, `setArchiveFilter`
357
+ and `archivedCount` from `useConversations()`.
358
+
359
+ The initial state is a KNOB, not this package's taste (root law 6, and clause 6 of the law):
360
+
361
+ ```tsx
362
+ <MessagingProvider archiveFilter={settings.lists.archivedDefault} … >
363
+ ```
364
+
365
+ Omit it and you get the platform default, `active`.
366
+
367
+ ---
368
+
336
369
  ## The database contract
337
370
 
338
371
  One canonical schema, in Matrx Main, under the platform's DB conventions:
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ DEFAULT_MESSAGING_ARCHIVE_FILTER: () => DEFAULT_MESSAGING_ARCHIVE_FILTER,
23
24
  MESSAGING_EVENTS: () => MESSAGING_EVENTS,
24
25
  MESSAGING_RPC_SCHEMA: () => MESSAGING_RPC_SCHEMA,
25
26
  MESSAGING_SCHEMA: () => MESSAGING_SCHEMA,
@@ -65,6 +66,7 @@ __export(src_exports, {
65
66
  resolveActor: () => resolveActor,
66
67
  splitText: () => splitText,
67
68
  summarizeText: () => summarizeText,
69
+ toMessagingArchiveFilter: () => toMessagingArchiveFilter,
68
70
  unreadCutoff: () => unreadCutoff
69
71
  });
70
72
  module.exports = __toCommonJS(src_exports);
@@ -986,6 +988,17 @@ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
986
988
  };
987
989
  }
988
990
 
991
+ // src/core/types.ts
992
+ var asConversationId = (value) => value;
993
+ var asMessageId = (value) => value;
994
+ var asUserId = (value) => value;
995
+ var asOrganizationId = (value) => value;
996
+ var asClientMessageId = (value) => value;
997
+ var DEFAULT_MESSAGING_ARCHIVE_FILTER = "active";
998
+ function toMessagingArchiveFilter(value, fallback = DEFAULT_MESSAGING_ARCHIVE_FILTER) {
999
+ return value === "active" || value === "archived" || value === "all" ? value : fallback;
1000
+ }
1001
+
989
1002
  // src/core/store.ts
990
1003
  function timeOf(message) {
991
1004
  const stamp = message.editedAt ?? message.createdAt;
@@ -1016,6 +1029,8 @@ function createMessagingStore() {
1016
1029
  let conversations = [];
1017
1030
  let hasMoreConversations = false;
1018
1031
  let hasLoadedConversations = false;
1032
+ let archiveFilter = DEFAULT_MESSAGING_ARCHIVE_FILTER;
1033
+ let archivedCount = null;
1019
1034
  let threads = /* @__PURE__ */ new Map();
1020
1035
  let activeConversationId = null;
1021
1036
  const listeners = /* @__PURE__ */ new Set();
@@ -1028,7 +1043,9 @@ function createMessagingStore() {
1028
1043
  hasLoadedConversations,
1029
1044
  threads,
1030
1045
  activeConversationId,
1031
- totalUnreadConversations: conversations.filter((item) => item.unreadCount > 0).length
1046
+ totalUnreadConversations: conversations.filter((item) => item.unreadCount > 0).length,
1047
+ archiveFilter,
1048
+ archivedCount
1032
1049
  };
1033
1050
  return cached;
1034
1051
  }
@@ -1075,6 +1092,18 @@ function createMessagingStore() {
1075
1092
  hasLoadedConversations = true;
1076
1093
  emit();
1077
1094
  },
1095
+ setArchiveFilter(next) {
1096
+ if (next === archiveFilter) return;
1097
+ archiveFilter = next;
1098
+ conversations = [];
1099
+ hasMoreConversations = false;
1100
+ hasLoadedConversations = false;
1101
+ emit();
1102
+ },
1103
+ setArchivedCount(value) {
1104
+ archivedCount = value;
1105
+ emit();
1106
+ },
1078
1107
  appendConversations(items, hasMore) {
1079
1108
  const byId = new Map(conversations.map((item) => [item.conversation.id, item]));
1080
1109
  items.forEach((item) => byId.set(item.conversation.id, item));
@@ -1280,10 +1309,21 @@ function createMessagingEngine(options) {
1280
1309
  openChannels.get(message.conversationId)?.send(MESSAGING_EVENTS.message, message);
1281
1310
  }
1282
1311
  async function reloadInbox() {
1283
- const page = await repository.listConversations({ limit: conversationPageSize });
1312
+ if (disposed) return;
1313
+ const page = await repository.listConversations({
1314
+ limit: conversationPageSize,
1315
+ archived: store.snapshot().archiveFilter
1316
+ });
1317
+ if (disposed) return;
1284
1318
  conversationCursor = page.nextCursor;
1285
1319
  store.setConversations(page.items, page.hasMore);
1286
1320
  }
1321
+ async function reloadArchivedCount() {
1322
+ if (disposed) return;
1323
+ const value = await repository.countArchivedConversations();
1324
+ if (disposed) return;
1325
+ store.setArchivedCount(value);
1326
+ }
1287
1327
  async function backfillConversation(id) {
1288
1328
  const thread = store.snapshot().threads.get(id);
1289
1329
  const since = thread?.latestAt ?? null;
@@ -1310,7 +1350,13 @@ function createMessagingEngine(options) {
1310
1350
  outbox,
1311
1351
  identity,
1312
1352
  async start() {
1353
+ if (options.archiveFilter !== void 0) {
1354
+ store.setArchiveFilter(options.archiveFilter);
1355
+ }
1313
1356
  await reloadInbox();
1357
+ void reloadArchivedCount().catch(
1358
+ (error) => reportError(error, "refreshArchivedCount")
1359
+ );
1314
1360
  if (disposed || inboxChannel !== null) return;
1315
1361
  inboxChannel = manager.open({
1316
1362
  topic: inboxTopic(identity.userId),
@@ -1325,7 +1371,7 @@ function createMessagingEngine(options) {
1325
1371
  const message = projectMessage(row, identity.organizationId);
1326
1372
  const known = store.snapshot().conversations.some((item) => item.conversation.id === message.conversationId);
1327
1373
  if (!known) {
1328
- void reloadInbox();
1374
+ void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
1329
1375
  return;
1330
1376
  }
1331
1377
  store.ingest(message);
@@ -1341,7 +1387,7 @@ function createMessagingEngine(options) {
1341
1387
  filter: `user_id=eq.${identity.userId}`,
1342
1388
  rowId: (row) => typeof row["id"] === "string" ? row["id"] : void 0,
1343
1389
  onChange: () => {
1344
- void reloadInbox();
1390
+ void reloadInbox().catch((error) => reportError(error, "inboxRefresh"));
1345
1391
  }
1346
1392
  }
1347
1393
  ],
@@ -1352,12 +1398,33 @@ function createMessagingEngine(options) {
1352
1398
  }
1353
1399
  });
1354
1400
  },
1401
+ async setArchiveFilter(next) {
1402
+ if (next === store.snapshot().archiveFilter) return;
1403
+ store.setArchiveFilter(next);
1404
+ conversationCursor = null;
1405
+ try {
1406
+ await reloadInbox();
1407
+ } catch (error) {
1408
+ reportError(error, "setArchiveFilter");
1409
+ }
1410
+ await reloadArchivedCount().catch(
1411
+ (error) => reportError(error, "refreshArchivedCount")
1412
+ );
1413
+ },
1414
+ async refreshArchivedCount() {
1415
+ try {
1416
+ await reloadArchivedCount();
1417
+ } catch (error) {
1418
+ reportError(error, "refreshArchivedCount");
1419
+ }
1420
+ },
1355
1421
  async loadMoreConversations() {
1356
1422
  if (conversationCursor === null) return;
1357
1423
  try {
1358
1424
  const page = await repository.listConversations({
1359
1425
  limit: conversationPageSize,
1360
- cursor: conversationCursor
1426
+ cursor: conversationCursor,
1427
+ archived: store.snapshot().archiveFilter
1361
1428
  });
1362
1429
  conversationCursor = page.nextCursor;
1363
1430
  store.appendConversations(page.items, page.hasMore);
@@ -1666,6 +1733,7 @@ function createMessagingRepository(options) {
1666
1733
  identity,
1667
1734
  async listConversations(args = {}) {
1668
1735
  const limit = args.limit ?? 30;
1736
+ const archived = args.archived ?? DEFAULT_MESSAGING_ARCHIVE_FILTER;
1669
1737
  const operation = "listConversations";
1670
1738
  const rows = await withSessionRetry(
1671
1739
  operation,
@@ -1675,7 +1743,12 @@ function createMessagingRepository(options) {
1675
1743
  p_user_id: identity.userId,
1676
1744
  p_limit: limit + 1,
1677
1745
  p_before_sort_at: args.cursor?.beforeSortAt ?? null,
1678
- p_before_conversation_id: args.cursor?.beforeConversationId ?? null
1746
+ p_before_conversation_id: args.cursor?.beforeConversationId ?? null,
1747
+ // THE ARCHIVED-ITEMS LAW, SERVER-side. `get_dm_conversations_with_details`
1748
+ // used to hardcode `is_archived IS FALSE` with no parameter at all,
1749
+ // so an archived conversation was not hidden — it was unreachable.
1750
+ // The RPC gained `p_archived` on 2026-09-09 (register row R1).
1751
+ p_archived: archived
1679
1752
  },
1680
1753
  operation
1681
1754
  )
@@ -1695,6 +1768,29 @@ function createMessagingRepository(options) {
1695
1768
  nextCursor: hasMore && last !== void 0 ? { beforeSortAt: last.sortAt, beforeConversationId: last.conversation.id } : null
1696
1769
  };
1697
1770
  },
1771
+ async countArchivedConversations(args = {}) {
1772
+ const limit = args.limit ?? 100;
1773
+ const operation = "countArchivedConversations";
1774
+ const rows = await withSessionRetry(
1775
+ operation,
1776
+ () => rpc(
1777
+ RPCS.conversationsWithDetails,
1778
+ {
1779
+ p_user_id: identity.userId,
1780
+ p_limit: limit + 1,
1781
+ p_before_sort_at: null,
1782
+ p_before_conversation_id: null,
1783
+ p_archived: "archived"
1784
+ },
1785
+ operation
1786
+ )
1787
+ );
1788
+ if (rows !== null && !Array.isArray(rows)) {
1789
+ throw invalidResponse(operation, `${RPCS.conversationsWithDetails} did not return rows`);
1790
+ }
1791
+ const found = (rows ?? []).length;
1792
+ return found > limit ? { count: limit, exact: false } : { count: found, exact: true };
1793
+ },
1698
1794
  async getConversation(id) {
1699
1795
  const operation = "getConversation";
1700
1796
  const { data, error } = await withSessionRetry(
@@ -1928,11 +2024,4 @@ function createMessagingRepository(options) {
1928
2024
  };
1929
2025
  return repository;
1930
2026
  }
1931
-
1932
- // src/core/types.ts
1933
- var asConversationId = (value) => value;
1934
- var asMessageId = (value) => value;
1935
- var asUserId = (value) => value;
1936
- var asOrganizationId = (value) => value;
1937
- var asClientMessageId = (value) => value;
1938
2027
  //# sourceMappingURL=index.cjs.map