@pmate/chat-sdk 0.12.0-next.61 → 0.12.0-next.63

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/README.md CHANGED
@@ -128,133 +128,116 @@ The handler leaves text-only pastes and events already handled by the host untou
128
128
 
129
129
  Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
130
130
 
131
- ## Browser message and reading data
131
+ ## Browser messages
132
132
 
133
133
  ### Background
134
134
 
135
- Message hosts previously coordinated remote history, offline snapshots, optimistic
136
- sends and reading synchronization independently. The runtime now provides local
137
- IndexedDB storage and fixed-window scheduling primitives so those operations can
138
- share bounded I/O, durable confirmation and scoped subscriptions.
135
+ App code should render messages and report actual user actions. IndexedDB,
136
+ trusted recent windows, synchronization, recipients, retries and read rules belong
137
+ to the SDK. The default API presents one semantic message list, including delivery,
138
+ recalls and reactions. It never presents a partial migration as a ready window.
139
139
 
140
- The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
140
+ ```ts
141
+ import {createChat, ChatError} from "@pmate/chat-sdk/chat"
142
+ const chat = createChat({appId, accountId, profileId, teamId, token, onError})
143
+ await chat.start() // also handles an optional shared calls/presence connection
144
+
145
+ // Existing conversations already have a threadId. For a new conversation:
146
+ const threadId = await chat.getConversation({peerId}) // or {groupId}
147
+ try {
148
+ await chat.getMessages(threadId)
149
+ openThread(threadId) // cold failure leaves the usable conversation list in place
150
+ } catch (error) {
151
+ if (error instanceof ChatError && error.retryable) showRetry()
152
+ }
153
+ const off = chat.subscribe(threadId, state => render(state.messages))
154
+ await chat.sendMessage(threadId, {text: "Hello"})
155
+ await chat.getMessages(threadId, {earlier: true})
156
+ off()
157
+ await chat.close()
158
+ ```
159
+
160
+ `getMessages` returns the same stable snapshot as `getSnapshot` and `subscribe`.
161
+ It resolves with a trusted recent window, or rejects with `ChatError`; Apps do not
162
+ check coverage or select local/remote sources. `{earlier:true}` extends the SDK's
163
+ current window without timestamps, limits or cursors; `{all:true}` traverses
164
+ accessible history and rejects if the scan is incomplete. A failed history scan
165
+ can leave the already trusted recent window usable. Cache hits do not repeat
166
+ history requests. Reconnection and browser wake reconcile newer changes.
167
+
168
+ A thread snapshot contains `threadId`, one renderable `messages` list, `ready`,
169
+ `hasEarlier`, `unread`, `group`, `readCounts`, optional `conversation` target identity,
170
+ and optional `error`. Target identity survives recalls without exposing redacted
171
+ message bodies. `ready` helps
172
+ route entry and startup before the first read; it is not a cache-source indicator.
173
+ `getSnapshot()` and `subscribe(listener)` provide conversation summaries, aggregate
174
+ unread counts and connection state. Unknown unread counts remain `undefined`.
175
+ Subscription always emits the current snapshot first, including with
176
+ `{fields:["messages","unread"]}`, then meaningful changes only. Object references
177
+ remain stable for unchanged results. Framework adapters can use
178
+ `{emitCurrent:false}`; React can import `useChatSnapshot(chat, threadId)` from
179
+ `@pmate/chat-sdk`. Establishing a subscription never starts a read or marks it read.
180
+
181
+ Sending accepts text/mentions/quotes, images, voice, recalls, reactions and
182
+ invitations through the same typed content union. The SDK resolves the target
183
+ and group roster, expands @all and assigns reaction revisions. The default
184
+ Promise resolves after durable acceptance and returns `{id,timestamp}`; outgoing
185
+ status is part of the same message list. `{waitFor:"delivered"}` waits for the
186
+ server acknowledgement. `retryMessage(id)` retries a failed exact wire.
141
187
 
142
188
  ```ts
143
- const chat = createChat({ appId, accountId, profileId, teamId, token, onError })
144
- await chat.start()
145
- const page = await chat.getMessages(threadId)
146
- // SDK owns the page size and history boundary; hosts need not construct a cursor.
147
- const earlier = await chat.getMessages(threadId, { earlier: true })
148
- // After an authoritative mutation (e.g. history assignment), await reconciliation.
149
- // Concurrent refreshes share transport; errors do not masquerade as fresh cache.
150
- const reconciled = await chat.getMessages(threadId, { refresh: true })
151
- // Explicit full-history queries reuse cached pages and internally rebase expired cursors.
152
- const history = await chat.getMessages(threadId, { all: true })
153
- const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
154
- const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
189
+ await chat.sendMessage(threadId, {voice:{blob,durationMs}})
190
+ await chat.sendMessage(threadId, {reaction:{messageId,emojiId,active:true}})
191
+ await chat.sendMessage(threadId, {recall:{messageId}}, {waitFor:"delivered"})
155
192
  await chat.observeMessages(threadId, visibleIds, "visible")
156
- // Report only the actual audio `playing` event.
157
- await chat.observeMessages(threadId, [voiceId], "played")
158
- const conversation = { id: threadId, kind: "dm" as const, targetId: peerId, teamId }
159
- await chat.sendMessage(conversation, { text: "Hello" })
160
- await chat.sendMessage(conversation, { image: { url: uploadedUrl, name: "photo.png" } })
161
- await chat.sendMessage(conversation, { voice: { blob: recording, durationMs: 1200 } })
193
+ await chat.observeMessages(threadId, [voiceId], "played") // actual playing event
194
+ await chat.markRead(threadId) // ordinary text only; unheard voice stays unread
162
195
  const readers = await chat.getReaders(threadId, messageId)
163
- off(); offSummary()
164
- // Ordinary navigation/shutdown preserves offline data.
165
- await chat.close()
166
196
  ```
167
197
 
