@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.41

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.
Files changed (50) hide show
  1. package/README.md +41 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +11 -2
  3. package/lib/runtime/batchedReadIndex.js +21 -1
  4. package/lib/runtime/cachedMessage.d.ts +12 -0
  5. package/lib/runtime/cachedMessage.js +18 -0
  6. package/lib/runtime/chatClient.d.ts +1 -0
  7. package/lib/runtime/chatClient.js +1 -1
  8. package/lib/runtime/chatSession.d.ts +78 -8
  9. package/lib/runtime/chatSession.js +504 -39
  10. package/lib/runtime/connectivity.d.ts +4 -0
  11. package/lib/runtime/connectivity.js +8 -0
  12. package/lib/runtime/conversationRead.d.ts +13 -0
  13. package/lib/runtime/conversationRead.js +34 -0
  14. package/lib/runtime/conversationReading.d.ts +28 -0
  15. package/lib/runtime/conversationReading.js +47 -0
  16. package/lib/runtime/createChat.d.ts +4 -5
  17. package/lib/runtime/createChat.js +14 -17
  18. package/lib/runtime/durableSender.d.ts +30 -4
  19. package/lib/runtime/durableSender.js +174 -34
  20. package/lib/runtime/index.d.ts +4 -0
  21. package/lib/runtime/index.js +4 -0
  22. package/lib/runtime/messageClient.d.ts +33 -6
  23. package/lib/runtime/messageClient.js +443 -71
  24. package/lib/runtime/messageControls.d.ts +19 -0
  25. package/lib/runtime/messageControls.js +44 -0
  26. package/lib/runtime/messageLaunch.d.ts +42 -0
  27. package/lib/runtime/messageLaunch.js +127 -0
  28. package/lib/runtime/messageQuotes.d.ts +3 -0
  29. package/lib/runtime/messageQuotes.js +17 -0
  30. package/lib/runtime/messageReaders.d.ts +12 -1
  31. package/lib/runtime/messageReaders.js +67 -3
  32. package/lib/runtime/messageStore.d.ts +47 -2
  33. package/lib/runtime/messageStore.js +233 -10
  34. package/lib/runtime/messageTimeline.d.ts +50 -0
  35. package/lib/runtime/messageTimeline.js +120 -0
  36. package/lib/runtime/readCounts.d.ts +15 -1
  37. package/lib/runtime/readCounts.js +96 -8
  38. package/lib/runtime/readIndex.d.ts +44 -6
  39. package/lib/runtime/readIndex.js +238 -11
  40. package/lib/runtime/readSync.d.ts +1 -0
  41. package/lib/runtime/readSync.js +21 -8
  42. package/lib/runtime/readingClient.d.ts +19 -0
  43. package/lib/runtime/readingClient.js +121 -18
  44. package/lib/runtime/readingContent.d.ts +5 -0
  45. package/lib/runtime/readingContent.js +23 -0
  46. package/lib/runtime/recordCache.d.ts +9 -0
  47. package/lib/runtime/recordCache.js +62 -5
  48. package/lib/runtime/responseCache.d.ts +44 -0
  49. package/lib/runtime/responseCache.js +173 -0
  50. package/package.json +6 -1
