@ai-matrx/messaging 0.15.82 → 0.16.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/dist/index.d.cts CHANGED
@@ -134,6 +134,13 @@ interface Message {
134
134
  readonly attachments: readonly Attachment[];
135
135
  readonly references: readonly MatrxReference[];
136
136
  readonly metadata: JsonObject;
137
+ /**
138
+ * SOFT EXPIRY (`dm_messages.soft_expires_at`). Past this instant the message
139
+ * is LAPSED: still readable and actionable, never hidden or edited, and it no
140
+ * longer counts as unread. `null` = never lapses. Ask `isLapsed`, never
141
+ * compare this yourself — that is the one lapse rule.
142
+ */
143
+ readonly softExpiresAt: string | null;
137
144
  /** Set only while this message is in the outbox and its last send failed. */
138
145
  readonly failureReason?: string | undefined;
139
146
  }
@@ -222,6 +229,8 @@ interface DraftMessage {
222
229
  readonly attachments?: readonly Attachment[] | undefined;
223
230
  readonly references?: readonly MatrxReference[] | undefined;
224
231
  readonly metadata?: JsonObject | undefined;
232
+ /** ISO instant after which the message is lapsed (see `Message.softExpiresAt`). */
233
+ readonly softExpiresAt?: string | undefined;
225
234
  }
226
235
 
227
236
  /**
@@ -939,9 +948,22 @@ declare const RPCS: {
939
948
  /** Conversation list + participants + last message + unread, keyset paged. */
940
949
  readonly conversationsWithDetails: "get_dm_conversations_with_details";
941
950
  readonly unreadCount: "get_dm_unread_count";
951
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
952
+ readonly pendingSoftExpiries: "get_dm_pending_soft_expiries";
942
953
  readonly userInfo: "get_dm_user_info";
943
954
  readonly isParticipant: "is_dm_participant";
944
955
  };
956
+ /** The answer to `pendingSoftExpiries`: per conversation, and the server's own clock. */
957
+ interface PendingSoftExpiries {
958
+ /** Soonest FUTURE `soft_expires_at` per conversation; absent = nothing pending. */
959
+ readonly byConversation: ReadonlyMap<ConversationId, string>;
960
+ /**
961
+ * The database's `now()` when it answered. The engine measures its clock against this and
962
+ * judges lapse on the SERVER's clock — a skewed browser clock never suppresses a count.
963
+ * `null` when nothing was asked (no conversations), so no clock was read.
964
+ */
965
+ readonly serverNow: string | null;
966
+ }
945
967
  /**
946
968
  * The host's session source. A messaging read that lands between sign-in and
947
969
  * token arrival must not become a red error — it retries ONCE after the host
@@ -1054,6 +1076,20 @@ interface MessagingRepository {
1054
1076
  deleteMessage(id: MessageId, forEveryone: boolean): Promise<void>;
1055
1077
  /** Persists the cutoff, then returns the server's remaining unread count. */
1056
1078
  markRead(conversationId: ConversationId, at: string): Promise<number>;
1079
+ /**
1080
+ * The server's unread count for one conversation — READ ONLY. It never moves
1081
+ * the watermark. The soft-expiry timer uses it to re-derive unread once a
1082
+ * counted message lapses, instead of guessing with local arithmetic.
1083
+ */
1084
+ unreadCount(conversationId: ConversationId): Promise<number>;
1085
+ /**
1086
+ * The soonest FUTURE `soft_expires_at` among other people's live messages,
1087
+ * per conversation, for the given conversations, and the server's clock.
1088
+ * Conversations with nothing pending are absent. One call per
1089
+ * `PENDING_SOFT_EXPIRY_CHUNK` conversations, each answering every one of them
1090
+ * — this is what lets ONE timer re-derive unread when a counted message lapses.
1091
+ */
1092
+ pendingSoftExpiries(conversationIds: readonly ConversationId[]): Promise<PendingSoftExpiries>;
1057
1093
  setConversationFlags(conversationId: ConversationId, flags: {
1058
1094
  isMuted?: boolean;
1059
1095
  isArchived?: boolean;
@@ -1098,6 +1134,10 @@ declare function createMessagingRepository(options: RepositoryOptions): Messagin
1098
1134
  * clears unread counts. Only a confirmed participant write may do that, and
1099
1135
  * overlapping confirmations apply monotonically so one baseline is never
1100
1136
  * subtracted twice.
1137
+ * 6. **A lapsed message never counts as unread** (`isLapsed`, the one lapse
1138
+ * rule). It is ingested and drawn like any other message; it just never
1139
+ * increments a badge. Messages that lapse LATER are re-derived from the
1140
+ * server by the engine's one soft-expiry timer, never subtracted here.
1101
1141
  */
1102
1142
 
1103
1143
  /**
@@ -1229,7 +1269,11 @@ interface ConversationReadCutoff {
1229
1269
  /** Per-conversation unread-state revision observed when this read began. */
1230
1270
  readonly revision: number;
1231
1271
  }
1232
- declare function createMessagingStore(): MessagingStore;
1272
+ interface MessagingStoreOptions {
1273
+ /** The clock the lapse rule reads. Defaults to `Date.now`. */
1274
+ now?: (() => number) | undefined;
1275
+ }
1276
+ declare function createMessagingStore(options?: MessagingStoreOptions): MessagingStore;
1233
1277
  /**
1234
1278
  * The optimistic row a composer shows the instant Enter is pressed. It carries
1235
1279
  * the outbox's client key, which is the whole reason the confirmed row can find
@@ -1246,6 +1290,7 @@ declare function optimisticMessage(args: {
1246
1290
  action?: Message["action"];
1247
1291
  attachments?: readonly Message["attachments"][number][];
1248
1292
  references?: readonly Message["references"][number][];
1293
+ softExpiresAt?: string | null;
1249
1294
  now?: () => number;
1250
1295
  }): Message;
1251
1296
 
@@ -1294,6 +1339,8 @@ interface MessagingEngineOptions {
1294
1339
  * the list.
1295
1340
  */
1296
1341
  archiveFilter?: MessagingArchiveFilter | undefined;
1342
+ /** The clock the soft-expiry rule reads. Defaults to `Date.now`. */
1343
+ now?: (() => number) | undefined;
1297
1344
  }
