@pmate/chat-sdk 0.12.0-next.1 → 0.12.0-next.10

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
@@ -137,6 +137,52 @@ sends and reading synchronization independently. The runtime now provides local
137
137
  IndexedDB storage and fixed-window scheduling primitives so those operations can
138
138
  share bounded I/O, durable confirmation and scoped subscriptions.
139
139
 
140
+ The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
141
+
142
+ ```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()))
155
+ await chat.observeMessages(threadId, visibleIds, "visible")
156
+ // Report only the actual audio `playing` event.
157
+ await chat.observeMessages(threadId, [voiceId], "played")
158
+ await chat.sendMessage(threadId, { to, body, recipients })
159
+ const readers = await chat.getReaders(threadId, messageId)
160
+ off(); offSummary()
161
+ await chat.close()
162
+ ```
163
+
164
+ The facade owns all clients below. Message reads transparently restore IndexedDB
165
+ windows and fetch missing ranges through the authenticated Hub. Its normalized
166
+ messages retain the protocol body; product rendering remains with the host.
167
+ `sendMessage` resolves after local persistence, while `outgoing` in the thread
168
+ snapshot reports transport progress and failure. Retry uses the original message
169
+ hash. The subscribed recent window remains 100 records regardless of individual
170
+ query limits; history requests return their own pages and do not replace it.
171
+ Global snapshots contain thread summaries and totals, not complete message arrays.
172
+ Message pages report `complete`; unavailable older pages return cached records with
173
+ `complete: false`. Authorization failures throw and never fall back to denied
174
+ records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
175
+ it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
176
+ pre-reset payload.
177
+ Voice visibility never acknowledges playback, including observations that arrive
178
+ before metadata. Create a new instance and close the previous owner when the
179
+ account or active Team changes.
180
+
181
+ This facade is still under integration. Reader details currently require network
182
+ access, global summaries do not yet replace the host's contact/thread metadata,
183
+ and Merry's message-list/send migration and production validation are unfinished.
184
+ The lower-level primitives remain available for custom transports:
185
+
140
186
  `MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
141
187
  subscribes per conversation, and accepts incremental event batches. Its transport
142
188
  supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
@@ -157,6 +203,11 @@ legacy cleanup happens after durable migration. Normal reads use memory and writ
157
203
  are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
158
204
  the host, and pending operations are not reported as saved before their promise
159
205
  resolves.
206
+ Control-event batches are bounded by both 128 records and 1 MiB per transaction;
207
+ the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
208
+ `transactionBytes` and `queueBytes` can lower these limits. Oversized operations
209
+ fail before changing the memory snapshot. Repeating an in-flight write waits for
210
+ that physical write, including its failure, rather than acknowledging it early.
160
211
 
161
212
  `DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
162
213
  before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
@@ -165,7 +216,7 @@ when the connection returns. Call `confirm(hashes)` only after authoritative
165
216
  messages have been durably merged. Confirmation tombstones prevent another tab's
166
217
  late transport failure from recreating an outgoing message.
167
218
 
168
- These APIs are runtime primitives; applications must initialize them with the
169
- same authenticated scope, dispose them on identity changes, and wire their
170
- transport to an authorized server. The Merry migration and production validation
219
+ Custom primitive integrations must initialize them with the same authenticated
220
+ scope, dispose them on identity changes, and wire their transport to an authorized
221
+ server. Standard browser integrations should use the facade instead. The Merry migration and production validation
171
222
  are tracked in the unified message layer proposal.
