@ai-matrx/messaging 0.10.5 → 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,56 @@
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
+
3
54
  ## 0.10.5 — 2026-09-09
4
55
 
5
56
  ### 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));
@@ -1281,11 +1310,20 @@ function createMessagingEngine(options) {
1281
1310
  }
1282
1311
  async function reloadInbox() {
1283
1312
  if (disposed) return;
1284
- const page = await repository.listConversations({ limit: conversationPageSize });
1313
+ const page = await repository.listConversations({
1314
+ limit: conversationPageSize,
1315
+ archived: store.snapshot().archiveFilter
1316
+ });
1285
1317
  if (disposed) return;
1286
1318
  conversationCursor = page.nextCursor;
1287
1319
  store.setConversations(page.items, page.hasMore);
1288
1320
  }
1321
+ async function reloadArchivedCount() {
1322
+ if (disposed) return;
1323
+ const value = await repository.countArchivedConversations();
1324
+ if (disposed) return;
1325
+ store.setArchivedCount(value);
1326
+ }
1289
1327
  async function backfillConversation(id) {
1290
1328
  const thread = store.snapshot().threads.get(id);
1291
1329
  const since = thread?.latestAt ?? null;
@@ -1312,7 +1350,13 @@ function createMessagingEngine(options) {
1312
1350
  outbox,
1313
1351
  identity,
1314
1352
  async start() {
1353
+ if (options.archiveFilter !== void 0) {
1354
+ store.setArchiveFilter(options.archiveFilter);
1355
+ }
1315
1356
  await reloadInbox();
1357
+ void reloadArchivedCount().catch(
1358
+ (error) => reportError(error, "refreshArchivedCount")
1359
+ );
1316
1360
  if (disposed || inboxChannel !== null) return;
1317
1361
  inboxChannel = manager.open({
1318
1362
  topic: inboxTopic(identity.userId),
@@ -1354,12 +1398,33 @@ function createMessagingEngine(options) {
1354
1398
  }
1355
1399
  });
1356
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
+ },
1357
1421
  async loadMoreConversations() {
1358
1422
  if (conversationCursor === null) return;
1359
1423
  try {
1360
1424
  const page = await repository.listConversations({
1361
1425
  limit: conversationPageSize,
1362
- cursor: conversationCursor
1426
+ cursor: conversationCursor,
1427
+ archived: store.snapshot().archiveFilter
1363
1428
  });
1364
1429
  conversationCursor = page.nextCursor;
1365
1430
  store.appendConversations(page.items, page.hasMore);
@@ -1668,6 +1733,7 @@ function createMessagingRepository(options) {
1668
1733
  identity,
1669
1734
  async listConversations(args = {}) {
1670
1735
  const limit = args.limit ?? 30;
1736
+ const archived = args.archived ?? DEFAULT_MESSAGING_ARCHIVE_FILTER;
1671
1737
  const operation = "listConversations";
1672
1738
  const rows = await withSessionRetry(
1673
1739
  operation,
@@ -1677,7 +1743,12 @@ function createMessagingRepository(options) {
1677
1743
  p_user_id: identity.userId,
1678
1744
  p_limit: limit + 1,
1679
1745
  p_before_sort_at: args.cursor?.beforeSortAt ?? null,
1680
- 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
1681
1752
  },
1682
1753
  operation
1683
1754
  )
@@ -1697,6 +1768,29 @@ function createMessagingRepository(options) {
1697
1768
  nextCursor: hasMore && last !== void 0 ? { beforeSortAt: last.sortAt, beforeConversationId: last.conversation.id } : null
1698
1769
  };
1699
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
+ },
1700
1794
  async getConversation(id) {
1701
1795
  const operation = "getConversation";
1702
1796
  const { data, error } = await withSessionRetry(
@@ -1930,11 +2024,4 @@ function createMessagingRepository(options) {
1930
2024
  };
1931
2025
  return repository;
1932
2026
  }
1933
-
1934
- // src/core/types.ts
1935
- var asConversationId = (value) => value;
1936
- var asMessageId = (value) => value;
1937
- var asUserId = (value) => value;
1938
- var asOrganizationId = (value) => value;
1939
- var asClientMessageId = (value) => value;
1940
2027
  //# sourceMappingURL=index.cjs.map