198
+ Voice media is acquired on demand through `getVoiceAttachment(threadId,messageId)`.
199
+ Use its `url` and call `release()` when playback/transcription stops. Acquisition,
200
+ clicking play, a fulfilled play Promise and transcription do not count as playing.
201
+ The SDK persists observations locally, batches writes and synchronization, and
202
+ preserves offline intents. It does not promise all media binaries are offline.
203
+
168
204
  ```ts
169
- // When team access is withdrawn, use this instead of ordinary close(). It hides
170
- // snapshots immediately, retires durable caches/outbox and closes this session.
171
- // Other teams' reading operations remain intact. Create a new session to recover.
172
- await chat.revokeAccess()
205
+ const group = await chat.getGroup(threadId)
206
+ const created = await chat.createGroup({title, members}) // optional groupId
207
+ await chat.updateGroup(created.threadId, {title: "New title"})
173
208
  ```
174
209
 
175
- Conversation descriptors identify the target and optional group `memberIds`; the SDK
176
- checks the descriptor against its identity and team before acceptance. It builds
177
- text, image, quote and voice envelopes and expands group `@all`/`@所有人` to concrete
178
- members. Content callers do not provide `appId`, protocol `body` or `recipients`.
179
- The existing `sendMessage(threadId, { to, body, recipients })` form remains available
180
- for protocol integrations. Blob voice uploads stay in the durable sender; offline
181
- uploads wait for retry instead of starting HTTP.
182
-
183
- The facade owns all clients below. Message reads transparently restore IndexedDB
184
- windows and fetch missing ranges through the authenticated Hub. Its normalized
185
- messages retain the protocol body; product rendering remains with the host.
186
- `sendMessage` resolves after local persistence, while `outgoing` in the thread
187
- snapshot reports transport progress and failure. Retry uses the original message
188
- hash. The subscribed recent window remains 100 records regardless of individual
189
- query limits; history requests return their own pages and do not replace it.
190
- Global snapshots contain thread summaries and totals, not complete message arrays.
191
- Message pages report `complete`; unavailable older pages return cached records with
192
- `complete: false`. Authorization failures throw and never fall back to denied
193
- records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
194
- it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
195
- pre-reset payload.
196
- Voice visibility never acknowledges playback, including observations that arrive
197
- before metadata. Create a new instance and close the previous owner when the
198
- account or active Team changes.
199
-
200
- This facade is still under integration. Reader details currently require network
201
- access, global summaries do not yet replace the host's contact/thread metadata,
202
- and Merry's message-list/send migration and production validation are unfinished.
203
- The lower-level primitives remain available for custom transports:
204
-
205
- `MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
206
- subscribes per conversation, and accepts incremental event batches. Its transport
207
- supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
208
- uncached older history independently of the latest synchronization checkpoint.
209
- A projection reset removes obsolete rows before installing a new window.
210
- `SocialReadClient.messagePage` implements the authenticated Hub page protocol;
211
- request cancellation is supported through its optional second signal argument.
212
-
213
- `ReadingClient` owns persisted visible/explicit read intents, windowed network
214
- synchronization and retry. A host reports actual visibility with `recordVisible`
215
- or explicit actions with `recordExplicit`; loading or subscribing never marks
216
- messages read. `getSnapshot` plus `subscribe` provide stable reading snapshots.
217
- `BatchedReadIndex` resolves queued writes only after their transaction commits.
218
-
219
- `IndexedDbRecordCache` is a version-aware control-event index. Call and await
220
- `open` before using it. An optional legacy reader runs during initialization only;
221
- legacy cleanup happens after durable migration. Normal reads use memory and writes
222
- are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
223
- the host, and pending operations are not reported as saved before their promise
224
- resolves.
225
- Control-event batches are bounded by both 128 records and 1 MiB per transaction;
226
- the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
227
- `transactionBytes` and `queueBytes` can lower these limits. Oversized operations
228
- fail before changing the memory snapshot. Repeating an in-flight write waits for
229
- that physical write, including its failure, rather than acknowledging it early.
230
-
231
- `DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
232
- before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
233
- and payload. Failed sends require an explicit retry; interrupted sends can resume
234
- when the connection returns. Call `confirm(hashes)` only after authoritative
235
- messages have been durably merged. Confirmation tombstones prevent another tab's
236
- late transport failure from recreating an outgoing message.
237
-
238
- Custom primitive integrations must initialize them with the same authenticated
239
- scope, dispose them on identity changes, and wire their transport to an authorized
240
- server. Standard browser integrations should use the facade instead. The Merry migration and production validation
241
- are tracked in the unified message layer proposal.
242
-
243
- When the browser reports offline, scheduled message restoration and reading
244
- refreshes wait for the normal reconnect/wake path. Cached reads remain available;
245
- unknown receipt counts stay unknown and an uncached reader detail page rejects
246
- without attempting transport. A network failure arriving after the browser went
247
- offline does not turn background synchronization into an operation error.
248
- Authorization failures still revoke the corresponding cached data.
249
-
250
- Reading entry points check the message scope even before `initialize()` finishes.
251
- Concurrent observations share the permission lookup and batch their local writes.
252
- Visibility/playback operations reject another known team's conversation; a
253
- permission change in another team does not discard this team's observations.
254
- Ordinary shutdown waits for accepted local reading writes, while access withdrawal
255
- rejects late results. Retired asynchronous reading entries reject through promises;
256
- refresh/subscription teardown remains safe.
257
-
210
+ Group reads restore IndexedDB metadata before background reconciliation. Mutations
211
+ wait for confirmed delivery and a causal server version. SDK-owned requests share
212
+ bounded queues and time windows; Apps do not poll. `watchReaders(threadId,ids,receiver)`
213
+ selects receipt counts for the current viewport; clear with `watchReaders(undefined,[])`.
214
+
215
+ `ChatError` exposes `code`, `retryable`, optional HTTP `status`, and the original
216
+ `cause`. Recovery categories are `unavailable`, `access-denied`,
217
+ `storage-unavailable`, `invalid-input`, `conflict`, `closed`, and `busy`.
218
+ Permission failures never turn into trusted offline data. Dispose and recreate
219
+ the SDK when account, profile, team or token changes.
220
+
221
+ ### Advanced integration and migration
222
+
223
+ `chat.advanced` exposes the underlying `ChatSession` for one-time legacy imports,
224
+ custom transports and diagnostics. Its `messages` are raw records, `timeline` is
225
+ the semantic projection, and `complete/resetVersion` describe coverage internals.
226
+ The older `createChat` export from `@pmate/chat-sdk/runtime` retains this low-level
227
+ contract for compatibility. New Apps should import from `@pmate/chat-sdk/chat`
228
+ or the package root. Do not mix the two snapshot contracts.
229
+
230
+ For migration, replace `subscribe(id, () => render(getSnapshot(id).timeline))`
231
+ with `subscribe(id, state => render(state.messages))`; replace conversation
232
+ objects and App-supplied recipients with `threadId`; await `getMessages` before
233
+ opening a cold thread instead of checking `page.complete`. Replace `initialize()`
234
+ with `start()`; a shared connection is initialized without another connect call.
235
+ Raw wire sends are available only through `advanced.sendMessage`. Group changes
236
+ use `createGroup/updateGroup`; UI never assigns reaction versions or recipients.
237
+
238
+ The advanced storage and transport classes remain exported from `/runtime`.
239
+ They must share one authenticated scope and must not bypass scope authority.
240
+ Normal UI code should not maintain another message cache or outbox.
258
241
 
