@pmate/chat-sdk 0.12.0-next.60 → 0.12.0-next.62

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,114 @@ 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` and optional `error`. `ready` helps
170
+ route entry and startup before the first read; it is not a cache-source indicator.
171
+ `getSnapshot()` and `subscribe(listener)` provide conversation summaries, aggregate
172
+ unread counts and connection state. Unknown unread counts remain `undefined`.
173
+ Subscription always emits the current snapshot first, including with
174
+ `{fields:["messages","unread"]}`, then meaningful changes only. Object references
175
+ remain stable for unchanged results. Framework adapters can use
176
+ `{emitCurrent:false}`; React can import `useChatSnapshot(chat, threadId)` from
177
+ `@pmate/chat-sdk`. Establishing a subscription never starts a read or marks it read.
178
+
179
+ Sending accepts text/mentions/quotes, images, voice, recalls, reactions and
180
+ invitations through the same typed content union. The SDK resolves the target
181
+ and group roster, expands @all and assigns reaction revisions. The default
182
+ Promise resolves after durable acceptance and returns `{id,timestamp}`; outgoing
183
+ status is part of the same message list. `{waitFor:"delivered"}` waits for the
184
+ server acknowledgement. `retryMessage(id)` retries a failed exact wire.
141
185
 
142
186
  ```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()))
187
+ await chat.sendMessage(threadId, {voice:{blob,durationMs}})
188
+ await chat.sendMessage(threadId, {reaction:{messageId,emojiId,active:true}})
189
+ await chat.sendMessage(threadId, {recall:{messageId}}, {waitFor:"delivered"})
155
190
  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 } })
191
+ await chat.observeMessages(threadId, [voiceId], "played") // actual playing event
192
+ await chat.markRead(threadId) // ordinary text only; unheard voice stays unread
162
193
  const readers = await chat.getReaders(threadId, messageId)
163
- off(); offSummary()
164
- // Ordinary navigation/shutdown preserves offline data.
165
- await chat.close()
166
194
  ```
167
195
 
196
+ Voice media is acquired on demand through `getVoiceAttachment(threadId,messageId)`.
197
+ Use its `url` and call `release()` when playback/transcription stops. Acquisition,
198
+ clicking play, a fulfilled play Promise and transcription do not count as playing.
199
+ The SDK persists observations locally, batches writes and synchronization, and
200
+ preserves offline intents. It does not promise all media binaries are offline.
201
+
168
202
  ```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()