@@ -0,0 +1,4 @@
1
+ /** Browser connectivity is advisory: only suppress network errors while the
2
+ * browser explicitly reports offline. Permission and data errors stay visible. */
3
+ export declare function isOffline(): boolean;
4
+ export declare function isOfflineNetworkFailure(error: unknown): boolean;
@@ -0,0 +1,8 @@
1
+ /** Browser connectivity is advisory: only suppress network errors while the
2
+ * browser explicitly reports offline. Permission and data errors stay visible. */
3
+ export function isOffline() {
4
+ return typeof navigator !== "undefined" && navigator.onLine === false;
5
+ }
6
+ export function isOfflineNetworkFailure(error) {
7
+ return isOffline() && (error instanceof TypeError || error?.name === "AbortError");
8
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReadIndexState } from "./readIndex.js";
2
+ /** Optional observations whose index metadata may still be in flight. */
3
+ export type ConversationReadMessage = {
4
+ id: string;
5
+ threadId: string;
6
+ mine?: boolean;
7
+ from?: string;
8
+ recalled?: boolean;
9
+ readPolicy?: "visible" | "explicit";
10
+ kind?: string;
11
+ };
12
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
13
+ export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}`): ReadIndexState;
@@ -0,0 +1,34 @@
1
+ import { recordReadIntent, sourcesInConversation } from "./readIndex.js";
2
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
3
+ export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID()) {
4
+ if (!conversationId)
5
+ throw new Error("INVALID_READING_THREAD");
6
+ const sources = sourcesInConversation(state, conversationId);
7
+ if (new Set(sources.map(source => source.teamId)).size > 1)
8
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
9
+ let next = state;
10
+ const indexedIds = new Set();
11
+ for (const source of sources) {
12
+ const incoming = source.messages.filter(message => {
13
+ indexedIds.add(message.id);
14
+ return !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
15
+ });
16
+ const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > source.readThroughSeq)
17
+ .reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
18
+ if (latest)
19
+ next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
20
+ }
21
+ // Live/history messages can precede their read-index metadata. Persist their
22
+ // observations so leaving the page or reloading cannot resurrect the badge.
23
+ const observed = new Set(next.observedReadIntents?.filter(intent => intent.threadId === conversationId).map(intent => intent.messageId));
24
+ for (const message of messages) {
25
+ if (message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
26
+ continue;
27
+ const readPolicy = message.readPolicy ?? (message.kind === "voice" ? "explicit" : "visible");
28
+ if (message.kind === "voice" || readPolicy === "explicit")
29
+ continue;
30
+ next = recordReadIntent(next, { operationId: operationId(), threadId: conversationId, messageId: message.id, readPolicy });
31
+ observed.add(message.id);
32
+ }
33
+ return next;
34
+ }
@@ -0,0 +1,28 @@
1
+ import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
2
+ export type ReadingCandidate = {
3
+ id: string;
4
+ threadId: string;
5
+ from?: string;
6
+ mine?: boolean;
7
+ recalled?: boolean;
8
+ unread?: boolean;
9
+ attentionEligible?: boolean;
10
+ readPolicy?: "visible" | "explicit";
11
+ };
12
+ export type MessageReadingState = {
13
+ seq?: number;
14
+ read: boolean;
15
+ readPolicy: "visible" | "explicit";
16
+ };
17
+ export type ConversationReadingView = {
18
+ messageReading: Readonly<Record<string, MessageReadingState>>;
19
+ unread: ReturnType<typeof unreadInConversation> | undefined;
20
+ badgeCount: number | undefined;
21
+ };
22
+ /** One projection for indexed authority and observations awaiting indexing. A
23
+ * directory badge is optional compatibility metadata, never proof of coverage. */
24
+ export declare function projectConversationReading(state: ReadIndexState | undefined, conversation: {
25
+ id: string;
26
+ kind: "dm" | "group";
27
+ badgeCount?: number;
28
+ }, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
@@ -0,0 +1,47 @@
1
+ import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
2
+ /** One projection for indexed authority and observations awaiting indexing. A
3
+ * directory badge is optional compatibility metadata, never proof of coverage. */
4
+ export function projectConversationReading(state, conversation, candidates = []) {
5
+ const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
6
+ const hidden = new Map();
7
+ for (const message of unique.values())
8
+ if (message.recalled && message.from)
9
+ hidden.set(message.id, new Set([message.from]));
10
+ const sources = state ? sourcesInConversation(state, conversation.id) : [];
11
+ const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
12
+ const indexed = new Set(), messageReading = Object.create(null);
13
+ for (const source of sources)
14
+ for (const message of source.messages) {
15
+ indexed.add(message.id);
16
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || hidden.get(message.id)?.has(message.from) === true || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= source.readThroughSeq) };
17
+ }
18
+ const observed = new Map();
19
+ for (const intent of state?.observedReadIntents ?? [])
20
+ if (sourceIds.has(intent.threadId)) {
21
+ if (!observed.has(intent.messageId))
22
+ observed.set(intent.messageId, new Set());
23
+ observed.get(intent.messageId).add(intent.readPolicy);
24
+ }
25
+ let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
26
+ for (const message of unique.values()) {
27
+ const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
28
+ const attention = conversation.kind === "dm" || message.attentionEligible === true;
29
+ const policy = message.readPolicy ?? "visible";
30
+ const acknowledged = observed.get(message.id)?.has(policy) === true;
31
+ if (!indexed.has(message.id) && acknowledged)
32
+ messageReading[message.id] = { read: true, readPolicy: policy };
33
+ if (!incoming)
34
+ continue;
35
+ if (acknowledged && attention)
36
+ observedBadges++;
37
+ if (indexed.has(message.id) || acknowledged || message.unread !== true)
38
+ continue;
39
+ pendingMessages++;
40
+ if (message.attentionEligible === true)
41
+ pendingMentions++;
42
+ }
43
+ const authoritative = sources.length && state ? unreadInConversation(state, conversation.id, hidden) : undefined;
44
+ const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
45
+ const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
46
+ return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
47
+ }
@@ -1,16 +1,15 @@
1
1
  import { ChatSession } from "./chatSession.js";
2
- import type { ChatReaderSocialMessage } from "./readerClient.js";
3
2
  import type { ChatClient, ChatClientOptions } from "./chatClient.js";
4
- export type CachedChatMessage = ChatReaderSocialMessage & {
5
- id: string;
6
- timestamp: number;
7
- };
3
+ import { type CachedChatMessage } from "./cachedMessage.js";
4
+ export type { CachedChatMessage } from "./cachedMessage.js";
8
5
  export type CreateChatOptions = {
9
6
  appId: string;
10
7
  accountId: string;
11
8
  profileId: string;
12
9
  teamId: string;
13
10
  token: string;
11
+ /** Limit reconnect reconciliation for a dedicated conversation document. */
12
+ threadId?: string;
14
13
  endpoint?: string;
15
14
  clientInstanceId?: string;
16
15
  fetch?: typeof fetch;
@@ -1,7 +1,8 @@
1
+ import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
1
2
  import { ChatSession } from "./chatSession.js";
2
3
  import { SocialReadClient } from "./socialReadClient.js";
3
- import { chatTeamId } from "./teamScope.js";
4
- import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
4
+ import { isValidChatTeamId } from "./thread.js";
5
+ import { decodeCachedChatMessage } from "./cachedMessage.js";
5
6
  /** Standard browser entry: storage, paging, realtime and reading share one identity. */
6
7
  export function createChat(options) {
7
8
  const { appId, accountId, profileId, teamId, token, onError } = options;
@@ -16,24 +17,20 @@ export function createChat(options) {
16
17
  };
17
18
  return new ChatSession({
18
19
  sharedConnection: options.connection,
20
+ activeThreadId: options.threadId,
19
21
  messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
20
22
  reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
21
23
  connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
22
24
  session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
23
- decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
25
+ readingCandidate: message => {
26
+ if (isReadingControl(message.body, appId))
27
+ return undefined;
28
+ const body = message.body;
29
+ if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
30
+ return undefined;
31
+ return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
32
+ readPolicy: message.readPolicy === "explicit" || message.legacyDisplay?.kind === "voice" || messageRequiresPlayback(body) ? "explicit" : "visible", attentionEligible: messageMentionsProfile(body, profileId) };
33
+ },
34
+ decode: message => decodeCachedChatMessage(message, { appId, profileId, teamId }), onError,
24
35
  });
25
36
  }
26
- function decodeIncoming(message, scope) {
27
- if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
28
- return;
29
- const body = message.body;
30
- if (body.kind !== "message" || body.content?.appId !== scope.appId || chatTeamId(body) !== scope.teamId)
31
- return;
32
- if (body.threadKind !== "dm" && body.threadKind !== "group")
33
- return;
34
- if (body.threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
35
- return;
36
- const threadId = body.threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
37
- return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind: body.threadKind, messageId: message.hash, id: message.hash,
38
- timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
39
- }
@@ -8,13 +8,30 @@ export type OutgoingMessage = {
8
8
  message: ChatRuntimeMessage;
9
9
  status: "sending" | "failed" | "sent";
10
10
  confirmed?: boolean;
11
+ accessVersion?: number;
12
+ };
13
+ export type SenderScopeAccess = {
14
+ version: number;
15
+ allowed: boolean;
16
+ };
17
+ export type DurableSenderOptions = {
18
+ indexedDB?: IDBFactory;
19
+ name?: string;
20
+ access?: () => Promise<SenderScopeAccess>;
11
21
  };
12
22
  /** Persists the exact wire message before sending; retries preserve its hash. */
13
23
  export declare class DurableChatSender {
14
24
  private client;
15
25
  private onError;
26
+ private options;
16
27
  private db;
17
28
  private outbox;
29
+ private epochs;
30
+ private version;
31
+ private withdrawn;
32
+ private revoking?;
33
+ private channel?;
34
+ private accepted;
18
35
  private scope;
19
36
  private identity;
20
37
  private values;
@@ -22,12 +39,21 @@ export declare class DurableChatSender {
22
39
  private listeners;
23
40
  private running;
24
41
  private closed;
42
+ private closing;
43
+ private empty;
25
44
  private offConnection;
26
45
  private ready;
27
- constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: {
28
- indexedDB?: IDBFactory;
29
- name?: string;
30
- });
46
+ constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: DurableSenderOptions);
47
+ /** Read only the selected durable rows, without restoring or delivering an outbox. */
48
+ static readSelected(scope: MessageScope, threadId: string, ids: readonly string[], version: number, options?: Pick<DurableSenderOptions, "indexedDB" | "name">): Promise<OutgoingMessage[]>;
49
+ private readAccess;
50
+ private restore;
51
+ private checkAccess;
52
+ private withEpoch;
53
+ private deleteRows;
54
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
55
+ withdraw(): void;
56
+ revoke(targetVersion?: number): Promise<void>;
31
57
  initialize(): Promise<void>;
32
58
  subscribe(listener: () => void): () => void;
33
59
  snapshot(threadId?: string): readonly OutgoingMessage[];
@@ -5,8 +5,15 @@ import { messageScopeKey } from "./messageStore.js";
5
5
  export class DurableChatSender {
6
6
  client;
7
7
  onError;
8
+ options;
8
9
  db;
9
10
  outbox;
11
+ epochs;
12
+ version = 0;
13
+ withdrawn = false;
14
+ revoking;
15
+ channel;
16
+ accepted = new Set();
10
17
  scope;
11
18
  identity;
12
19
  values = new Map();
@@ -14,30 +21,148 @@ export class DurableChatSender {
14
21
  listeners = new Set();
15
22
  running = new Map();
16
23
  closed = false;
24
+ closing = false;
25
+ empty = [];
17
26
  offConnection;
18
27
  ready;
19
28
  constructor(client, scope, onError, options = {}) {
20
29
  this.client = client;
21
30
  this.onError = onError;
31
+ this.options = options;
22
32
  this.scope = messageScopeKey(scope);
23
33
  this.identity = { ...scope };
24
34
  this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
35
  this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
36
+ this.db.version(2).stores({ scopeEpochs: "scope" });
26
37
  this.outbox = this.db.table("outbox");
27
- this.ready = this.outbox.where("scope").equals(this.scope).toArray().then(records => {
28
- if (this.closed)
29
- return;
30
- records.filter(record => !record.confirmed).forEach(record => this.values.set(record.hash, record));
31
- this.emit();
32
- });
38
+ this.epochs = this.db.table("scopeEpochs");
39
+ this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
40
+ this.ready = this.restore();
41
+ void this.ready.catch(() => { });
42
+ if (typeof BroadcastChannel !== "undefined") {
43
+ this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
44
+ this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
45
+ this.withdraw(); };
46
+ }
33
47
  this.offConnection = client.watchConnection(state => {
34
48
  if (state === "connected")
35
49
  void this.resume().catch(onError);
36
50
  });
37
51
  }
52
+ /** Read only the selected durable rows, without restoring or delivering an outbox. */
53
+ static async readSelected(scope, threadId, ids, version, options = {}) {
54
+ if (ids.length > 100)
55
+ throw new Error("INVALID_OUTBOX_SELECTION");
56
+ const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
57
+ db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
58
+ db.version(2).stores({ scopeEpochs: "scope" });
59
+ try {
60
+ return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
61
+ if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
62
+ return [];
63
+ const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
64
+ return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
65
+ });
66
+ }
67
+ finally {
68
+ db.close();
69
+ }
70
+ }
71
+ async readAccess() {
72
+ const access = await this.options.access?.();
73
+ if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
74
+ throw new Error("INVALID_OUTBOX_ACCESS");
75
+ return access;
76
+ }
77
+ async restore() {
78
+ const access = await this.readAccess();
79
+ const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
80
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
81
+ const version = access?.version ?? current;
82
+ if (version < current)
83
+ throw new Error("OUTBOX_SCOPE_CHANGED");
84
+ if (version > current) {
85
+ await this.epochs.put({ scope: this.scope, version });
86
+ await this.deleteRows();
87
+ }
88
+ return { version, advanced: version > current, records: access?.allowed === false ? [] : await this.outbox.where("scope").equals(this.scope).toArray() };
89
+ });
90
+ this.version = loaded.version;
91
+ if (loaded.advanced)
92
+ this.channel?.postMessage({ version: this.version });
93
+ if (this.closed || this.withdrawn)
94
+ return;
95
+ const current = await this.readAccess();
96
+ if (current && (!current.allowed || current.version !== this.version))
97
+ return;
98
+ loaded.records.filter(record => !record.confirmed && (record.accessVersion ?? 0) === this.version).forEach(record => this.values.set(record.hash, record));
99
+ this.emit();
100
+ }
101
+ async checkAccess() {
102
+ if (this.closed)
103
+ throw new Error("CHAT_SENDER_CLOSED");
104
+ if (this.withdrawn)
105
+ throw new Error("OUTBOX_ACCESS_REVOKED");
106
+ const access = await this.readAccess();
107
+ if (access && access.version !== this.version) {
108
+ this.withdraw();
109
+ throw new Error("OUTBOX_SCOPE_CHANGED");
110
+ }
111
+ if (access && !access.allowed)
112
+ throw new Error("OUTBOX_ACCESS_REVOKED");
113
+ }
114
+ async withEpoch(mode, operation) {
115
+ await this.checkAccess();
116
+ try {
117
+ return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
118
+ if (this.closed || this.withdrawn)
119
+ throw new Error("OUTBOX_ACCESS_REVOKED");
120
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
121
+ throw new Error("OUTBOX_SCOPE_CHANGED");
122
+ return operation();
123
+ });
124
+ }
125
+ catch (error) {
126
+ if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
127
+ this.withdraw();
128
+ throw error;
129
+ }
130
+ }
131
+ deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
132
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
133
+ withdraw() {
134
+ this.withdrawn = true;
135
+ this.values.clear();
136
+ this.emit();
137
+ }
138
+ revoke(targetVersion) {
139
+ if (this.closed || this.closing)
140
+ return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
141
+ if (this.revoking)
142
+ return this.revoking;
143
+ this.withdraw();
144
+ return this.revoking = (async () => {
145
+ await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
146
+ throw error; });
147
+ const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
148
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
149
+ const target = targetVersion ?? this.version + 1;
150
+ if (!Number.isSafeInteger(target) || target < 0)
151
+ throw new Error("INVALID_OUTBOX_EPOCH");
152
+ if (current >= target)
153
+ return current;
154
+ await this.epochs.put({ scope: this.scope, version: target });
155
+ await this.deleteRows();
156
+ return target;
157
+ });
158
+ this.channel?.postMessage({ version });
159
+ })();
160
+ }
38
161
  initialize() { return this.ready; }
39
162
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
40
163
  snapshot(threadId) {
164
+ if (this.closed || this.closing || this.withdrawn)
165
+ return this.empty;
41
166
  let snapshot = this.snapshots.get(threadId);
42
167
  if (!snapshot) {
43
168
  snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
@@ -50,7 +175,8 @@ export class DurableChatSender {
50
175
  /** Resolves after the exact wire message is durable, independently of network delivery. */
51
176
  async enqueue(threadId, input) {
52
177
  const message = await this.persistOutgoing(threadId, input);
53
- void this.retry(message.hash).catch(error => this.onError(error));
178
+ if (!this.closed && !this.closing && !this.withdrawn)
179
+ void this.retry(message.hash).catch(error => this.onError(error));
54
180
  return message;
55
181
  }
56
182
  /** Compatibility API: resolves only after transport delivery. */
@@ -58,23 +184,27 @@ export class DurableChatSender {
58
184
  const message = await this.persistOutgoing(threadId, input);
59
185
  return this.retry(message.hash);
60
186
  }
61
- async persistOutgoing(threadId, input) {
62
- await this.ready;
63
- if (this.closed)
64
- throw new Error("CHAT_SENDER_CLOSED");
65
- const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
66
- if (message.from !== this.identity.profileId)
67
- throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
68
- const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
69
- await this.outbox.put(record);
70
- if (this.closed)
71
- throw new Error("CHAT_SENDER_CLOSED");
72
- this.values.set(record.hash, record);
73
- this.emit();
74
- return message;
187
+ persistOutgoing(threadId, input) {
188
+ if (this.closed || this.closing || this.withdrawn)
189
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
190
+ const pending = (async () => {
191
+ await this.ready;
192
+ await this.checkAccess();
193
+ const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
194
+ if (message.from !== this.identity.profileId)
195
+ throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
196
+ const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
197
+ await this.withEpoch("rw", async () => { await this.outbox.put(record); });
198
+ await this.checkAccess();
199
+ this.values.set(record.hash, record);
200
+ this.emit();
201
+ return message;
202
+ })().finally(() => this.accepted.delete(pending));
203
+ this.accepted.add(pending);
204
+ return pending;
75
205
  }
76
206
  retry(hash) {
77
- if (this.closed)
207
+ if (this.closed || this.closing || this.withdrawn)
78
208
  return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
79
209
  const running = this.running.get(hash);
80
210
  if (running)
@@ -85,8 +215,8 @@ export class DurableChatSender {
85
215
  }
86
216
  async transmit(hash) {
87
217
  await this.ready;
88
- const record = await this.outbox.get([this.scope, hash]);
89
- if (!record || this.closed)
218
+ const record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
219
+ if (!record || this.closed || this.withdrawn)
90
220
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
91
221
  if (record.status === "sent" || record.confirmed)
92
222
  return record.message;
@@ -96,6 +226,7 @@ export class DurableChatSender {
96
226
  if (!latest || latest.confirmed || latest.status === "sent")
97
227
  return latest?.message ?? record.message;
98
228
  }
229
+ await this.checkAccess();
99
230
  if (this.values.get(hash)?.status !== "sending") {
100
231
  this.values.set(hash, sending);
101
232
  this.emit();
@@ -104,7 +235,7 @@ export class DurableChatSender {
104
235
  const message = await this.client.sendPrepared(record.message);
105
236
  const sent = { ...record, message, status: "sent" };
106
237
  const committed = await this.save(sent);
107
- if (!this.closed) {
238
+ if (!this.closed && !this.withdrawn) {
108
239
  if (committed?.confirmed || !committed)
109
240
  this.values.delete(hash);
110
241
  else
@@ -114,7 +245,7 @@ export class DurableChatSender {
114
245
  return message;
115
246
  }
116
247
  catch (error) {
117
- if (!this.closed) {
248
+ if (!this.closed && !this.withdrawn) {
118
249
  const failed = { ...record, status: "failed" };
119
250
  const committed = await this.save(failed);
120
251
  if (committed?.confirmed || committed?.status === "sent") {
@@ -132,7 +263,7 @@ export class DurableChatSender {
132
263
  }
133
264
  }
134
265
  async save(record) {
135
- return this.db.transaction("rw", this.outbox, async () => {
266
+ return this.withEpoch("rw", async () => {
136
267
  const previous = await this.outbox.get([this.scope, record.hash]);
137
268
  if (!previous || previous.confirmed || previous.status === "sent")
138
269
  return previous;
@@ -147,7 +278,7 @@ export class DurableChatSender {
147
278
  if (!hashes.length)
148
279
  return;
149
280
  await this.ready;
150
- await this.db.transaction("rw", this.outbox, async () => {
281
+ await this.withEpoch("rw", async () => {
151
282
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
152
283
  const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
153
284
  if (confirmed.length)
@@ -161,12 +292,12 @@ export class DurableChatSender {
161
292
  }
162
293
  async resume() {
163
294
  await this.ready;
164
- if (this.closed)
295
+ if (this.closed || this.closing || this.withdrawn)
165
296
  return;
166
297
  // Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
167
298
  for (const record of this.values.values())
168
299
  if (record.status === "sending") {
169
- if (this.closed)
300
+ if (this.closed || this.withdrawn)
170
301
  return;
171
302
  try {
172
303
  await this.retry(record.hash);
@@ -177,11 +308,20 @@ export class DurableChatSender {
177
308
  }
178
309
  }
179
310
  async close() {
180
- this.closed = true;
311
+ this.closing = true;
181
312
  this.offConnection();
182
313
  this.listeners.clear();
183
- await this.ready;
184
- await Promise.allSettled([...this.running.values()]);
185
- this.db.close();
314
+ this.channel?.close();
315
+ this.channel = undefined;
316
+ try {
317
+ await this.ready;
318
+ await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
319
+ }
320
+ finally {
321
+ this.closed = true;
322
+ this.values.clear();
323
+ this.snapshots.clear();
324
+ this.db.close();
325
+ }
186
326
  }
187
327
  }
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
+ export * from "./conversationRead.js";
33
+ export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
34
+ export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
35
+ export * from "./messageLaunch.js";
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
+ export * from "./conversationRead.js";
33
+ export { projectConversationReading } from "./conversationReading.js";
34
+ export { projectMessageTimeline } from "./messageTimeline.js";
35
+ export * from "./messageLaunch.js";