259
242
  ### Reading storage recovery
260
243
 
@@ -282,29 +265,10 @@ Pending voice snapshots contain `voiceAttachment` metadata, without loading audi
282
265
  The outbox format upgrade renames the old canonical store without copying binary data in the schema transaction, then atomically separates inline payloads in bounded, resumable transactions. An already-open older application must reload; unsupported senders reject the new format. Original wire/hash, failed status and retry semantics are retained.
283
266
 
284
267
 
285
- Control messages use the same send API:
286
-
287
- ```ts
288
- await chat.sendMessage(conversation, {recall: {messageId}})
289
- await chat.sendMessage(conversation, {reaction: {messageId, emojiId, active: true}})
290
- await chat.sendMessage(conversation, {invitation: {message: "Hello"}})
291
- await chat.sendMessage(group, {groupCreate: {title, members}})
292
- await chat.sendMessage(group, {groupUpdate: {expectedRevision, addMembers}})
293
- ```
294
-
295
- These calls retain durable-acceptance semantics. Recall and reaction validate the target against the current scoped timeline or canonical local cache; the App does not supply protocol bodies or reaction versions. Reaction counters and exact wires commit in one IndexedDB transaction, including across documents. Validation ordering does not wait for each preceding commit, so a same-key burst still reaches the bounded acceptance batches. Pass `{waitFor:"delivered"}` as the third argument when an action requires server confirmation; the default still resolves after durable acceptance.
296
-
297
-
298
- Group metadata uses the same identity, cache and subscriptions:
299
-
300
- ```ts
301
- const group = await chat.getGroup(conversation)
302
- const off = chat.subscribe(conversation.id, () => {
303
- renderGroup(chat.getSnapshot(conversation.id).group)
304
- }, ["group"])
305
- await chat.createGroup(conversation, {title, members})
306
- await chat.updateGroup(conversation, {title: "New title"})
307
- off()
308
- ```
309
-
310
- Reads restore trusted IndexedDB metadata before background revalidation. Creation and updates wait for confirmed delivery and a causal server version, including newer concurrent revisions; Apps do not poll for an exact message hash. Group events and browser recovery share a 200ms window. At most four metadata requests run concurrently, with 128 queued requests, a five-second request timeout, and at most five read-after-write attempts. Per-scope persistent metadata is capped at 64 groups / 1MiB, with 128KiB records, 32-record write batches, conditional cross-document writes and bounded pruning. Cached metadata does not bypass scope withdrawal or server membership checks. Binary media remains on demand.
268
+ SDK group metadata uses a 200ms merge window, four concurrent requests, a bounded
269
+ 128-request queue, five-second timeout and at most five read-after-write attempts.
270
+ The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
271
+ 32-record write batches, conditional writes and bounded pruning. Conversation
272
+ identities are stored separately without rosters; repeated registrations share
273
+ one write, concurrent lookups use bulk reads, and a concurrent memory-hit burst
274
+ shares one fresh authority read without retaining authorization across bursts. Scope withdrawal retires both.
package/lib/chat.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { type CreateChatOptions } from "./runtime/createChat.js";
2
+ import type { ChatSession, ChatSummarySnapshot as SessionSummary, ChatThreadSnapshot as SessionThread } from "./runtime/chatSession.js";
3
+ import type { CachedChatMessage } from "./runtime/cachedMessage.js";
4
+ import type { TimelineItem } from "./runtime/messageTimeline.js";
5
+ import type { ConversationTarget } from "./runtime/conversationDirectory.js";
6
+ import type { OutgoingContent as RuntimeContent } from "./runtime/outgoingContent.js";
7
+ import { ChatError } from "./runtime/chatError.js";
8
+ export { ChatError } from "./runtime/chatError.js";
9
+ export type { ChatErrorCode } from "./runtime/chatError.js";
10
+ export type { CreateChatOptions, ConversationTarget };
11
+ export type OutgoingContent = Exclude<RuntimeContent, {
12
+ groupCreate: unknown;
13
+ } | {
14
+ groupUpdate: unknown;
15
+ }>;
16
+ export type { ChatGroup } from "./runtime/groupClient.js";
17
+ export type ChatMessage = Readonly<TimelineItem<CachedChatMessage>>;
18
+ export type ConversationInfo = Readonly<{
19
+ kind: "dm" | "group";
20
+ targetId: string;
21
+ }>;
22
+ export type ChatThreadSnapshot = Readonly<{
23
+ threadId: string;
24
+ messages: readonly ChatMessage[];
25
+ ready: boolean;
26
+ hasEarlier: boolean;
27
+ conversation?: ConversationInfo;
28
+ unread: SessionThread<CachedChatMessage>["unread"];
29
+ group: SessionThread<CachedChatMessage>["group"];
30
+ readCounts: SessionThread<CachedChatMessage>["readCounts"];
31
+ error?: ChatError;
32
+ }>;
33
+ export type ChatSnapshot = Readonly<{
34
+ threads: readonly {
35
+ threadId: string;
36
+ lastMessage?: ChatMessage;
37
+ group: SessionThread<CachedChatMessage>["group"];
38
+ unread: ChatThreadSnapshot["unread"];
39
+ outgoingCount: number;
40
+ }[];
41
+ unreadMessageCount: number | undefined;
42
+ badgeCount: number | undefined;
43
+ connection: SessionSummary<CachedChatMessage>["connection"];
44
+ }>;
45
+ export type MessageQuery = {
46
+ earlier?: boolean;
47
+ all?: boolean;
48
+ refresh?: boolean;
49
+ };
50
+ export type SendOptions = {
51
+ waitFor?: "accepted" | "delivered";
52
+ };
53
+ export type SentMessage = Readonly<{
54
+ id: string;
55
+ timestamp: number;
56
+ }>;
57
+ type SubscribeOptions<T> = {
58
+ fields?: readonly (keyof T)[];
59
+ emitCurrent?: boolean;
60
+ };
61
+ /** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
62
+ export declare class Chat {
63
+ readonly advanced: ChatSession<CachedChatMessage>;
64
+ private options;
65
+ private views;
66
+ private summary?;
67
+ private targets;
68
+ private errors;
69
+ private listeners;
70
+ private closed;
71
+ constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
72
+ private operation;
73
+ private threadOperation;
74
+ start(): Promise<void>;
75
+ getConversation(target: ConversationTarget): Promise<string>;
76
+ getSnapshot(): ChatSnapshot;
77
+ getSnapshot(threadId: string): ChatThreadSnapshot;
78
+ getMessages(threadId: string, query?: MessageQuery): Promise<ChatThreadSnapshot>;
79
+ sendMessage(threadId: string, content: OutgoingContent, options?: SendOptions): Promise<SentMessage>;
80
+ retryMessage(messageId: string): Promise<SentMessage>;
81
+ subscribe(listener: (snapshot: ChatSnapshot) => void, options?: SubscribeOptions<ChatSnapshot>): () => void;
82
+ subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
83
+ private sessionFields;
84
+ private notify;
85
+ observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./runtime/readIndex.js").ReadIndexState>;
86
+ markRead(threadId: string): Promise<import("./runtime/readIndex.js").ReadIndexState>;
87
+ getReaders(threadId: string, messageId: string, page?: Parameters<ChatSession<CachedChatMessage>["getReaders"]>[2], signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
88
+ watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
89
+ handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
90
+ getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
91
+ getGroup(threadId: string, options?: {
92
+ refresh?: boolean;
93
+ }): Promise<import("./chat.js").ChatGroup>;
94
+ createGroup(input: {
95
+ groupId?: string;
96
+ title: string;
97
+ members: readonly string[];
98
+ }): Promise<{
99
+ threadId: string;
100
+ groupId: string;
101
+ teamId: string;
102
+ ownerId: string;
103
+ title: string;
104
+ avatar?: string;
105
+ memberIds: readonly string[];
106
+ revision: string;
107
+ versionNumber: number;
108
+ }>;
109
+ updateGroup(threadId: string, changes: Parameters<ChatSession<CachedChatMessage>["updateGroup"]>[1]): Promise<import("./chat.js").ChatGroup>;
110
+ revokeAccess(): Promise<void>;
111
+ close(): Promise<void>;
112
+ }
113
+ export declare function createChat(options: CreateChatOptions): Chat;
package/lib/chat.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createChat as createSession } from "./runtime/createChat.js";
2
+ import { asChatError, ChatError } from "./runtime/chatError.js";
3
+ export { ChatError } from "./runtime/chatError.js";
4
+ const emptyMessages = Object.freeze([]);
5
+ /** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
6
+ export class Chat {
7
+ advanced;
8
+ options;
9
+ views = new Map();
10
+ summary;
11
+ targets = new Map();
12
+ errors = new Map();
13
+ listeners = new Map();
14
+ closed = false;
15
+ constructor(advanced, options) {
16
+ this.advanced = advanced;
17
+ this.options = options;
18
+ }
19
+ async operation(run) { try {
20
+ return await run();
21
+ }
22
+ catch (error) {
23
+ throw asChatError(error);
24
+ } }
25
+ threadOperation(threadId, run) {
26
+ return this.operation(async () => {
27
+ const denied = threadId ? this.errors.get(threadId) : undefined;
28
+ if (denied?.code === "access-denied")
29
+ throw denied;
30
+ try {
31
+ return await run();
32
+ }
33
+ catch (error) {
34
+ const failure = asChatError(error);
35
+ if (threadId && failure.code === "access-denied") {
36
+ this.errors.set(threadId, failure);
37
+ this.targets.delete(threadId);
38
+ this.notify(threadId);
39
+ }
40
+ throw failure;
41
+ }
42
+ });
43
+ }
44
+ start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
45
+ getConversation(target) {
46
+ return this.operation(async () => {
47
+ const id = await this.advanced.getConversation(target);
48
+ const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
49
+ this.targets.delete(id);
50
+ this.targets.set(id, value);
51
+ if (this.targets.size > 256)
52
+ this.targets.delete(this.targets.keys().next().value);
53
+ this.notify(id);
54
+ return id;
55
+ });
56
+ }
57
+ getSnapshot(threadId) {
58
+ if (threadId === undefined) {
59
+ const source = this.advanced.getSnapshot(), previous = this.summary;
60
+ const threads = source.threads.map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
61
+ if (previous && previous.connection === source.connection && previous.badgeCount === source.badgeCount && previous.unreadMessageCount === source.unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
62
+ return previous;
63
+ return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
64
+ }
65
+ const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
66
+ const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
67
+ const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
68
+ const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
69
+ // Partial migrated rows never masquerade as a trusted recent window.
70
+ const next = { threadId, conversation, messages: !denied && source.complete ? source.timeline : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : source.group, readCounts: denied ? undefined : source.readCounts, error };
71
+ if (previous && Object.keys(next).every(key => next[key] === previous[key]))
72
+ return previous;
73
+ this.views.set(threadId, next);
74
+ return next;
75
+ }
76
+ async getMessages(threadId, query = {}) {
77
+ try {
78
+ if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
79
+ throw new ChatError("invalid-input", "Unsupported message query");
80
+ const page = await this.advanced.getMessages(threadId, query);
81
+ if (!page.complete)
82
+ throw new ChatError("unavailable", "Messages are not ready; retry when connected");
83
+ this.errors.delete(threadId);
84
+ this.notify(threadId);
85
+ return this.getSnapshot(threadId);
86
+ }
87
+ catch (error) {
88
+ const failure = asChatError(error);
89
+ if (!this.closed) {
90
+ this.errors.set(threadId, failure);
91
+ this.notify(threadId);
92
+ }
93
+ throw failure;
94
+ }
95
+ }
96
+ sendMessage(threadId, content, options = {}) {
97
+ return this.threadOperation(threadId, async () => {
98
+ if (content && ("groupCreate" in content || "groupUpdate" in content))
99
+ throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
100
+ const result = await this.advanced.sendMessage(threadId, content, options);
101
+ return { id: result.hash, timestamp: result.t };
102
+ });
103
+ }
104
+ retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
105
+ subscribe(target, callback, options = {}) {
106
+ const global = typeof target === "function", key = global ? "" : target;
107
+ const config = global ? callback : options;
108
+ const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
109
+ let previous = read();
110
+ const emit = () => {
111
+ const value = read();
112
+ const fields = config?.fields;
113
+ const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
114
+ previous = value;
115
+ if (changed)
116
+ (global ? target : callback)(value);
117
+ };
118
+ let listeners = this.listeners.get(key);
119
+ if (!listeners) {
120
+ listeners = new Set();
121
+ this.listeners.set(key, listeners);
122
+ }
123
+ listeners.add(emit);
124
+ const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
125
+ const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
126
+ this.listeners.delete(key); };
127
+ if (config?.emitCurrent !== false) {
128
+ try {
129
+ (global ? target : callback)(previous);
130
+ }
131
+ catch (error) {
132
+ dispose();
133
+ throw error;
134
+ }
135
+ }
136
+ return dispose;
137
+ }
138
+ sessionFields(fields) {
139
+ return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
140
+ }
141
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); }
142
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, event)); }
143
+ markRead(threadId) { return this.threadOperation(threadId, () => this.advanced.markRead(threadId)); }
144
+ getReaders(threadId, messageId, page = {}, signal) { return this.threadOperation(threadId, () => this.advanced.getReaders(threadId, messageId, page, signal)); }
145
+ watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
146
+ handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
147
+ getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
148
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, () => this.advanced.getGroup(threadId, options)); }
149
+ createGroup(input) {
150
+ return this.operation(async () => {
151
+ const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
152
+ const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
153
+ return { ...group, threadId };
154
+ });
155
+ }
156
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, () => this.advanced.updateGroup(threadId, changes)); }
157
+ revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
158
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
159
+ }
160
+ export function createChat(options) {
161
+ const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
162
+ return new Chat(createSession(configured), configured);
163
+ }
@@ -0,0 +1,4 @@
1
+ import type { Chat, ChatSnapshot, ChatThreadSnapshot } from "../chat.js";
2
+ /** React consumes the same stable view as callback subscribers. No App-side cache. */
3
+ export declare function useChatSnapshot(chat: Chat | undefined): ChatSnapshot;
4
+ export declare function useChatSnapshot(chat: Chat | undefined, threadId: string): ChatThreadSnapshot;
@@ -0,0 +1,8 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+ const emptySummary = Object.freeze({ threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" });
3
+ const emptyThread = Object.freeze({ threadId: "", messages: [], ready: false, hasEarlier: false, unread: undefined, group: undefined, readCounts: undefined });
4
+ export function useChatSnapshot(chat, threadId) {
5
+ const subscribe = useCallback((notify) => !chat ? () => { } : threadId === undefined ? chat.subscribe(notify, { emitCurrent: false }) : chat.subscribe(threadId, notify, { emitCurrent: false }), [chat, threadId]);
6
+ const snapshot = useCallback(() => threadId === undefined ? chat?.getSnapshot() ?? emptySummary : chat?.getSnapshot(threadId) ?? emptyThread, [chat, threadId]);
7
+ return useSyncExternalStore(subscribe, snapshot, () => threadId === undefined ? emptySummary : emptyThread);
8
+ }
package/lib/index.d.ts CHANGED
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
41
41
  export * from "./ux/imagePaste";
42
42
  export * from "./hooks/useImageAttachments";
43
43
  export * from "./components/ImageAttachmentTray";
44
+ export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery } from "./chat.js";
45
+ export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
package/lib/index.js CHANGED
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
41
41
  export * from "./ux/imagePaste";
42
42
  export * from "./hooks/useImageAttachments";
43
43
  export * from "./components/ImageAttachmentTray";
44
+ export { createChat, Chat, ChatError } from "./chat.js";
45
+ export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
@@ -0,0 +1,12 @@
1
+ export type ChatErrorCode = "unavailable" | "access-denied" | "storage-unavailable" | "invalid-input" | "conflict" | "closed" | "busy";
2
+ /** Stable recovery categories. The original failure remains available as cause. */
3
+ export declare class ChatError extends Error {
4
+ readonly code: ChatErrorCode;
5
+ readonly retryable: boolean;
6
+ readonly status?: number;
7
+ constructor(code: ChatErrorCode, message: string, options?: {
8
+ cause?: unknown;
9
+ status?: number;
10
+ });
11
+ }
12
+ export declare function asChatError(error: unknown): ChatError;
@@ -0,0 +1,26 @@
1
+ /** Stable recovery categories. The original failure remains available as cause. */
2
+ export class ChatError extends Error {
3
+ code;
4
+ retryable;
5
+ status;
6
+ constructor(code, message, options = {}) {
7
+ super(message, { cause: options.cause });
8
+ this.code = code;
9
+ this.name = "ChatError";
10
+ this.status = options.status;
11
+ this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
12
+ }
13
+ }
14
+ export function asChatError(error) {
15
+ if (error instanceof ChatError)
16
+ return error;
17
+ const value = error;
18
+ const status = value?.status, message = value?.message ?? String(error);
19
+ const code = status === 401 || status === 403 || /ACCESS_REVOKED|ACCESS_DENIED|SCOPE_CHANGED/.test(message) ? "access-denied"
20
+ : /CLOSED/.test(message) ? "closed"
21
+ : status === 409 || /REVISION_CONFLICT|PROJECTION_CHANGED/.test(message) ? "conflict"
22
+ : /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
23
+ : status === 400 || /INVALID_|CONVERSATION_MISMATCH|NOT_OWNED/.test(message) ? "invalid-input"
24
+ : /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
25
+ return new ChatError(code, message, { cause: error, status });
26
+ }
@@ -1,3 +1,4 @@
1
+ import { type ConversationTarget } from "./conversationDirectory.js";
1
2
  import { type ChatGroup } from "./groupClient.js";
2
3
  import type { SocialGroup } from "./socialReadClient.js";
3
4
  import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
@@ -55,6 +56,7 @@ export type ChatSummarySnapshot<T> = {
55
56
  /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
56
57
  export declare class ChatSession<T extends StoredMessage> {
57
58
  private options;
59
+ private conversations;
58
60
  private messages;
59
61
  private connection;
60
62
  private reading;
@@ -132,26 +134,35 @@ export declare class ChatSession<T extends StoredMessage> {
132
134
  private readEarlier;
133
135
  private readAll;
134
136
  /** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
135
- getGroup(thread: ChatConversation, options?: {
137
+ getGroup(thread: ChatConversation | string, options?: {
136
138
  refresh?: boolean;
137
139
  }): Promise<ChatGroup>;
138
140
  createGroup(thread: ChatConversation, content: {
139
141
  title: string;
140
142
  members: readonly string[];
141
143
  }): Promise<ChatGroup>;
142
- updateGroup(thread: ChatConversation, changes: {
144
+ updateGroup(target: ChatConversation | string, changes: {
143
145
  expectedRevision?: string;
144
146
  addMembers?: readonly string[];
145
147
  removeMembers?: readonly string[];
146
148
  title?: string;
147
149
  avatar?: string;
148
150
  }): Promise<ChatGroup>;
151
+ sendMessage(threadId: string, input: OutgoingContent, options?: {
152
+ waitFor?: "accepted" | "delivered";
153
+ }): Promise<ChatRuntimeMessage>;
149
154
  sendMessage(thread: ChatConversation, input: OutgoingContent, options?: {
150
155
  waitFor?: "accepted" | "delivered";
151
156
  }): Promise<ChatRuntimeMessage>;
152
157
  sendMessage(threadId: string, input: SendMessageInput, options?: {
153
158
  waitFor?: "accepted" | "delivered";
154
159
  }): Promise<ChatRuntimeMessage>;
160
+ private sendWire;
161
+ getConversation(target: ConversationTarget): Promise<string>;
162
+ private sendContent;
163
+ private resolveConversation;
164
+ /** Validated target identity survives content redaction; no roster is exposed. */
165
+ getConversationTarget(threadId: string): ChatConversation | undefined;
155
166
  private sendControl;
156
167
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
157
168
  /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
@@ -168,8 +179,12 @@ export declare class ChatSession<T extends StoredMessage> {
168
179
  getSnapshot(): ChatSummarySnapshot<T>;
169
180
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
170
181
  private buildThreadSnapshot;
171
- subscribe(listener: () => void): () => void;
172
- subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
182
+ /** Delivers the current stable snapshot immediately, then only meaningful changes. */
183
+ subscribe(listener: (snapshot: ChatSummarySnapshot<T>) => void): () => void;
184
+ subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot<T>) => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
185
+ /** Change-only bridge for useSyncExternalStore and other framework adapters. */
186
+ subscribeChanges(listener: () => void): () => void;
187
+ subscribeChanges(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
173
188
  private publishAll;
174
189
  private buildGlobal;
175
190
  private publishGlobal;
@@ -177,6 +192,7 @@ export declare class ChatSession<T extends StoredMessage> {
177
192
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
178
193
  * completion means all durable stores have crossed the same authority epoch. */
179
194
  revokeAccess(): Promise<void>;
195
+ get active(): boolean;
180
196
  close(): Promise<void>;
181
197
  private dispose;
182
198
  }
@@ -1,7 +1,8 @@
1
+ import { ConversationDirectory } from "./conversationDirectory.js";
1
2
  import { groupThreadId } from "./thread.js";
2
3
  import { GroupClient } from "./groupClient.js";
3
4
  import { isReadingControl } from "./readingContent.js";
4
- import { outgoingContent } from "./outgoingContent.js";
5
+ import { outgoingContent, validateConversation } from "./outgoingContent.js";
5
6
  import { projectMessageTimeline } from "./messageTimeline.js";
6
7
  import { projectConversationReading } from "./conversationReading.js";
7
8
  import { ReadCountsClient } from "./readCounts.js";
@@ -15,6 +16,7 @@ import { unreadInConversation } from "./readIndex.js";
15
16
  /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
16
17
  export class ChatSession {
17
18
  options;
19
+ conversations;
18
20
  messages;
19
21
  connection;
20
22
  reading;
@@ -68,6 +70,7 @@ export class ChatSession {
68
70
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
69
71
  }
70
72
  this.messages = new MessageClient(options.messages);
73
+ this.conversations = new ConversationDirectory(scope, () => this.messages.scopeAccess());
71
74
  this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
72
75
  const handled = await this.handleReach(event);
73
76
  if (handled)
@@ -379,11 +382,11 @@ export class ChatSession {
379
382
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
380
383
  }
381
384
  /** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
382
- getGroup(thread, options = {}) {
385
+ async getGroup(thread, options = {}) {
383
386
  this.assertOpen();
384
387
  if (!this.groups)
385
388
  throw new Error("GROUP_METADATA_UNAVAILABLE");
386
- return this.groups.get(thread, options);
389
+ return this.groups.get(typeof thread === "string" ? await this.resolveConversation(thread) : thread, options);
387
390
  }
388
391
  async createGroup(thread, content) {
389
392
  this.assertOpen();
@@ -392,7 +395,8 @@ export class ChatSession {
392
395
  await this.sendMessage(thread, { groupCreate: content }, { waitFor: "delivered" });
393
396
  return this.groups.after(thread, 1);
394
397
  }
395
- async updateGroup(thread, changes) {
398
+ async updateGroup(target, changes) {
399
+ const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
396
400
  const previous = await this.getGroup(thread);
397
401
  if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
398
402
  throw new Error("INVALID_GROUP_VERSION");
@@ -403,14 +407,61 @@ export class ChatSession {
403
407
  this.assertOpen();
404
408
  if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
405
409
  throw new Error("INVALID_MESSAGE_WAIT");
406
- const threadId = typeof thread === "string" ? thread : thread.id;
407
- const wire = typeof thread === "string" ? input : outgoingContent(this.options.messages.scope, thread, input);
408
- const delivered = options.waitFor === "delivered";
409
- if (typeof thread !== "string" && (input.recall || input.reaction))
410
- return this.sendControl({ id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: thread.teamId, memberIds: wire.recipients }, wire, delivered);
410
+ if (typeof thread === "string" && Object.hasOwn(input, "body"))
411
+ return this.sendWire(thread, input, options.waitFor === "delivered");
412
+ return this.sendContent(thread, input, options.waitFor === "delivered");
413
+ }
414
+ sendWire(threadId, wire, delivered) {
411
415
  return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
412
416
  throw new Error("CHAT_ACCESS_REVOKED"); return next; });
413
417
  }
418
+ getConversation(target) { this.assertOpen(); return this.conversations.create(target); }
419
+ async sendContent(target, input, delivered) {
420
+ let thread = typeof target === "string" ? await this.resolveConversation(target) : target;
421
+ validateConversation(this.options.messages.scope, thread);
422
+ await this.conversations.put(thread);
423
+ this.assertOpen();
424
+ // Recipients are SDK-owned metadata, never an App-supplied roster.
425
+ if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
426
+ const group = await this.getGroup(thread);
427
+ this.assertOpen();
428
+ thread = { ...thread, memberIds: group.memberIds };
429
+ }
430
+ const wire = outgoingContent(this.options.messages.scope, thread, input);
431
+ if (input.recall || input.reaction)
432
+ return this.sendControl({ ...thread, memberIds: wire.recipients }, wire, delivered);
433
+ return this.sendWire(thread.id, wire, delivered);
434
+ }
435
+ async resolveConversation(threadId) {
436
+ const known = await this.conversations.get(threadId);
437
+ if (known)
438
+ return known;
439
+ const inferred = this.getConversationTarget(threadId);
440
+ if (!inferred)
441
+ throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
442
+ return inferred;
443
+ }
444
+ /** Validated target identity survives content redaction; no roster is exposed. */
445
+ getConversationTarget(threadId) {
446
+ const scope = this.options.messages.scope;
447
+ const group = this.getSnapshot(threadId).group;
448
+ if (group)
449
+ return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
450
+ for (const value of this.getSnapshot(threadId).messages) {
451
+ const row = value;
452
+ const kind = row.threadKind ?? row.body?.threadKind;
453
+ if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
454
+ continue;
455
+ const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
456
+ // A stored row is only a hint; validate the canonical scoped identifier.
457
+ try {
458
+ validateConversation(scope, thread);
459
+ return thread;
460
+ }
461
+ catch { }
462
+ }
463
+ return undefined;
464
+ }
414
465
  sendControl(thread, wire, delivered = false) {
415
466
  const content = wire.body.content;
416
467
  const recall = content.recall, reaction = content.reaction;
@@ -643,6 +694,33 @@ export class ChatSession {
643
694
  return { group: this.groups?.snapshot(threadId).data, timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
644
695
  }
645
696
  subscribe(target, listener, fields) {
697
+ if (typeof target === "function") {
698
+ const notify = () => target(this.getSnapshot());
699
+ const off = this.subscribeChanges(notify);
700
+ try {
701
+ notify();
702
+ }
703
+ catch (error) {
704
+ off();
705
+ throw error;
706
+ }
707
+ return off;
708
+ }
709
+ const notify = () => listener(this.getSnapshot(target));
710
+ const off = this.subscribeChanges(target, notify, fields);
711
+ // The deprecated field-array form retains change-only behavior during migration.
712
+ if (!fields) {
713
+ try {
714
+ notify();
715
+ }
716
+ catch (error) {
717
+ off();
718
+ throw error;
719
+ }
720
+ }
721
+ return off;
722
+ }
723
+ subscribeChanges(target, listener, fields) {
646
724
  if (this.closed || this.withdrawn)
647
725
  return () => { };
648
726
  if (typeof target === "function") {
@@ -771,7 +849,7 @@ export class ChatSession {
771
849
  const results = await Promise.allSettled([
772
850
  this.sender.revoke(version),
773
851
  this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
774
- this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version),
852
+ this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version), this.conversations.retire(version),
775
853
  ]);
776
854
  for (const result of results)
777
855
  if (result.status === "rejected")
@@ -786,6 +864,7 @@ export class ChatSession {
786
864
  throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
787
865
  });
788
866
  }
867
+ get active() { return !this.closed && !this.withdrawn; }
789
868
  close() { return this.revoking ?? this.dispose(); }
790
869
  dispose() {
791
870
  return this.closing ??= (async () => {
@@ -799,7 +878,7 @@ export class ChatSession {
799
878
  const readingBarrier = Promise.allSettled([...this.acceptedReading, ...this.acceptedControls]);
800
879
  const readingClosing = readingBarrier.then(() => this.reading.close());
801
880
  try {
802
- const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
881
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), this.conversations.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
803
882
  const failed = results.find((result) => result.status === "rejected");
804
883
  if (failed)
805
884
  throw failed.reason;
@@ -0,0 +1,36 @@
1
+ import type { ResponseCacheAccess } from "./responseCache.js";
2
+ import { type MessageScope } from "./messageStore.js";
3
+ import { type ChatConversation } from "./outgoingContent.js";
4
+ export type ConversationTarget = {
5
+ peerId: string;
6
+ groupId?: never;
7
+ } | {
8
+ groupId: string;
9
+ peerId?: never;
10
+ };
11
+ /** Canonical target identities only; rosters always belong to GroupClient. */
12
+ export declare class ConversationDirectory {
13
+ private scope;
14
+ private access;
15
+ private cache;
16
+ private memory;
17
+ private pending;
18
+ private queue;
19
+ private writes;
20
+ private version;
21
+ private authority;
22
+ private scheduled;
23
+ private closed;
24
+ private retired;
25
+ constructor(scope: MessageScope, access: ResponseCacheAccess);
26
+ private authorize;
27
+ private assertOpen;
28
+ private remember;
29
+ create(target: ConversationTarget): Promise<string>;
30
+ put(thread: ChatConversation): Promise<ChatConversation>;
31
+ private persist;
32
+ get(id: string): Promise<ChatConversation | undefined>;
33
+ private flush;
34
+ retire(version: number): Promise<void>;
35
+ close(): Promise<void>;
36
+ }
@@ -0,0 +1,116 @@
1
+ import { IndexedDbResponseCache } from "./responseCache.js";
2
+ import { messageScopeKey } from "./messageStore.js";
3
+ import { validateConversation } from "./outgoingContent.js";
4
+ import { dmThreadId, groupThreadId } from "./thread.js";
5
+ /** Canonical target identities only; rosters always belong to GroupClient. */
6
+ export class ConversationDirectory {
7
+ scope;
8
+ access;
9
+ cache;
10
+ memory = new Map();
11
+ pending = new Map();
12
+ queue = [];
13
+ writes = new Map();
14
+ version;
15
+ authority;
16
+ scheduled = false;
17
+ closed = false;
18
+ retired = false;
19
+ constructor(scope, access) {
20
+ this.scope = scope;
21
+ this.access = access;
22
+ this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096 });
23
+ }
24
+ authorize() { return this.authority ??= this.access().finally(() => { this.authority = undefined; }); }
25
+ assertOpen() { if (this.retired)
26
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
27
+ throw new Error("CHAT_SESSION_CLOSED"); }
28
+ remember(thread) {
29
+ this.memory.delete(thread.id);
30
+ this.memory.set(thread.id, thread);
31
+ if (this.memory.size > 256)
32
+ this.memory.delete(this.memory.keys().next().value);
33
+ return thread;
34
+ }
35
+ async create(target) {
36
+ const kind = target.groupId !== undefined ? "group" : "dm", targetId = target.groupId ?? target.peerId;
37
+ if (target.groupId !== undefined && target.peerId !== undefined)
38
+ throw new Error("INVALID_MESSAGE_CONVERSATION");
39
+ const id = kind === "group" ? groupThreadId(this.scope.appId, targetId, this.scope.teamId) : dmThreadId(this.scope.appId, this.scope.profileId, targetId, this.scope.teamId);
40
+ await this.put({ id, kind, targetId, teamId: this.scope.teamId });
41
+ return id;
42
+ }
43
+ put(thread) {
44
+ this.assertOpen();
45
+ validateConversation(this.scope, thread);
46
+ const existing = this.writes.get(thread.id);
47
+ if (existing)
48
+ return existing;
49
+ if (this.writes.size >= 128)
50
+ return Promise.reject(new Error("CONVERSATION_BACKPRESSURE"));
51
+ const task = this.persist(thread).finally(() => this.writes.delete(thread.id));
52
+ this.writes.set(thread.id, task);
53
+ return task;
54
+ }
55
+ async persist(thread) {
56
+ this.assertOpen();
57
+ validateConversation(this.scope, thread);
58
+ const value = { id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: this.scope.teamId };
59
+ const previous = await this.get(thread.id);
60
+ if (previous) {
61
+ if (previous.kind !== value.kind || previous.targetId !== value.targetId)
62
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
63
+ return previous;
64
+ }
65
+ const row = await this.cache.read(thread.id);
66
+ await this.cache.write(thread.id, value, row.epoch);
67
+ this.assertOpen();
68
+ return this.remember(value);
69
+ }
70
+ async get(id) {
71
+ this.assertOpen();
72
+ const authority = await this.authorize();
73
+ this.assertOpen();
74
+ if (!authority.allowed)
75
+ throw new Error("CHAT_ACCESS_REVOKED");
76
+ if (this.version !== undefined && this.version !== authority.version)
77
+ this.memory.clear();
78
+ this.version = authority.version;
79
+ const known = this.memory.get(id);
80
+ if (known)
81
+ return this.remember(known);
82
+ const existing = this.pending.get(id);
83
+ if (existing)
84
+ return existing;
85
+ if (this.pending.size >= 128)
86
+ throw new Error("CONVERSATION_BACKPRESSURE");
87
+ const task = new Promise((resolve, reject) => this.queue.push({ id, resolve, reject })).finally(() => this.pending.delete(id));
88
+ this.pending.set(id, task);
89
+ if (!this.scheduled) {
90
+ this.scheduled = true;
91
+ queueMicrotask(() => { this.scheduled = false; void this.flush(); });
92
+ }
93
+ return task;
94
+ }
95
+ async flush() {
96
+ const batch = this.queue.splice(0, 128);
97
+ try {
98
+ const rows = await this.cache.readMany(batch.map(item => item.id));
99
+ this.assertOpen();
100
+ for (const item of batch) {
101
+ const value = rows.get(item.id)?.cached?.value;
102
+ if (value) {
103
+ if (value.id !== item.id)
104
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
105
+ validateConversation(this.scope, value);
106
+ }
107
+ item.resolve(value ? this.remember(value) : undefined);
108
+ }
109
+ }
110
+ catch (error) {
111
+ batch.forEach(item => item.reject(error));
112
+ }
113
+ }
114
+ retire(version) { this.retired = true; this.memory.clear(); return this.cache.retire(version); }
115
+ async close() { this.closed = true; this.memory.clear(); await Promise.allSettled([...this.pending.values(), ...this.writes.values()]); await this.cache.close(); }
116
+ }
@@ -35,3 +35,4 @@ export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type
35
35
  export * from "./messageLaunch.js";
36
36
  export * from "./outgoingContent.js";
37
37
  export type { ChatGroup } from "./groupClient.js";
38
+ export type { ConversationTarget } from "./conversationDirectory.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.61",
4
- "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
3
+ "version": "0.12.0-next.63",
4
+ "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
7
7
  "types": "./lib/index.d.ts",
@@ -28,6 +28,11 @@
28
28
  "types": "./lib/runtime/index.d.ts",
29
29
  "import": "./lib/runtime/index.js",
30
30
  "default": "./lib/runtime/index.js"
31
+ },
32
+ "./chat": {
33
+ "types": "./lib/chat.d.ts",
34
+ "import": "./lib/chat.js",
35
+ "default": "./lib/chat.js"
31
36
  }
32
37
  },
33
38
  "publishConfig": {