@@ -56,6 +56,13 @@ export declare class ChatClient {
56
56
  private initialize;
57
57
  close(): void;
58
58
  connectionState(): ChatConnectionState;
59
+ /** Non-secret identity used to validate a shared browser transport. */
60
+ identityScope(): {
61
+ appId: string;
62
+ profileId: string | undefined;
63
+ accountId: string | undefined;
64
+ teamId: string | undefined;
65
+ };
59
66
  watchConnection(handler: (state: ChatConnectionState) => void): () => void;
60
67
  private setConnection;
61
68
  private rejectPending;
@@ -120,6 +120,11 @@ export class ChatClient {
120
120
  this.rejectPending(new Error("ChatClient closed"));
121
121
  }
122
122
  connectionState() { return this.connection; }
123
+ /** Non-secret identity used to validate a shared browser transport. */
124
+ identityScope() {
125
+ const identity = this.session?.identity ?? this.session?.session?.identity;
126
+ return { appId: this.options.appId, profileId: this.profileIdValue ?? this.options.profileId, accountId: identity?.accountId, teamId: this.options.teamId };
127
+ }
123
128
  watchConnection(handler) {
124
129
  this.connectionHandlers.add(handler);
125
130
  handler(this.connection);
@@ -423,10 +428,11 @@ export class ChatClient {
423
428
  return profileId;
424
429
  }
425
430
  profileId() {
426
- if (!this.profileIdValue) {
431
+ const profileId = this.profileIdValue ?? this.options.profileId;
432
+ if (!profileId) {
427
433
  throw new Error("ChatClient is missing profile id; call connect() first");
428
434
  }
429
- return this.profileIdValue;
435
+ return profileId;
430
436
  }
431
437
  async fetchProfiles(appId, accountId, token) {
432
438
  const fetcher = this.options.fetchImpl ?? fetch;
@@ -1,14 +1,15 @@
1
1
  import { type MessageReadersQuery } from "./messageReaders.js";
2
- import { type MessageClientOptions } from "./messageClient.js";
2
+ import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
3
3
  import { DurableChatSender } from "./durableSender.js";
4
4
  import { type ReadingClientOptions } from "./readingClient.js";
5
- import { type ChatClientOptions } from "./chatClient.js";
5
+ import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
6
6
  import { unreadInConversation } from "./readIndex.js";
7
7
  import type { StoredMessage } from "./messageStore.js";
8
8
  import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
9
9
  export type ChatSessionOptions<T extends StoredMessage> = {
10
10
  messages: MessageClientOptions<T>;
11
11
  connection: ChatClientOptions;
12
+ sharedConnection?: ChatClient;
12
13
  reading: ReadingClientOptions;
13
14
  decode: (message: ChatRuntimeMessage) => T | undefined;
14
15
  onError: (error: unknown) => void;
@@ -17,8 +18,21 @@ export type ChatThreadSnapshot<T> = {
17
18
  messages: readonly T[];
18
19
  outgoing: ReturnType<DurableChatSender["snapshot"]>;
19
20
  hasEarlier: boolean;
21
+ resetVersion: number;
22
+ complete: boolean;
20
23
  unread: ReturnType<typeof unreadInConversation>;
21
24
  };
25
+ export type ChatSummarySnapshot<T> = {
26
+ threads: readonly {
27
+ threadId: string;
28
+ lastMessage?: T;
29
+ unread: ChatThreadSnapshot<T>["unread"];
30
+ outgoingCount: number;
31
+ }[];
32
+ unreadMessageCount: number;
33
+ badgeCount: number;
34
+ connection: ChatConnectionState;
35
+ };
22
36
  /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
23
37
  export declare class ChatSession<T extends StoredMessage> {
24
38
  private options;
@@ -29,7 +43,14 @@ export declare class ChatSession<T extends StoredMessage> {
29
43
  private sender;
30
44
  private listeners;
31
45
  private snapshots;
46
+ private historyEdges;
47
+ private earlierReads;
32
48
  private off;
49
+ private globalListeners;
50
+ private globalSnapshot?;
51
+ private connectionState;
52
+ private catchUp;
53
+ private readingStarted;
33
54
  private closed;
34
55
  private starting?;
35
56
  private closing?;
@@ -42,7 +63,13 @@ export declare class ChatSession<T extends StoredMessage> {
42
63
  timestamp: number;
43
64
  id: string;
44
65
  };
45
- }): Promise<import("./messageClient.js").MessageSnapshot<T>>;
66
+ earlier?: boolean;
67
+ all?: boolean;
68
+ refresh?: boolean;
69
+ }): Promise<MessageSnapshot<T>>;
70
+ private rememberHistoryEdge;
71
+ private readEarlier;
72
+ private readAll;
46
73
  sendMessage(threadId: string, input: {
47
74
  to: string;
48
75
  body: ChatBody;
@@ -51,9 +78,13 @@ export declare class ChatSession<T extends StoredMessage> {
51
78
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
52
79
  retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
53
80
  observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./readIndex.js").ReadIndexState>;
81
+ getSnapshot(): ChatSummarySnapshot<T>;
54
82
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
83
+ subscribe(listener: () => void): () => void;
55
84
  subscribe(threadId: string, listener: () => void): () => void;
56
85
  private publishAll;
86
+ private buildGlobal;
87
+ private publishGlobal;
57
88
  private publish;
58
89
  close(): Promise<void>;
59
90
  }
@@ -1,3 +1,4 @@
1
+ import { WindowedTasks } from "./coalescer.js";
1
2
  import { MessageReadersClient } from "./messageReaders.js";
2
3
  import { MessageClient } from "./messageClient.js";
3
4
  import { DurableChatSender } from "./durableSender.js";
@@ -14,7 +15,14 @@ export class ChatSession {
14
15
  sender;
15
16
  listeners = new Map();
16
17
  snapshots = new Map();
18
+ historyEdges = new Map();
19
+ earlierReads = new Map();
17
20
  off = [];
21
+ globalListeners = new Set();
22
+ globalSnapshot;
23
+ connectionState = "closed";
24
+ catchUp;
25
+ readingStarted = false;
18
26
  closed = false;
19
27
  starting;
20
28
  closing;
@@ -23,27 +31,41 @@ export class ChatSession {
23
31
  const scope = options.messages.scope;
24
32
  if (options.connection.appId !== scope.appId || options.reading.appId !== scope.appId || options.reading.profileId !== scope.profileId || options.connection.profileId !== scope.profileId || options.connection.teamId !== scope.teamId)
25
33
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
34
+ if (options.sharedConnection) {
35
+ const shared = options.sharedConnection.identityScope();
36
+ if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
37
+ throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
38
+ }
26
39
  this.messages = new MessageClient(options.messages);
27
- this.connection = new ChatClient(options.connection);
28
- this.reading = new ReadingClient(options.reading);
29
- this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
40
+ this.connection = options.sharedConnection ?? new ChatClient(options.connection);
41
+ this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
42
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
30
43
  this.sender = new DurableChatSender(this.connection, scope, options.onError);
44
+ this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
45
+ options.onError(error); });
46
+ this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
31
47
  this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
32
48
  this.off.push(this.connection.watchMessages(message => {
33
49
  try {
34
50
  const decoded = options.decode(message);
35
51
  if (decoded)
36
- void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
52
+ void this.messages.receive(decoded.threadId, [decoded]).then(() => this.sender.confirm([message.hash])).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
53
+ this.messages.invalidate(decoded.threadId);
54
+ else if (!this.closed)
55
+ options.onError(error); });
37
56
  }
38
57
  catch (error) {
39
58
  options.onError(error);
40
59
  }
41
60
  }));
42
61
  this.off.push(this.connection.watchConnection(state => {
62
+ this.connectionState = state;
63
+ this.publishGlobal();
43
64
  if (state === "connected") {
44
- for (const thread of this.listeners.keys())
45
- this.messages.invalidate(thread);
46
- this.reading.refresh();
65
+ const since = Date.now();
66
+ this.catchUp.schedule("scope", () => this.messages.restore(since));
67
+ if (this.readingStarted)
68
+ this.reading.refresh();
47
69
  }
48
70
  }));
49
71
  }
@@ -51,6 +73,7 @@ export class ChatSession {
51
73
  this.assertOpen();
52
74
  return this.starting ??= (async () => {
53
75
  await this.sender.initialize();
76
+ this.readingStarted = true;
54
77
  await this.reading.start();
55
78
  this.assertOpen();
56
79
  await this.connection.connect();
@@ -60,11 +83,91 @@ export class ChatSession {
60
83
  throw new Error("CHAT_SESSION_CLOSED"); }
61
84
  async getMessages(threadId, options = {}) {
62
85
  this.assertOpen();
86
+ if (options.earlier) {
87
+ if (options.before || options.all || options.limit !== undefined || options.refresh)
88
+ throw new Error("INVALID_MESSAGE_QUERY");
89
+ return this.readEarlier(threadId);
90
+ }
91
+ if (options.all && (options.before || options.limit !== undefined))
92
+ throw new Error("INVALID_MESSAGE_QUERY");
93
+ // Explicit authoritative changes (for example history assignment) can invalidate
94
+ // even a recently cached empty window. Keep this on the existing read surface.
95
+ if (options.refresh)
96
+ await this.messages.read(threadId, options.limit ?? 100, true);
97
+ if (options.all) {
98
+ return this.readAll(threadId);
99
+ }
63
100
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
64
- if (!options.before)
65
- this.publish(threadId);
101
+ await this.sender.confirm(page.messages.map(message => message.id));
102
+ const edge = this.historyEdges.get(threadId);
103
+ if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
104
+ this.rememberHistoryEdge(threadId, page);
66
105
  return page;
67
106
  }
107
+ rememberHistoryEdge(threadId, page) {
108
+ const first = page.messages[0];
109
+ this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
110
+ }
111
+ readEarlier(threadId) {
112
+ const running = this.earlierReads.get(threadId);
113
+ if (running)
114
+ return running;
115
+ const task = (async () => {
116
+ const recent = await this.getMessages(threadId);
117
+ if (!recent.complete)
118
+ throw new Error("MESSAGE_WINDOW_INCOMPLETE");
119
+ const edge = this.historyEdges.get(threadId);
120
+ if (!edge.hasEarlier)
121
+ return { ...recent, messages: [], hasEarlier: false };
122
+ if (!edge.before)
123
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
124
+ const page = await this.getMessages(threadId, { before: edge.before });
125
+ this.assertOpen();
126
+ this.rememberHistoryEdge(threadId, page);
127
+ return page;
128
+ })().finally(() => { if (this.earlierReads.get(threadId) === task)
129
+ this.earlierReads.delete(threadId); });
130
+ this.earlierReads.set(threadId, task);
131
+ return task;
132
+ }
133
+ async readAll(threadId) {
134
+ for (let attempt = 0; attempt < 3; attempt++) {
135
+ const pages = [];
136
+ let version = 0;
137
+ try {
138
+ let page = await this.getMessages(threadId);
139
+ version = page.resetVersion;
140
+ pages.push(page.messages);
141
+ if (!page.complete)
142
+ return { ...page, hasEarlier: true };
143
+ let restart = false;
144
+ while (page.hasEarlier) {
145
+ const first = page.messages[0];
146
+ if (!first)
147
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
148
+ page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
149
+ if (page.resetVersion !== version) {
150
+ restart = true;
151
+ break;
152
+ }
153
+ pages.push(page.messages);
154
+ }
155
+ if (restart)
156
+ continue;
157
+ return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
158
+ }
159
+ catch (error) {
160
+ if ([401, 403].includes(error?.status ?? 0))
161
+ throw error;
162
+ if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
163
+ continue;
164
+ if (pages.length)
165
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
166
+ throw error;
167
+ }
168
+ }
169
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
170
+ }
68
171
  sendMessage(threadId, input) {
69
172
  this.assertOpen();
70
173
  return this.sender.enqueue(threadId, input);
@@ -79,34 +182,68 @@ export class ChatSession {
79
182
  return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
183
  }
81
184
  getSnapshot(threadId) {
185
+ if (threadId === undefined) {
186
+ if (!this.globalSnapshot)
187
+ this.globalSnapshot = this.buildGlobal();
188
+ return this.globalSnapshot;
189
+ }
82
190
  let snapshot = this.snapshots.get(threadId);
83
191
  if (!snapshot) {
84
192
  const messages = this.messages.getSnapshot(threadId);
85
193
  const state = this.reading.getSnapshot();
86
- snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
194
+ snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
87
195
  this.snapshots.set(threadId, snapshot);
88
196
  }
89
197
  return snapshot;
90
198
  }
91
- subscribe(threadId, listener) {
199
+ subscribe(target, listener) {
92
200
  this.assertOpen();
201
+ if (typeof target === "function") {
202
+ this.globalListeners.add(target);
203
+ return () => { this.globalListeners.delete(target); };
204
+ }
205
+ const threadId = target;
206
+ const notify = listener;
93
207
  let listeners = this.listeners.get(threadId);
94
208
  if (!listeners) {
95
209
  listeners = new Set();
96
210
  this.listeners.set(threadId, listeners);
97
211
  }
98
- listeners.add(listener);
99
- const off = this.messages.subscribe(threadId, () => this.publish(threadId));
212
+ listeners.add(notify);
213
+ const off = this.messages.subscribe(threadId, () => { });
100
214
  return () => {
101
215
  off();
102
- listeners.delete(listener);
216
+ listeners.delete(notify);
103
217
  if (!listeners.size)
104
218
  this.listeners.delete(threadId);
105
219
  };
106
220
  }
107
- publishAll() { for (const threadId of [...this.snapshots.keys()])
108
- this.publish(threadId); }
109
- publish(threadId) {
221
+ publishAll() {
222
+ for (const threadId of [...this.snapshots.keys()])
223
+ this.publish(threadId, false);
224
+ this.publishGlobal();
225
+ }
226
+ buildGlobal() {
227
+ const ids = new Set(this.snapshots.keys());
228
+ for (const thread of this.reading.getSnapshot()?.threads ?? [])
229
+ if (thread.teamId === this.options.messages.scope.teamId)
230
+ ids.add(thread.conversationId ?? thread.id);
231
+ for (const outgoing of this.sender.snapshot())
232
+ ids.add(outgoing.threadId);
233
+ const threads = [...ids].sort().map(threadId => {
234
+ const snapshot = this.getSnapshot(threadId);
235
+ return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
236
+ });
237
+ return { threads, unreadMessageCount: threads.reduce((sum, t) => sum + t.unread.unreadMessageCount, 0), badgeCount: threads.reduce((sum, t) => sum + t.unread.badgeCount, 0), connection: this.connectionState };
238
+ }
239
+ publishGlobal() {
240
+ const next = this.buildGlobal();
241
+ if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
242
+ return;
243
+ this.globalSnapshot = next;
244
+ this.globalListeners.forEach(listener => listener());
245
+ }
246
+ publish(threadId, notifyGlobal = true) {
110
247
  const previous = this.snapshots.get(threadId);
111
248
  this.snapshots.delete(threadId);
112
249
  const next = this.getSnapshot(threadId);
@@ -115,16 +252,23 @@ export class ChatSession {
115
252
  return;
116
253
  }
117
254
  this.listeners.get(threadId)?.forEach(listener => listener());
255
+ if (notifyGlobal)
256
+ this.publishGlobal();
118
257
  }
119
258
  close() {
120
259
  return this.closing ??= (async () => {
121
260
  this.closed = true;
261
+ this.catchUp.close();
122
262
  this.off.splice(0).forEach(off => off());
123
- this.connection.close();
124
- this.readers.close();
125
- await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
263
+ if (!this.options.sharedConnection)
264
+ this.connection.close();
265
+ await Promise.all([this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
126
266
  this.listeners.clear();
267
+ this.globalListeners.clear();
127
268
  this.snapshots.clear();
269
+ this.historyEdges.clear();
270
+ this.earlierReads.clear();
271
+ this.globalSnapshot = undefined;
128
272
  })();
129
273
  }
130
274
  }
@@ -0,0 +1,24 @@
1
+ import { ChatSession } from "./chatSession.js";
2
+ import type { ChatReaderSocialMessage } from "./readerClient.js";
3
+ import type { ChatClient, ChatClientOptions } from "./chatClient.js";
4
+ export type CachedChatMessage = ChatReaderSocialMessage & {
5
+ id: string;
6
+ timestamp: number;
7
+ };
8
+ export type CreateChatOptions = {
9
+ appId: string;
10
+ accountId: string;
11
+ profileId: string;
12
+ teamId: string;
13
+ token: string;
14
+ endpoint?: string;
15
+ clientInstanceId?: string;
16
+ fetch?: typeof fetch;
17
+ /** Reuse an existing transport for call/presence integrations; its owner closes it. */
18
+ connection?: ChatClient;
19
+ onError: (error: unknown) => void;
20
+ onNotification?: ChatClientOptions["onNotification"];
21
+ onReach?: ChatClientOptions["onReach"];
22
+ };
23
+ /** Standard browser entry: storage, paging, realtime and reading share one identity. */
24
+ export declare function createChat(options: CreateChatOptions): ChatSession<CachedChatMessage>;
@@ -0,0 +1,39 @@
1
+ import { ChatSession } from "./chatSession.js";
2
+ import { SocialReadClient } from "./socialReadClient.js";
3
+ import { chatTeamId } from "./teamScope.js";
4
+ import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
5
+ /** Standard browser entry: storage, paging, realtime and reading share one identity. */
6
+ export function createChat(options) {
7
+ const { appId, accountId, profileId, teamId, token, onError } = options;
8
+ if (!isValidChatTeamId(teamId))
9
+ throw new Error("INVALID_CHAT_TEAM");
10
+ const endpoint = (options.endpoint ?? "https://hub.pmate.chat").replace(/\/+$/, "");
11
+ const http = endpoint.replace(/^ws/, "http"), ws = endpoint.replace(/^http/, "ws");
12
+ const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: options.fetch });
13
+ const page = async (threadId, signal, cursor = {}) => {
14
+ const result = await reader.messagePage({ threadId, teamId, limit: 100, ...cursor }, signal);
15
+ return { ...result, messages: result.messages.map(message => ({ ...message, id: message.messageId, timestamp: message.serverCreatedAt })) };
16
+ };
17
+ return new ChatSession({
18
+ sharedConnection: options.connection,
19
+ messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
20
+ reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
21
+ connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
22
+ session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
23
+ decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
24
+ });
25
+ }
26
+ function decodeIncoming(message, scope) {
27
+ if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
28
+ return;
29
+ const body = message.body;
30
+ if (body.kind !== "message" || body.content?.appId !== scope.appId || chatTeamId(body) !== scope.teamId)
31
+ return;
32
+ if (body.threadKind !== "dm" && body.threadKind !== "group")
33
+ return;
34
+ if (body.threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
35
+ return;
36
+ const threadId = body.threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
37
+ return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind: body.threadKind, messageId: message.hash, id: message.hash,
38
+ timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
39
+ }
@@ -143,10 +143,13 @@ export class DurableChatSender {
143
143
  /** Confirm only after the authoritative message has been durably merged.
144
144
  * A tombstone prevents another tab's late failure from recreating the outbox. */
145
145
  async confirm(hashes) {
146
+ hashes = [...new Set(hashes)];
147
+ if (!hashes.length)
148
+ return;
146
149
  await this.ready;
147
150
  await this.db.transaction("rw", this.outbox, async () => {
148
151
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
149
- const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
152
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
150
153
  if (confirmed.length)
151
154
  await this.outbox.bulkPut(confirmed);
152
155
  });
@@ -28,3 +28,4 @@ export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
+ export * from "./createChat.js";
@@ -28,3 +28,4 @@ export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
+ export * from "./createChat.js";
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
15
15
  export type MessageSnapshot<T> = {
16
16
  messages: readonly T[];
17
17
  hasEarlier: boolean;
18
+ resetVersion: number;
19
+ complete: boolean;
18
20
  };
19
21
  export type MessageClientOptions<T extends StoredMessage> = {
20
22
  scope: MessageScope;
@@ -25,14 +27,19 @@ export type MessageClientOptions<T extends StoredMessage> = {
25
27
  batchSize?: number;
26
28
  writeWindowMs?: number;
27
29
  syncWindowMs?: number;
30
+ transactionBytes?: number;
31
+ queueBytes?: number;
32
+ freshnessMs?: number;
28
33
  };
29
34
  /** Local-first message facade; hosts never select a cache or network source. */
30
35
  export declare class MessageClient<T extends StoredMessage> {
31
36
  private options;
32
37
  private storage;
33
38
  private watches;
39
+ private allListeners;
34
40
  private reads;
35
41
  private syncing;
42
+ private paging;
36
43
  private writes;
37
44
  private activeWrites;
38
45
  private writeTasks;
@@ -43,16 +50,25 @@ export declare class MessageClient<T extends StoredMessage> {
43
50
  private closing;
44
51
  private revoked;
45
52
  private batchSize;
53
+ private transactionBytes;
54
+ private queueBytes;
55
+ private pendingBytes;
56
+ private pendingRecords;
46
57
  constructor(options: MessageClientOptions<T>);
47
58
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
48
59
  subscribe(thread: string, listener: () => void): () => void;
49
- read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
60
+ subscribeAll(listener: (threadId: string) => void): () => void;
61
+ /** Close the connection gap for saved windows with four bounded background lanes. */
62
+ restore(since: number): Promise<void>;
63
+ read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
50
64
  /** Application connection/wake events invalidate, rather than fetching directly. */
51
65
  invalidate(thread: string): void;
52
66
  private reload;
53
67
  /** Events are visible only after their atomic batch commits. */
54
68
  receive(thread: string, messages: T[]): Promise<void>;
55
69
  private persist;
70
+ private messageBytes;
71
+ private release;
56
72
  private flushWrites;
57
73
  private runWrites;
58
74
  synchronize(thread: string): Promise<void>;
@@ -62,7 +78,11 @@ export declare class MessageClient<T extends StoredMessage> {
62
78
  }, limit?: number): Promise<{
63
79
  messages: T[];
64
80
  hasEarlier: boolean;
81
+ resetVersion: number;
82
+ complete: boolean;
65
83
  }>;
84
+ private fillWindow;
85
+ private denyView;
66
86
  revoke(thread: string): Promise<void>;
67
87
  close(): Promise<void>;
68
88
  }