1298
1345
  interface MessagingEngine {
1299
1346
  readonly store: MessagingStore;
@@ -1384,6 +1431,28 @@ declare function participantNames(participants: readonly UserSummary[], excludin
1384
1431
  */
1385
1432
  declare function formatLastSeen(lastSeenMs: number | null, now?: number): string;
1386
1433
 
1434
+ /**
1435
+ * THE LAPSE RULE — the only place in this package that decides whether a
1436
+ * message has soft-expired.
1437
+ *
1438
+ * A message carrying `softExpiresAt` (a meeting invitation is worth reading
1439
+ * until an hour after the meeting starts) is LAPSED once that instant has
1440
+ * passed. Lapsed is DERIVED from the clock every time it is asked, never
1441
+ * stamped on the row: there is no sweeper and nothing to drift. The database's
1442
+ * `get_dm_unread_count` applies the same comparison (`soft_expires_at > now()`
1443
+ * still counts), so the badge the server computes and the bubble the client
1444
+ * draws agree.
1445
+ *
1446
+ * A lapsed message stays readable and its actions keep working. It simply
1447
+ * stops counting as unread — and the reader's watermark is NEVER moved for it,
1448
+ * because moving `last_read_at` would mark every other message read too.
1449
+ */
1450
+
1451
+ /** Parse a soft-expiry instant; `null` when absent or unparseable (never lapses). */
1452
+ declare function softExpiryTime(message: Pick<Message, "softExpiresAt">): number | null;
1453
+ /** True when the message's soft expiry is at or before `now` (epoch ms or Date). */
1454
+ declare function isLapsed(message: Pick<Message, "softExpiresAt">, now?: number | Date): boolean;
1455
+
1387
1456
  /**
1388
1457
  * THE INGRESS BOUNDARY — where a database row becomes a domain object.
1389
1458
  *
@@ -1466,4 +1535,4 @@ declare function summarizeText(content: string, maxLength?: number): string;
1466
1535
  /** Serialize picked references into a fence the platform's other readers accept. */
1467
1536
  declare function composeFence(references: readonly MatrxReference[]): string;
1468
1537
 
1469
- export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, type AiCallArgs, type AiCapability, type AiResult, type Attachment, type ClientMessageId, type Conversation, type ConversationAiContext, type ConversationCursor, type ConversationId, type ConversationReadCutoff, type ConversationReadFailure, type ConversationSummary, type ConversationThread, type ConversationType, DEFAULT_MESSAGING_ARCHIVE_FILTER, type DeliveryState, type DraftMessage, type EngineDiagnostic, type JsonObject, type JsonValue, MESSAGING_EVENTS, MESSAGING_RPC_SCHEMA, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgentIdentity, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingArchiveFilter, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingIdentity, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type MessagingSupabaseClient, type MessagingSupabaseInternal, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, type Participant, type ParticipantRole, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, type RepositoryOptions, SERVER_SESSION_IDENTITY, type SchemaLike, type SessionIdentity, type SessionIdentitySnapshot, type SessionResolver, type SupabaseLike, TABLES, type TextSegment, type UserId, type UserSummary, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, composeFence, conversationContextVariables, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createSessionIdentity, createWebOutboxStorage, effectiveActorKey, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, groupMessages, inboxTopic, invalidResponse, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, splitText, summarizeText, toMessagingArchiveFilter, unreadCutoff, writeNotApplied };
1538
+ export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, type AiCallArgs, type AiCapability, type AiResult, type Attachment, type ClientMessageId, type Conversation, type ConversationAiContext, type ConversationCursor, type ConversationId, type ConversationReadCutoff, type ConversationReadFailure, type ConversationSummary, type ConversationThread, type ConversationType, DEFAULT_MESSAGING_ARCHIVE_FILTER, type DeliveryState, type DraftMessage, type EngineDiagnostic, type JsonObject, type JsonValue, MESSAGING_EVENTS, MESSAGING_RPC_SCHEMA, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgentIdentity, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingArchiveFilter, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingIdentity, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type MessagingSupabaseClient, type MessagingSupabaseInternal, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, type Participant, type ParticipantRole, type PendingSoftExpiries, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, type RepositoryOptions, SERVER_SESSION_IDENTITY, type SchemaLike, type SessionIdentity, type SessionIdentitySnapshot, type SessionResolver, type SupabaseLike, TABLES, type TextSegment, type UserId, type UserSummary, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, composeFence, conversationContextVariables, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createSessionIdentity, createWebOutboxStorage, effectiveActorKey, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, groupMessages, inboxTopic, invalidResponse, isLapsed, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, softExpiryTime, splitText, summarizeText, toMessagingArchiveFilter, unreadCutoff, writeNotApplied };
package/dist/index.d.ts CHANGED
@@ -134,6 +134,13 @@ interface Message {
134
134
  readonly attachments: readonly Attachment[];
135
135
  readonly references: readonly MatrxReference[];
136
136
  readonly metadata: JsonObject;
137
+ /**
138
+ * SOFT EXPIRY (`dm_messages.soft_expires_at`). Past this instant the message
139
+ * is LAPSED: still readable and actionable, never hidden or edited, and it no
140
+ * longer counts as unread. `null` = never lapses. Ask `isLapsed`, never
141
+ * compare this yourself — that is the one lapse rule.
142
+ */
143
+ readonly softExpiresAt: string | null;
137
144
  /** Set only while this message is in the outbox and its last send failed. */
138
145
  readonly failureReason?: string | undefined;
139
146
  }