203
+ const group = await chat.getGroup(threadId)
204
+ const created = await chat.createGroup({title, members}) // optional groupId
205
+ await chat.updateGroup(created.threadId, {title: "New title"})
173
206
  ```
174
207
 
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
-
208
+ Group reads restore IndexedDB metadata before background reconciliation. Mutations
209
+ wait for confirmed delivery and a causal server version. SDK-owned requests share
210
+ bounded queues and time windows; Apps do not poll. `watchReaders(threadId,ids,receiver)`
211
+ selects receipt counts for the current viewport; clear with `watchReaders(undefined,[])`.
212
+
213
+ `ChatError` exposes `code`, `retryable`, optional HTTP `status`, and the original
214
+ `cause`. Recovery categories are `unavailable`, `access-denied`,
215
+ `storage-unavailable`, `invalid-input`, `conflict`, `closed`, and `busy`.
216
+ Permission failures never turn into trusted offline data. Dispose and recreate
217
+ the SDK when account, profile, team or token changes.
218
+
219
+ ### Advanced integration and migration
220
+
221
+ `chat.advanced` exposes the underlying `ChatSession` for one-time legacy imports,
222
+ custom transports and diagnostics. Its `messages` are raw records, `timeline` is
223
+ the semantic projection, and `complete/resetVersion` describe coverage internals.
224
+ The older `createChat` export from `@pmate/chat-sdk/runtime` retains this low-level
225
+ contract for compatibility. New Apps should import from `@pmate/chat-sdk/chat`
226
+ or the package root. Do not mix the two snapshot contracts.
227
+
228
+ For migration, replace `subscribe(id, () => render(getSnapshot(id).timeline))`
229
+ with `subscribe(id, state => render(state.messages))`; replace conversation
230
+ objects and App-supplied recipients with `threadId`; await `getMessages` before
231
+ opening a cold thread instead of checking `page.complete`. Replace `initialize()`
232
+ with `start()`; a shared connection is initialized without another connect call.
233
+ Raw wire sends are available only through `advanced.sendMessage`. Group changes
234
+ use `createGroup/updateGroup`; UI never assigns reaction versions or recipients.
235
+
236
+ The advanced storage and transport classes remain exported from `/runtime`.
237
+ They must share one authenticated scope and must not bypass scope authority.
238
+ Normal UI code should not maintain another message cache or outbox.
258
239
 
259
240
  ### Reading storage recovery
260
241
 
@@ -282,29 +263,9 @@ Pending voice snapshots contain `voiceAttachment` metadata, without loading audi
282
263
  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
264
 
284
265
 
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.
266
+ SDK group metadata uses a 200ms merge window, four concurrent requests, a bounded
267
+ 128-request queue, five-second timeout and at most five read-after-write attempts.
268
+ The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
269
+ 32-record write batches, conditional writes and bounded pruning. Conversation
270
+ identities are stored separately without rosters; repeated registrations share
271
+ one write and concurrent lookups use bulk reads. Scope withdrawal retires both.
package/lib/chat.d.ts ADDED
@@ -0,0 +1,101 @@
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 } 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, OutgoingContent };
11
+ export type { ChatGroup } from "./runtime/groupClient.js";
12
+ export type ChatMessage = TimelineItem<CachedChatMessage>;
13
+ export type ChatThreadSnapshot = {
14
+ threadId: string;
15
+ messages: readonly ChatMessage[];
16
+ ready: boolean;
17
+ hasEarlier: boolean;
18
+ unread: SessionThread<CachedChatMessage>["unread"];
19
+ group: SessionThread<CachedChatMessage>["group"];
20
+ readCounts: SessionThread<CachedChatMessage>["readCounts"];
21
+ error?: ChatError;
22
+ };
23
+ export type ChatSnapshot = {
24
+ threads: readonly {
25
+ threadId: string;
26
+ lastMessage?: ChatMessage;
27
+ group: SessionThread<CachedChatMessage>["group"];
28
+ unread: ChatThreadSnapshot["unread"];
29
+ outgoingCount: number;
30
+ }[];
31
+ unreadMessageCount: number | undefined;
32
+ badgeCount: number | undefined;
33
+ connection: SessionSummary<CachedChatMessage>["connection"];
34
+ };
35
+ export type MessageQuery = {
36
+ earlier?: boolean;
37
+ all?: boolean;
38
+ refresh?: boolean;
39
+ };
40
+ export type SendOptions = {
41
+ waitFor?: "accepted" | "delivered";
42
+ };
43
+ export type SentMessage = {
44
+ id: string;
45
+ timestamp: number;
46
+ };
47
+ type SubscribeOptions<T> = {
48
+ fields?: readonly (keyof T)[];
49
+ emitCurrent?: boolean;
50
+ };
51
+ /** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
52
+ export declare class Chat {
53
+ readonly advanced: ChatSession<CachedChatMessage>;
54
+ private options;
55
+ private views;
56
+ private summary?;
57
+ private errors;
58
+ private listeners;
59
+ private closed;
60
+ constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
61
+ private operation;
62
+ start(): Promise<void>;
63
+ getConversation(target: ConversationTarget): Promise<string>;
64
+ getSnapshot(): ChatSnapshot;
65
+ getSnapshot(threadId: string): ChatThreadSnapshot;
66
+ getMessages(threadId: string, query?: MessageQuery): Promise<ChatThreadSnapshot>;
67
+ sendMessage(threadId: string, content: OutgoingContent, options?: SendOptions): Promise<SentMessage>;
68
+ retryMessage(messageId: string): Promise<SentMessage>;
69
+ subscribe(listener: (snapshot: ChatSnapshot) => void, options?: SubscribeOptions<ChatSnapshot>): () => void;
70
+ subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
71
+ private sessionFields;
72
+ private notify;
73
+ observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./runtime/readIndex.js").ReadIndexState>;
74
+ markRead(threadId: string): Promise<import("./runtime/readIndex.js").ReadIndexState>;
75
+ getReaders(threadId: string, messageId: string, page?: Parameters<ChatSession<CachedChatMessage>["getReaders"]>[2], signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
76
+ watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
77
+ handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
78
+ getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
79
+ getGroup(threadId: string, options?: {
80
+ refresh?: boolean;
81
+ }): Promise<import("./chat.js").ChatGroup>;
82
+ createGroup(input: {
83
+ groupId?: string;
84
+ title: string;
85
+ members: readonly string[];
86
+ }): Promise<{
87
+ threadId: string;
88
+ groupId: string;
89
+ teamId: string;
90
+ ownerId: string;
91
+ title: string;
92
+ avatar?: string;
93
+ memberIds: readonly string[];
94
+ revision: string;
95
+ versionNumber: number;
96
+ }>;
97
+ updateGroup(threadId: string, changes: Parameters<ChatSession<CachedChatMessage>["updateGroup"]>[1]): Promise<import("./chat.js").ChatGroup>;
98
+ revokeAccess(): Promise<void>;
99
+ close(): Promise<void>;
100
+ }
101
+ export declare function createChat(options: CreateChatOptions): Chat;
package/lib/chat.js ADDED
@@ -0,0 +1,124 @@
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
+ errors = new Map();
12
+ listeners = new Map();
13
+ closed = false;
14
+ constructor(advanced, options) {
15
+ this.advanced = advanced;
16
+ this.options = options;
17
+ }
18
+ async operation(run) { try {
19
+ return await run();
20
+ }
21
+ catch (error) {
22
+ throw asChatError(error);
23
+ } }
24
+ start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
25
+ getConversation(target) { return this.operation(() => this.advanced.getConversation(target)); }
26
+ getSnapshot(threadId) {
27
+ if (threadId === undefined) {
28
+ const source = this.advanced.getSnapshot(), previous = this.summary;
29
+ const threads = source.threads.map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
30
+ 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; }))
31
+ return previous;
32
+ return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
33
+ }
34
+ const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
35
+ // Partial migrated rows never masquerade as a trusted recent window.
36
+ const next = { threadId, messages: source.complete ? source.timeline : emptyMessages, ready: source.complete, hasEarlier: source.hasEarlier, unread: source.unread, group: source.group, readCounts: source.readCounts, error: this.closed ? undefined : this.errors.get(threadId) };
37
+ if (previous && Object.keys(next).every(key => next[key] === previous[key]))
38
+ return previous;
39
+ this.views.set(threadId, next);
40
+ return next;
41
+ }
42
+ async getMessages(threadId, query = {}) {
43
+ try {
44
+ if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
45
+ throw new ChatError("invalid-input", "Unsupported message query");
46
+ const page = await this.advanced.getMessages(threadId, query);
47
+ if (!page.complete)
48
+ throw new ChatError("unavailable", "Messages are not ready; retry when connected");
49
+ this.errors.delete(threadId);
50
+ this.notify(threadId);
51
+ return this.getSnapshot(threadId);
52
+ }
53
+ catch (error) {
54
+ const failure = asChatError(error);
55
+ if (!this.closed) {
56
+ this.errors.set(threadId, failure);
57
+ this.notify(threadId);
58
+ }
59
+ throw failure;
60
+ }
61
+ }
62
+ sendMessage(threadId, content, options = {}) {
63
+ return this.operation(async () => { const result = await this.advanced.sendMessage(threadId, content, options); return { id: result.hash, timestamp: result.t }; });
64
+ }
65
+ retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
66
+ subscribe(target, callback, options = {}) {
67
+ const global = typeof target === "function", key = global ? "" : target;
68
+ const config = global ? callback : options;
69
+ const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
70
+ let previous = read();
71
+ const emit = () => {
72
+ const value = read();
73
+ const fields = config?.fields;
74
+ const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
75
+ previous = value;
76
+ if (changed)
77
+ (global ? target : callback)(value);
78
+ };
79
+ let listeners = this.listeners.get(key);
80
+ if (!listeners) {
81
+ listeners = new Set();
82
+ this.listeners.set(key, listeners);
83
+ }
84
+ listeners.add(emit);
85
+ const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
86
+ const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
87
+ this.listeners.delete(key); };
88
+ if (config?.emitCurrent !== false) {
89
+ try {
90
+ (global ? target : callback)(previous);
91
+ }
92
+ catch (error) {
93
+ dispose();
94
+ throw error;
95
+ }
96
+ }
97
+ return dispose;
98
+ }
99
+ sessionFields(fields) {
100
+ return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
101
+ }
102
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); }
103
+ observeMessages(threadId, ids, event) { return this.operation(() => this.advanced.observeMessages(threadId, ids, event)); }
104
+ markRead(threadId) { return this.operation(() => this.advanced.markRead(threadId)); }
105
+ getReaders(threadId, messageId, page = {}, signal) { return this.operation(() => this.advanced.getReaders(threadId, messageId, page, signal)); }
106
+ watchReaders(threadId, ids, receiver) { return this.operation(() => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
107
+ handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
108
+ getVoiceAttachment(threadId, messageId) { return this.operation(() => this.advanced.getVoiceAttachment(threadId, messageId)); }
109
+ getGroup(threadId, options = {}) { return this.operation(() => this.advanced.getGroup(threadId, options)); }
110
+ createGroup(input) {
111
+ return this.operation(async () => {
112
+ const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
113
+ const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
114
+ return { ...group, threadId };
115
+ });
116
+ }
117
+ updateGroup(threadId, changes) { return this.operation(() => this.advanced.updateGroup(threadId, changes)); }
118
+ revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
119
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
120
+ }
121
+ export function createChat(options) {
122
+ const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
123
+ return new Chat(createSession(configured), configured);
124
+ }
@@ -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,33 @@ 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;
155
164
  private sendControl;
156
165
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
157
166
  /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
@@ -168,8 +177,12 @@ export declare class ChatSession<T extends StoredMessage> {
168
177
  getSnapshot(): ChatSummarySnapshot<T>;
169
178
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
170
179
  private buildThreadSnapshot;
171
- subscribe(listener: () => void): () => void;
172
- subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
180
+ /** Delivers the current stable snapshot immediately, then only meaningful changes. */
181
+ subscribe(listener: (snapshot: ChatSummarySnapshot<T>) => void): () => void;
182
+ subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot<T>) => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
183
+ /** Change-only bridge for useSyncExternalStore and other framework adapters. */
184
+ subscribeChanges(listener: () => void): () => void;
185
+ subscribeChanges(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
173
186
  private publishAll;
174
187
  private buildGlobal;
175
188
  private publishGlobal;
@@ -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,54 @@ 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 scope = this.options.messages.scope;
440
+ const group = this.getSnapshot(threadId).group;
441
+ if (group)
442
+ return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
443
+ for (const value of this.getSnapshot(threadId).messages) {
444
+ const row = value;
445
+ const kind = row.threadKind ?? row.body?.threadKind;
446
+ if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
447
+ continue;
448
+ const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
449
+ // A stored row is only a hint; validate the canonical scoped identifier.
450
+ try {
451
+ validateConversation(scope, thread);
452
+ return thread;
453
+ }
454
+ catch { }
455
+ }
456
+ throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
457
+ }
414
458
  sendControl(thread, wire, delivered = false) {
415
459
  const content = wire.body.content;
416
460
  const recall = content.recall, reaction = content.reaction;
@@ -643,6 +687,33 @@ export class ChatSession {
643
687
  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
688
  }
645
689
  subscribe(target, listener, fields) {
690
+ if (typeof target === "function") {
691
+ const notify = () => target(this.getSnapshot());
692
+ const off = this.subscribeChanges(notify);
693
+ try {
694
+ notify();
695
+ }
696
+ catch (error) {
697
+ off();
698
+ throw error;
699
+ }
700
+ return off;
701
+ }
702
+ const notify = () => listener(this.getSnapshot(target));
703
+ const off = this.subscribeChanges(target, notify, fields);
704
+ // The deprecated field-array form retains change-only behavior during migration.
705
+ if (!fields) {
706
+ try {
707
+ notify();
708
+ }
709
+ catch (error) {
710
+ off();
711
+ throw error;
712
+ }
713
+ }
714
+ return off;
715
+ }
716
+ subscribeChanges(target, listener, fields) {
646
717
  if (this.closed || this.withdrawn)
647
718
  return () => { };
648
719
  if (typeof target === "function") {
@@ -666,7 +737,7 @@ export class ChatSession {
666
737
  this.listeners.set(threadId, listeners);
667
738
  }
668
739
  listeners.add(notify);
669
- const off = this.messages.subscribe(threadId, () => { }), unpin = this.groups?.pin(threadId);
740
+ const off = this.messages.subscribe(threadId, () => { }), unpin = (!selected || selected.includes("group")) ? this.groups?.pin(threadId) : undefined;
670
741
  return () => {
671
742
  off();
672
743
  unpin?.();
@@ -771,7 +842,7 @@ export class ChatSession {
771
842
  const results = await Promise.allSettled([
772
843
  this.sender.revoke(version),
773
844
  this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
774
- this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version),
845
+ this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version), this.conversations.retire(version),
775
846
  ]);
776
847
  for (const result of results)
777
848
  if (result.status === "rejected")
@@ -799,7 +870,7 @@ export class ChatSession {
799
870
  const readingBarrier = Promise.allSettled([...this.acceptedReading, ...this.acceptedControls]);
800
871
  const readingClosing = readingBarrier.then(() => this.reading.close());
801
872
  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]))]);
873
+ 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
874
  const failed = results.find((result) => result.status === "rejected");
804
875
  if (failed)
805
876
  throw failed.reason;
@@ -0,0 +1,34 @@
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 scheduled;
22
+ private closed;
23
+ private retired;
24
+ constructor(scope: MessageScope, access: ResponseCacheAccess);
25
+ private assertOpen;
26
+ private remember;
27
+ create(target: ConversationTarget): Promise<string>;
28
+ put(thread: ChatConversation): Promise<ChatConversation>;
29
+ private persist;
30
+ get(id: string): Promise<ChatConversation | undefined>;
31
+ private flush;
32
+ retire(version: number): Promise<void>;
33
+ close(): Promise<void>;
34
+ }
@@ -0,0 +1,114 @@
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
+ scheduled = false;
16
+ closed = false;
17
+ retired = false;
18
+ constructor(scope, access) {
19
+ this.scope = scope;
20
+ this.access = access;
21
+ this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access, entries: 256, recordBytes: 4096 });
22
+ }
23
+ assertOpen() { if (this.retired)
24
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
25
+ throw new Error("CHAT_SESSION_CLOSED"); }
26
+ remember(thread) {
27
+ this.memory.delete(thread.id);
28
+ this.memory.set(thread.id, thread);
29
+ if (this.memory.size > 256)
30
+ this.memory.delete(this.memory.keys().next().value);
31
+ return thread;
32
+ }
33
+ async create(target) {
34
+ const kind = target.groupId !== undefined ? "group" : "dm", targetId = target.groupId ?? target.peerId;
35
+ if (target.groupId !== undefined && target.peerId !== undefined)
36
+ throw new Error("INVALID_MESSAGE_CONVERSATION");
37
+ const id = kind === "group" ? groupThreadId(this.scope.appId, targetId, this.scope.teamId) : dmThreadId(this.scope.appId, this.scope.profileId, targetId, this.scope.teamId);
38
+ await this.put({ id, kind, targetId, teamId: this.scope.teamId });
39
+ return id;
40
+ }
41
+ put(thread) {
42
+ this.assertOpen();
43
+ validateConversation(this.scope, thread);
44
+ const existing = this.writes.get(thread.id);
45
+ if (existing)
46
+ return existing;
47
+ if (this.writes.size >= 128)
48
+ return Promise.reject(new Error("CONVERSATION_BACKPRESSURE"));
49
+ const task = this.persist(thread).finally(() => this.writes.delete(thread.id));
50
+ this.writes.set(thread.id, task);
51
+ return task;
52
+ }
53
+ async persist(thread) {
54
+ this.assertOpen();
55
+ validateConversation(this.scope, thread);
56
+ const value = { id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: this.scope.teamId };
57
+ const previous = await this.get(thread.id);
58
+ if (previous) {
59
+ if (previous.kind !== value.kind || previous.targetId !== value.targetId)
60
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
61
+ return previous;
62
+ }
63
+ const row = await this.cache.read(thread.id);
64
+ await this.cache.write(thread.id, value, row.epoch);
65
+ this.assertOpen();
66
+ return this.remember(value);
67
+ }
68
+ async get(id) {
69
+ this.assertOpen();
70
+ const authority = await this.access();
71
+ this.assertOpen();
72
+ if (!authority.allowed)
73
+ throw new Error("CHAT_ACCESS_REVOKED");
74
+ if (this.version !== undefined && this.version !== authority.version)
75
+ this.memory.clear();
76
+ this.version = authority.version;
77
+ const known = this.memory.get(id);
78
+ if (known)
79
+ return this.remember(known);
80
+ const existing = this.pending.get(id);
81
+ if (existing)
82
+ return existing;
83
+ if (this.pending.size >= 128)
84
+ throw new Error("CONVERSATION_BACKPRESSURE");
85
+ const task = new Promise((resolve, reject) => this.queue.push({ id, resolve, reject })).finally(() => this.pending.delete(id));
86
+ this.pending.set(id, task);
87
+ if (!this.scheduled) {
88
+ this.scheduled = true;
89
+ queueMicrotask(() => { this.scheduled = false; void this.flush(); });
90
+ }
91
+ return task;
92
+ }
93
+ async flush() {
94
+ const batch = this.queue.splice(0, 128);
95
+ try {
96
+ const rows = await this.cache.readMany(batch.map(item => item.id));
97
+ this.assertOpen();
98
+ for (const item of batch) {
99
+ const value = rows.get(item.id)?.cached?.value;
100
+ if (value) {
101
+ if (value.id !== item.id)
102
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
103
+ validateConversation(this.scope, value);
104
+ }
105
+ item.resolve(value ? this.remember(value) : undefined);
106
+ }
107
+ }
108
+ catch (error) {
109
+ batch.forEach(item => item.reject(error));
110
+ }
111
+ }
112
+ retire(version) { this.retired = true; this.memory.clear(); return this.cache.retire(version); }
113
+ async close() { this.closed = true; this.memory.clear(); await Promise.allSettled([...this.pending.values(), ...this.writes.values()]); await this.cache.close(); }
114
+ }
@@ -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.60",
4
- "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
3
+ "version": "0.12.0-next.62",
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": {