@@ -222,6 +229,8 @@ interface DraftMessage {
222
229
  readonly attachments?: readonly Attachment[] | undefined;
223
230
  readonly references?: readonly MatrxReference[] | undefined;
224
231
  readonly metadata?: JsonObject | undefined;
232
+ /** ISO instant after which the message is lapsed (see `Message.softExpiresAt`). */
233
+ readonly softExpiresAt?: string | undefined;
225
234
  }
226
235
 
227
236
  /**
@@ -939,9 +948,22 @@ declare const RPCS: {
939
948
  /** Conversation list + participants + last message + unread, keyset paged. */
940
949
  readonly conversationsWithDetails: "get_dm_conversations_with_details";
941
950
  readonly unreadCount: "get_dm_unread_count";
951
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
952
+ readonly pendingSoftExpiries: "get_dm_pending_soft_expiries";
942
953
  readonly userInfo: "get_dm_user_info";
943
954
  readonly isParticipant: "is_dm_participant";
944
955
  };
956
+ /** The answer to `pendingSoftExpiries`: per conversation, and the server's own clock. */
957
+ interface PendingSoftExpiries {
958
+ /** Soonest FUTURE `soft_expires_at` per conversation; absent = nothing pending. */
959
+ readonly byConversation: ReadonlyMap<ConversationId, string>;
960
+ /**
961
+ * The database's `now()` when it answered. The engine measures its clock against this and
962
+ * judges lapse on the SERVER's clock — a skewed browser clock never suppresses a count.
963
+ * `null` when nothing was asked (no conversations), so no clock was read.
964
+ */
965
+ readonly serverNow: string | null;
966
+ }
945
967
  /**
946
968
  * The host's session source. A messaging read that lands between sign-in and
947
969
  * token arrival must not become a red error — it retries ONCE after the host
@@ -1054,6 +1076,20 @@ interface MessagingRepository {
1054
1076
  deleteMessage(id: MessageId, forEveryone: boolean): Promise<void>;
1055
1077
  /** Persists the cutoff, then returns the server's remaining unread count. */
1056
1078
  markRead(conversationId: ConversationId, at: string): Promise<number>;
1079
+ /**
1080
+ * The server's unread count for one conversation — READ ONLY. It never moves
1081
+ * the watermark. The soft-expiry timer uses it to re-derive unread once a
1082
+ * counted message lapses, instead of guessing with local arithmetic.
1083
+ */
1084
+ unreadCount(conversationId: ConversationId): Promise<number>;
1085
+ /**
1086
+ * The soonest FUTURE `soft_expires_at` among other people's live messages,
1087
+ * per conversation, for the given conversations, and the server's clock.
1088
+ * Conversations with nothing pending are absent. One call per
1089
+ * `PENDING_SOFT_EXPIRY_CHUNK` conversations, each answering every one of them
1090
+ * — this is what lets ONE timer re-derive unread when a counted message lapses.
1091
+ */
1092
+ pendingSoftExpiries(conversationIds: readonly ConversationId[]): Promise<PendingSoftExpiries>;
1057
1093
  setConversationFlags(conversationId: ConversationId, flags: {
1058
1094
  isMuted?: boolean;
1059
1095
  isArchived?: boolean;
@@ -1098,6 +1134,10 @@ declare function createMessagingRepository(options: RepositoryOptions): Messagin
1098
1134
  * clears unread counts. Only a confirmed participant write may do that, and
1099
1135
  * overlapping confirmations apply monotonically so one baseline is never
1100
1136
  * subtracted twice.
1137
+ * 6. **A lapsed message never counts as unread** (`isLapsed`, the one lapse
1138
+ * rule). It is ingested and drawn like any other message; it just never
1139
+ * increments a badge. Messages that lapse LATER are re-derived from the
1140
+ * server by the engine's one soft-expiry timer, never subtracted here.
1101
1141
  */
1102
1142
 
1103
1143
  /**
@@ -1229,7 +1269,11 @@ interface ConversationReadCutoff {
1229
1269
  /** Per-conversation unread-state revision observed when this read began. */
1230
1270
  readonly revision: number;
1231
1271
  }
1232
- declare function createMessagingStore(): MessagingStore;
1272
+ interface MessagingStoreOptions {
1273
+ /** The clock the lapse rule reads. Defaults to `Date.now`. */
1274
+ now?: (() => number) | undefined;
1275
+ }
1276
+ declare function createMessagingStore(options?: MessagingStoreOptions): MessagingStore;
1233
1277
  /**
1234
1278
  * The optimistic row a composer shows the instant Enter is pressed. It carries
1235
1279
  * the outbox's client key, which is the whole reason the confirmed row can find
@@ -1246,6 +1290,7 @@ declare function optimisticMessage(args: {
1246
1290
  action?: Message["action"];
1247
1291
  attachments?: readonly Message["attachments"][number][];
1248
1292
  references?: readonly Message["references"][number][];
1293
+ softExpiresAt?: string | null;
1249
1294
  now?: () => number;
1250
1295
  }): Message;
1251
1296
 
@@ -1294,6 +1339,8 @@ interface MessagingEngineOptions {
1294
1339
  * the list.
1295
1340
  */
1296
1341
  archiveFilter?: MessagingArchiveFilter | undefined;
1342
+ /** The clock the soft-expiry rule reads. Defaults to `Date.now`. */
1343
+ now?: (() => number) | undefined;
1297
1344
  }
1298
1345
  interface MessagingEngine {
1299
1346
  readonly store: MessagingStore;
@@ -1384,6 +1431,28 @@ declare function participantNames(participants: readonly UserSummary[], excludin
1384
1431
  */
1385
1432
  declare function formatLastSeen(lastSeenMs: number | null, now?: number): string;
1386
1433
 
1434
+ /**
1435
+ * THE LAPSE RULE — the only place in this package that decides whether a
1436
+ * message has soft-expired.
1437
+ *
1438
+ * A message carrying `softExpiresAt` (a meeting invitation is worth reading
1439
+ * until an hour after the meeting starts) is LAPSED once that instant has
1440
+ * passed. Lapsed is DERIVED from the clock every time it is asked, never
1441
+ * stamped on the row: there is no sweeper and nothing to drift. The database's
1442
+ * `get_dm_unread_count` applies the same comparison (`soft_expires_at > now()`
1443
+ * still counts), so the badge the server computes and the bubble the client
1444
+ * draws agree.
1445
+ *
1446
+ * A lapsed message stays readable and its actions keep working. It simply
1447
+ * stops counting as unread — and the reader's watermark is NEVER moved for it,
1448
+ * because moving `last_read_at` would mark every other message read too.
1449
+ */
1450
+
1451
+ /** Parse a soft-expiry instant; `null` when absent or unparseable (never lapses). */
1452
+ declare function softExpiryTime(message: Pick<Message, "softExpiresAt">): number | null;
1453
+ /** True when the message's soft expiry is at or before `now` (epoch ms or Date). */
1454
+ declare function isLapsed(message: Pick<Message, "softExpiresAt">, now?: number | Date): boolean;
1455
+
1387
1456
  /**
1388
1457
  * THE INGRESS BOUNDARY — where a database row becomes a domain object.
1389
1458
  *
@@ -1466,4 +1535,4 @@ declare function summarizeText(content: string, maxLength?: number): string;
1466
1535
  /** Serialize picked references into a fence the platform's other readers accept. */
1467
1536
  declare function composeFence(references: readonly MatrxReference[]): string;
1468
1537
 
1469
- export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, type AiCallArgs, type AiCapability, type AiResult, type Attachment, type ClientMessageId, type Conversation, type ConversationAiContext, type ConversationCursor, type ConversationId, type ConversationReadCutoff, type ConversationReadFailure, type ConversationSummary, type ConversationThread, type ConversationType, DEFAULT_MESSAGING_ARCHIVE_FILTER, type DeliveryState, type DraftMessage, type EngineDiagnostic, type JsonObject, type JsonValue, MESSAGING_EVENTS, MESSAGING_RPC_SCHEMA, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgentIdentity, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingArchiveFilter, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingIdentity, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type MessagingSupabaseClient, type MessagingSupabaseInternal, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, type Participant, type ParticipantRole, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, type RepositoryOptions, SERVER_SESSION_IDENTITY, type SchemaLike, type SessionIdentity, type SessionIdentitySnapshot, type SessionResolver, type SupabaseLike, TABLES, type TextSegment, type UserId, type UserSummary, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, composeFence, conversationContextVariables, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createSessionIdentity, createWebOutboxStorage, effectiveActorKey, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, groupMessages, inboxTopic, invalidResponse, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, splitText, summarizeText, toMessagingArchiveFilter, unreadCutoff, writeNotApplied };
1538
+ export { type ActionChoice, type ActionContext, type ActionHandler, type ActionOutcome, type ActionReceipt, type ActionRegistry, type ActorPresentation, type AiCallArgs, type AiCapability, type AiResult, type Attachment, type ClientMessageId, type Conversation, type ConversationAiContext, type ConversationCursor, type ConversationId, type ConversationReadCutoff, type ConversationReadFailure, type ConversationSummary, type ConversationThread, type ConversationType, DEFAULT_MESSAGING_ARCHIVE_FILTER, type DeliveryState, type DraftMessage, type EngineDiagnostic, type JsonObject, type JsonValue, MESSAGING_EVENTS, MESSAGING_RPC_SCHEMA, MESSAGING_SCHEMA, type MatrxReference, type Message, type MessageAction, type MessageCursor, type MessageGroup, type MessageId, type MessageKind, type MessagingAgentIdentity, type MessagingAgents, type MessagingAi, type MessagingAiOptions, type MessagingArchiveFilter, type MessagingEngine, type MessagingEngineOptions, MessagingError, type MessagingErrorCode, type MessagingIdentity, type MessagingRepository, type MessagingSnapshot, type MessagingStore, type MessagingSupabaseClient, type MessagingSupabaseInternal, type OrganizationId, type Outbox, type OutboxEntry, type OutboxOptions, type OutboxStorage, type Page, type Participant, type ParticipantRole, type PendingSoftExpiries, type PostgrestFilterLike, type PostgrestLikeResponse, type PostgrestTableLike, RPCS, type ReadCache, type ReadCacheOptions, type RepositoryOptions, SERVER_SESSION_IDENTITY, type SchemaLike, type SessionIdentity, type SessionIdentitySnapshot, type SessionResolver, type SupabaseLike, TABLES, type TextSegment, type UserId, type UserSummary, asClientMessageId, asConversationId, asMessageId, asOrganizationId, asUserId, composeFence, conversationContextVariables, conversationTopic, createActionRegistry, createMemoryOutboxStorage, createMessagingAi, createMessagingEngine, createMessagingRepository, createMessagingStore, createOutbox, createReadCache, createSessionIdentity, createWebOutboxStorage, effectiveActorKey, extractReferences, formatConversationTime, formatDateSeparator, formatLastSeen, formatMessageTime, formatTypists, groupMessages, inboxTopic, invalidResponse, isLapsed, isSameDay, messagingClientId, normalizeMessagingError, optimisticMessage, participantNames, projectConversationSummary, projectMessage, projectMessageAction, projectParticipantRole, projectUserSummary, resolveActor, softExpiryTime, splitText, summarizeText, toMessagingArchiveFilter, unreadCutoff, writeNotApplied };
package/dist/index.js CHANGED
@@ -901,7 +901,8 @@ function projectBroadcastMessage(value) {
901
901
  action: projectMessageAction(record["action"]),
902
902
  attachments: projectAttachmentList(record["attachments"]),
903
903
  references: projectReferenceList(record["references"]),
904
- metadata
904
+ metadata,
905
+ softExpiresAt: str(record, "softExpiresAt")
905
906
  };
906
907
  }
907
908
  function projectMessage(row, fallbackOrganizationId) {
@@ -931,7 +932,8 @@ function projectMessage(row, fallbackOrganizationId) {
931
932
  action,
932
933
  attachments: projectAttachments(metadata),
933
934
  references: projectReferences(metadata),
934
- metadata
935
+ metadata,
936
+ softExpiresAt: str(row, "soft_expires_at")
935
937
  };
936
938
  }
937
939
  function projectParticipantRole(value) {
@@ -984,6 +986,18 @@ function projectConversationSummary(row, viewerId, fallbackOrganizationId) {
984
986
  };
985
987
  }
986
988
 
989
+ // src/core/lapse.ts
990
+ function softExpiryTime(message) {
991
+ if (message.softExpiresAt === null) return null;
992
+ const parsed = Date.parse(message.softExpiresAt);
993
+ return Number.isFinite(parsed) ? parsed : null;
994
+ }
995
+ function isLapsed(message, now = Date.now()) {
996
+ const at = softExpiryTime(message);
997
+ if (at === null) return false;
998
+ return at <= (typeof now === "number" ? now : now.getTime());
999
+ }
1000
+
987
1001
  // src/core/types.ts
988
1002
  var asConversationId = (value) => value;
989
1003
  var asMessageId = (value) => value;
@@ -1021,7 +1035,8 @@ function insertOrdered(messages, message) {
1021
1035
  next.splice(index, 0, message);
1022
1036
  return next;
1023
1037
  }
1024
- function createMessagingStore() {
1038
+ function createMessagingStore(options = {}) {
1039
+ const now = options.now ?? Date.now;
1025
1040
  let conversations = [];
1026
1041
  let conversationSummaries = /* @__PURE__ */ new Map();
1027
1042
  let hasMoreConversations = false;
@@ -1258,7 +1273,7 @@ function createMessagingStore() {
1258
1273
  if (existing === void 0) return;
1259
1274
  if (message.deliveryState === "sending" || message.deletedAt !== null) return;
1260
1275
  if (existing.lastMessageAt !== null && message.createdAt < existing.lastMessageAt) return;
1261
- const shouldCount = args.incrementUnread === true;
1276
+ const shouldCount = args.incrementUnread === true && !isLapsed(message, now());
1262
1277
  replaceOneSummary(message.conversationId, (item) => ({
1263
1278
  ...item,
1264
1279
  lastMessageContent: message.content,
@@ -1321,14 +1336,25 @@ function optimisticMessage(args) {
1321
1336
  action: args.action ?? null,
1322
1337
  attachments: args.attachments ?? [],
1323
1338
  references: args.references ?? [],
1324
- metadata: {}
1339
+ metadata: {},
1340
+ softExpiresAt: args.softExpiresAt ?? null
1325
1341
  };
1326
1342
  }
1327
1343
 
1328
1344
  // src/core/engine.ts
1345
+ var SOFT_EXPIRY_GRACE_MS = 2e3;
1346
+ var MAX_TIMER_DELAY_MS = 2147483647;
1329
1347
  function createMessagingEngine(options) {
1330
1348
  const { repository, manager, identity } = options;
1331
- const store = createMessagingStore();
1349
+ const browserNow = options.now ?? Date.now;
1350
+ let serverClockOffsetMs = 0;
1351
+ const now = () => browserNow() + serverClockOffsetMs;
1352
+ function learnServerClock(serverNow) {
1353
+ if (serverNow === null) return;
1354
+ const server = Date.parse(serverNow);
1355
+ if (Number.isFinite(server)) serverClockOffsetMs = server - browserNow();
1356
+ }
1357
+ const store = createMessagingStore({ now });
1332
1358
  const conversationPageSize = options.conversationPageSize ?? 30;
1333
1359
  const messagePageSize = options.messagePageSize ?? 50;
1334
1360
  const openChannels = /* @__PURE__ */ new Map();
@@ -1342,6 +1368,8 @@ function createMessagingEngine(options) {
1342
1368
  let inboxChannel = null;
1343
1369
  let conversationCursor = null;
1344
1370
  let disposed = false;
1371
+ const pendingExpiry = /* @__PURE__ */ new Map();
1372
+ let expiryTimer = null;
1345
1373
  function report(event) {
1346
1374
  options.onDiagnostic?.(event);
1347
1375
  }
@@ -1384,6 +1412,72 @@ function createMessagingEngine(options) {
1384
1412
  }).catch(() => void 0);
1385
1413
  return current;
1386
1414
  }
1415
+ function scheduleExpiryTimer() {
1416
+ if (expiryTimer !== null) {
1417
+ clearTimeout(expiryTimer);
1418
+ expiryTimer = null;
1419
+ }
1420
+ if (disposed || pendingExpiry.size === 0) return;
1421
+ const soonest = Math.min(...pendingExpiry.values());
1422
+ const delay = Math.min(
1423
+ MAX_TIMER_DELAY_MS,
1424
+ Math.max(0, soonest + SOFT_EXPIRY_GRACE_MS - now())
1425
+ );
1426
+ expiryTimer = setTimeout(() => {
1427
+ expiryTimer = null;
1428
+ void fireSoftExpiry();
1429
+ }, delay);
1430
+ }
1431
+ function noteSoftExpiry(id, at) {
1432
+ if (at === null) return;
1433
+ if (at <= now() && (store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) {
1434
+ return;
1435
+ }
1436
+ const held = pendingExpiry.get(id);
1437
+ if (held !== void 0 && held <= at) return;
1438
+ pendingExpiry.set(id, at);
1439
+ scheduleExpiryTimer();
1440
+ }
1441
+ async function refreshPendingExpiries(ids) {
1442
+ const summaries = store.snapshot().conversationSummaries;
1443
+ const counted = ids.filter((id) => (summaries.get(id)?.unreadCount ?? 0) > 0);
1444
+ ids.forEach((id) => pendingExpiry.delete(id));
1445
+ if (counted.length > 0) {
1446
+ const pending = await repository.pendingSoftExpiries(counted);
1447
+ if (disposed) return;
1448
+ learnServerClock(pending.serverNow);
1449
+ pending.byConversation.forEach(
1450
+ (at, id) => noteSoftExpiry(id, softExpiryTime({ softExpiresAt: at }))
1451
+ );
1452
+ }
1453
+ scheduleExpiryTimer();
1454
+ }
1455
+ async function fireSoftExpiry() {
1456
+ if (disposed) return;
1457
+ const at = now();
1458
+ const due = [...pendingExpiry.entries()].filter(([, expiry]) => expiry <= at).map(([id]) => id);
1459
+ due.forEach((id) => pendingExpiry.delete(id));
1460
+ await Promise.all(
1461
+ due.map(async (id) => {
1462
+ if ((store.snapshot().conversationSummaries.get(id)?.unreadCount ?? 0) === 0) return;
1463
+ try {
1464
+ const count = await repository.unreadCount(id);
1465
+ if (!disposed) store.setUnreadCount(id, count);
1466
+ } catch (error) {
1467
+ reportError(error, "softExpiryUnread", { conversationId: id });
1468
+ }
1469
+ })
1470
+ );
1471
+ if (disposed) return;
1472
+ await refreshPendingExpiries(due).catch(
1473
+ (error) => reportError(error, "softExpiryRefresh")
1474
+ );
1475
+ }
1476
+ function watchSoftExpiries(ids) {
1477
+ void refreshPendingExpiries(ids).catch(
1478
+ (error) => reportError(error, "softExpiryRefresh")
1479
+ );
1480
+ }
1387
1481
  const outbox = createOutbox({
1388
1482
  ...options.outboxStorage !== void 0 ? { storage: options.outboxStorage } : {},
1389
1483
  send: (draft, clientMessageId) => repository.insertMessage(draft, clientMessageId),
@@ -1410,7 +1504,8 @@ function createMessagingEngine(options) {
1410
1504
  ...entry.draft.replyToId !== void 0 ? { replyToId: entry.draft.replyToId } : {},
1411
1505
  ...entry.draft.action !== void 0 ? { action: entry.draft.action } : {},
1412
1506
  ...entry.draft.attachments !== void 0 ? { attachments: [...entry.draft.attachments] } : {},
1413
- ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {}
1507
+ ...entry.draft.references !== void 0 ? { references: [...entry.draft.references] } : {},
1508
+ ...entry.draft.softExpiresAt !== void 0 ? { softExpiresAt: entry.draft.softExpiresAt } : {}
1414
1509
  })
1415
1510
  );
1416
1511
  });
@@ -1439,6 +1534,7 @@ function createMessagingEngine(options) {
1439
1534
  if (disposed) return;
1440
1535
  conversationCursor = page.nextCursor;
1441
1536
  store.setConversations(page.items, page.hasMore);
1537
+ watchSoftExpiries([...store.snapshot().conversationSummaries.keys()]);
1442
1538
  }
1443
1539
  async function reloadArchivedCount() {
1444
1540
  if (disposed) return;
@@ -1633,6 +1729,7 @@ function createMessagingEngine(options) {
1633
1729
  store.ingest(message);
1634
1730
  const isMine = message.senderId === identity.userId;
1635
1731
  store.applyLastMessage(message, { incrementUnread: !isMine });
1732
+ if (!isMine) noteSoftExpiry(message.conversationId, softExpiryTime(message));
1636
1733
  if (!isMine && store.snapshot().activeConversationId === message.conversationId) {
1637
1734
  void persistRead(message.conversationId).catch(() => void 0);
1638
1735
  }
@@ -1694,6 +1791,7 @@ function createMessagingEngine(options) {
1694
1791
  });
1695
1792
  conversationCursor = page.nextCursor;
1696
1793
  store.appendConversations(page.items, page.hasMore);
1794
+ watchSoftExpiries(page.items.map((item) => item.conversation.id));
1697
1795
  } catch (error) {
1698
1796
  reportError(error, "loadMoreConversations");
1699
1797
  }
@@ -1713,7 +1811,10 @@ function createMessagingEngine(options) {
1713
1811
  const directSummary = missingSummary ? await repository.getConversationSummary(id) : null;
1714
1812
  if (!openIsLive(id, generation, ownedChannel)) return;
1715
1813
  store.setConversationReadFailure(id, null);
1716
- if (directSummary !== null) store.setConversationSummary(directSummary);
1814
+ if (directSummary !== null) {
1815
+ store.setConversationSummary(directSummary);
1816
+ watchSoftExpiries([id]);
1817
+ }
1717
1818
  await persistRead(
1718
1819
  id,
1719
1820
  () => openIsLive(id, generation, ownedChannel)
@@ -1808,6 +1909,9 @@ function createMessagingEngine(options) {
1808
1909
  },
1809
1910
  dispose() {
1810
1911
  disposed = true;
1912
+ if (expiryTimer !== null) clearTimeout(expiryTimer);
1913
+ expiryTimer = null;
1914
+ pendingExpiry.clear();
1811
1915
  openChannels.forEach((_handle, id) => {
1812
1916
  initialReadGeneration.set(id, (initialReadGeneration.get(id) ?? 0) + 1);
1813
1917
  });
@@ -2069,10 +2173,13 @@ var RPCS = {
2069
2173
  /** Conversation list + participants + last message + unread, keyset paged. */
2070
2174
  conversationsWithDetails: "get_dm_conversations_with_details",
2071
2175
  unreadCount: "get_dm_unread_count",
2176
+ /** The soonest future soft expiry per conversation, plus the server's clock. */
2177
+ pendingSoftExpiries: "get_dm_pending_soft_expiries",
2072
2178
  userInfo: "get_dm_user_info",
2073
2179
  isParticipant: "is_dm_participant"
2074
2180
  };
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";
2181
+ 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,soft_expires_at";
2182
+ var PENDING_SOFT_EXPIRY_CHUNK = 200;
2076
2183
  var PARTICIPANT_COLUMNS = "conversation_id,user_id,role,joined_at,last_read_at,is_muted,is_archived";
2077
2184
  var CONVERSATION_COLUMNS = "id,type,group_name,group_image_url,created_by,organization_id,created_at,updated_at,metadata";
2078
2185
  function optionalOrg(organizationId) {
@@ -2493,6 +2600,9 @@ function createMessagingRepository(options) {
2493
2600
  // confirmed row instead of showing the message twice.
2494
2601
  client_message_id: clientMessageId,
2495
2602
  action_data: draft.action ?? null,
2603
+ // SOFT EXPIRY: written only when the sender sets one. Lapsed is
2604
+ // derived from it live (`isLapsed`), never stamped.
2605
+ ...draft.softExpiresAt !== void 0 ? { soft_expires_at: draft.softExpiresAt } : {},
2496
2606
  metadata: {
2497
2607
  ...draft.metadata ?? {},
2498
2608
  ...draft.attachments !== void 0 && draft.attachments.length > 0 ? { attachments: draft.attachments } : {},
@@ -2545,6 +2655,56 @@ function createMessagingRepository(options) {
2545
2655
  }
2546
2656
  return Math.floor(unread);
2547
2657
  },
2658
+ async unreadCount(conversationId) {
2659
+ const operation = "unreadCount";
2660
+ const me = await actingUser(operation);
2661
+ const unread = await withSessionRetry(
2662
+ operation,
2663
+ () => rpc(
2664
+ RPCS.unreadCount,
2665
+ { p_conversation_id: conversationId, p_user_id: me },
2666
+ operation
2667
+ )
2668
+ );
2669
+ if (typeof unread !== "number" || !Number.isFinite(unread) || unread < 0) {
2670
+ throw invalidResponse(operation, `${RPCS.unreadCount} returned ${JSON.stringify(unread)}`);
2671
+ }
2672
+ return Math.floor(unread);
2673
+ },
2674
+ async pendingSoftExpiries(conversationIds) {
2675
+ const operation = "pendingSoftExpiries";
2676
+ const unique = [...new Set(conversationIds)];
2677
+ const byConversation = /* @__PURE__ */ new Map();
2678
+ let serverNow = null;
2679
+ for (let start = 0; start < unique.length; start += PENDING_SOFT_EXPIRY_CHUNK) {
2680
+ const chunk = unique.slice(start, start + PENDING_SOFT_EXPIRY_CHUNK);
2681
+ const answer = await withSessionRetry(
2682
+ operation,
2683
+ () => rpc(
2684
+ RPCS.pendingSoftExpiries,
2685
+ { p_conversation_ids: chunk },
2686
+ operation
2687
+ )
2688
+ );
2689
+ const now = answer?.["server_now"];
2690
+ const pending = answer?.["pending"];
2691
+ if (typeof now !== "string" || !Array.isArray(pending)) {
2692
+ throw invalidResponse(
2693
+ operation,
2694
+ `${RPCS.pendingSoftExpiries} returned ${JSON.stringify(answer)}`
2695
+ );
2696
+ }
2697
+ serverNow = now;
2698
+ for (const row of pending) {
2699
+ const id = row["conversation_id"];
2700
+ const at = row["soft_expires_at"];
2701
+ if (typeof id === "string" && typeof at === "string") {
2702
+ byConversation.set(id, at);
2703
+ }
2704
+ }
2705
+ }
2706
+ return { byConversation, serverNow };
2707
+ },
2548
2708
  async setConversationFlags(conversationId, flags) {
2549
2709
  const operation = "setConversationFlags";
2550
2710
  const me = await actingUser(operation);
@@ -2657,6 +2817,7 @@ export {
2657
2817
  groupMessages,
2658
2818
  inboxTopic,
2659
2819
  invalidResponse,
2820
+ isLapsed,
2660
2821
  isSameDay,
2661
2822
  messagingClientId,
2662
2823
  normalizeMessagingError,
@@ -2668,6 +2829,7 @@ export {
2668
2829
  projectParticipantRole,
2669
2830
  projectUserSummary,
2670
2831
  resolveActor,
2832
+ softExpiryTime,
2671
2833
  splitText,
2672
2834
  summarizeText,
2673
2835
  toMessagingArchiveFilter,