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

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,40 @@ 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, { limit: 50 })
146
+ const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
147
+ const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
148
+ await chat.observeMessages(threadId, visibleIds, "visible")
149
+ // Report only the actual audio `playing` event.
150
+ await chat.observeMessages(threadId, [voiceId], "played")
151
+ await chat.sendMessage(threadId, { to, body, recipients })
152
+ const readers = await chat.getReaders(threadId, messageId)
153
+ off(); offSummary()
154
+ await chat.close()
155
+ ```
156
+
157
+ The facade owns all clients below. Message reads transparently restore IndexedDB
158
+ windows and fetch missing ranges through the authenticated Hub. Its normalized
159
+ messages retain the protocol body; product rendering remains with the host.
160
+ `sendMessage` resolves after local persistence, while `outgoing` in the thread
161
+ snapshot reports transport progress and failure. Retry uses the original message
162
+ hash. The subscribed recent window remains 100 records regardless of individual
163
+ query limits; history requests return their own pages and do not replace it.
164
+ Global snapshots contain thread summaries and totals, not complete message arrays.
165
+ Voice visibility never acknowledges playback, including observations that arrive
166
+ before metadata. Create a new instance and close the previous owner when the
167
+ account or active Team changes.
168
+
169
+ This facade is still under integration. Reader details currently require network
170
+ access, global summaries do not yet replace the host's contact/thread metadata,
171
+ and Merry's message-list/send migration and production validation are unfinished.
172
+ The lower-level primitives remain available for custom transports:
173
+
140
174
  `MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
141
175
  subscribes per conversation, and accepts incremental event batches. Its transport
142
176
  supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
@@ -165,7 +199,7 @@ when the connection returns. Call `confirm(hashes)` only after authoritative
165
199
  messages have been durably merged. Confirmation tombstones prevent another tab's
166
200
  late transport failure from recreating an outgoing message.
167
201
 
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
202
+ Custom primitive integrations must initialize them with the same authenticated
203
+ scope, dispose them on identity changes, and wire their transport to an authorized
204
+ server. Standard browser integrations should use the facade instead. The Merry migration and production validation
171
205
  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;
@@ -2,13 +2,14 @@ import { type MessageReadersQuery } from "./messageReaders.js";
2
2
  import { type MessageClientOptions } 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;
@@ -19,6 +20,17 @@ export type ChatThreadSnapshot<T> = {
19
20
  hasEarlier: boolean;
20
21
  unread: ReturnType<typeof unreadInConversation>;
21
22
  };
23
+ export type ChatSummarySnapshot<T> = {
24
+ threads: readonly {
25
+ threadId: string;
26
+ lastMessage?: T;
27
+ unread: ChatThreadSnapshot<T>["unread"];
28
+ outgoingCount: number;
29
+ }[];
30
+ unreadMessageCount: number;
31
+ badgeCount: number;
32
+ connection: ChatConnectionState;
33
+ };
22
34
  /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
23
35
  export declare class ChatSession<T extends StoredMessage> {
24
36
  private options;
@@ -30,6 +42,9 @@ export declare class ChatSession<T extends StoredMessage> {
30
42
  private listeners;
31
43
  private snapshots;
32
44
  private off;
45
+ private globalListeners;
46
+ private globalSnapshot?;
47
+ private connectionState;
33
48
  private closed;
34
49
  private starting?;
35
50
  private closing?;
@@ -51,9 +66,13 @@ export declare class ChatSession<T extends StoredMessage> {
51
66
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
52
67
  retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
53
68
  observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./readIndex.js").ReadIndexState>;
69
+ getSnapshot(): ChatSummarySnapshot<T>;
54
70
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
71
+ subscribe(listener: () => void): () => void;
55
72
  subscribe(threadId: string, listener: () => void): () => void;
56
73
  private publishAll;
74
+ private buildGlobal;
75
+ private publishGlobal;
57
76
  private publish;
58
77
  close(): Promise<void>;
59
78
  }
@@ -15,6 +15,9 @@ export class ChatSession {
15
15
  listeners = new Map();
16
16
  snapshots = new Map();
17
17
  off = [];
18
+ globalListeners = new Set();
19
+ globalSnapshot;
20
+ connectionState = "closed";
18
21
  closed = false;
19
22
  starting;
20
23
  closing;
@@ -23,9 +26,14 @@ export class ChatSession {
23
26
  const scope = options.messages.scope;
24
27
  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
28
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
29
+ if (options.sharedConnection) {
30
+ const shared = options.sharedConnection.identityScope();
31
+ if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
32
+ throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
33
+ }
26
34
  this.messages = new MessageClient(options.messages);
27
- this.connection = new ChatClient(options.connection);
28
- this.reading = new ReadingClient(options.reading);
35
+ this.connection = options.sharedConnection ?? new ChatClient(options.connection);
36
+ this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
29
37
  this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
30
38
  this.sender = new DurableChatSender(this.connection, scope, options.onError);
31
39
  this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
@@ -40,6 +48,8 @@ export class ChatSession {
40
48
  }
41
49
  }));
42
50
  this.off.push(this.connection.watchConnection(state => {
51
+ this.connectionState = state;
52
+ this.publishGlobal();
43
53
  if (state === "connected") {
44
54
  for (const thread of this.listeners.keys())
45
55
  this.messages.invalidate(thread);
@@ -79,6 +89,11 @@ export class ChatSession {
79
89
  return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
90
  }
81
91
  getSnapshot(threadId) {
92
+ if (threadId === undefined) {
93
+ if (!this.globalSnapshot)
94
+ this.globalSnapshot = this.buildGlobal();
95
+ return this.globalSnapshot;
96
+ }
82
97
  let snapshot = this.snapshots.get(threadId);
83
98
  if (!snapshot) {
84
99
  const messages = this.messages.getSnapshot(threadId);
@@ -88,25 +103,54 @@ export class ChatSession {
88
103
  }
89
104
  return snapshot;
90
105
  }
91
- subscribe(threadId, listener) {
106
+ subscribe(target, listener) {
92
107
  this.assertOpen();
108
+ if (typeof target === "function") {
109
+ this.globalListeners.add(target);
110
+ return () => { this.globalListeners.delete(target); };
111
+ }
112
+ const threadId = target;
113
+ const notify = listener;
93
114
  let listeners = this.listeners.get(threadId);
94
115
  if (!listeners) {
95
116
  listeners = new Set();
96
117
  this.listeners.set(threadId, listeners);
97
118
  }
98
- listeners.add(listener);
119
+ listeners.add(notify);
99
120
  const off = this.messages.subscribe(threadId, () => this.publish(threadId));
100
121
  return () => {
101
122
  off();
102
- listeners.delete(listener);
123
+ listeners.delete(notify);
103
124
  if (!listeners.size)
104
125
  this.listeners.delete(threadId);
105
126
  };
106
127
  }
107
- publishAll() { for (const threadId of [...this.snapshots.keys()])
108
- this.publish(threadId); }
109
- publish(threadId) {
128
+ publishAll() {
129
+ for (const threadId of [...this.snapshots.keys()])
130
+ this.publish(threadId, false);
131
+ this.publishGlobal();
132
+ }
133
+ buildGlobal() {
134
+ const ids = new Set(this.snapshots.keys());
135
+ for (const thread of this.reading.getSnapshot()?.threads ?? [])
136
+ if (thread.teamId === this.options.messages.scope.teamId)
137
+ ids.add(thread.conversationId ?? thread.id);
138
+ for (const outgoing of this.sender.snapshot())
139
+ ids.add(outgoing.threadId);
140
+ const threads = [...ids].sort().map(threadId => {
141
+ const snapshot = this.getSnapshot(threadId);
142
+ return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
143
+ });
144
+ 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 };
145
+ }
146
+ publishGlobal() {
147
+ const next = this.buildGlobal();
148
+ if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
149
+ return;
150
+ this.globalSnapshot = next;
151
+ this.globalListeners.forEach(listener => listener());
152
+ }
153
+ publish(threadId, notifyGlobal = true) {
110
154
  const previous = this.snapshots.get(threadId);
111
155
  this.snapshots.delete(threadId);
112
156
  const next = this.getSnapshot(threadId);
@@ -115,16 +159,21 @@ export class ChatSession {
115
159
  return;
116
160
  }
117
161
  this.listeners.get(threadId)?.forEach(listener => listener());
162
+ if (notifyGlobal)
163
+ this.publishGlobal();
118
164
  }
119
165
  close() {
120
166
  return this.closing ??= (async () => {
121
167
  this.closed = true;
122
168
  this.off.splice(0).forEach(off => off());
123
- this.connection.close();
169
+ if (!this.options.sharedConnection)
170
+ this.connection.close();
124
171
  this.readers.close();
125
172
  await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
126
173
  this.listeners.clear();
174
+ this.globalListeners.clear();
127
175
  this.snapshots.clear();
176
+ this.globalSnapshot = undefined;
128
177
  })();
129
178
  }
130
179
  }
@@ -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
+ }
@@ -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";
@@ -25,6 +25,8 @@ export type MessageClientOptions<T extends StoredMessage> = {
25
25
  batchSize?: number;
26
26
  writeWindowMs?: number;
27
27
  syncWindowMs?: number;
28
+ transactionBytes?: number;
29
+ queueBytes?: number;
28
30
  };
29
31
  /** Local-first message facade; hosts never select a cache or network source. */
30
32
  export declare class MessageClient<T extends StoredMessage> {
@@ -33,6 +35,7 @@ export declare class MessageClient<T extends StoredMessage> {
33
35
  private watches;
34
36
  private reads;
35
37
  private syncing;
38
+ private paging;
36
39
  private writes;
37
40
  private activeWrites;
38
41
  private writeTasks;
@@ -43,6 +46,10 @@ export declare class MessageClient<T extends StoredMessage> {
43
46
  private closing;
44
47
  private revoked;
45
48
  private batchSize;
49
+ private transactionBytes;
50
+ private queueBytes;
51
+ private pendingBytes;
52
+ private pendingRecords;
46
53
  constructor(options: MessageClientOptions<T>);
47
54
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
48
55
  subscribe(thread: string, listener: () => void): () => void;
@@ -53,6 +60,8 @@ export declare class MessageClient<T extends StoredMessage> {
53
60
  /** Events are visible only after their atomic batch commits. */
54
61
  receive(thread: string, messages: T[]): Promise<void>;
55
62
  private persist;
63
+ private messageBytes;
64
+ private release;
56
65
  private flushWrites;
57
66
  private runWrites;
58
67
  synchronize(thread: string): Promise<void>;
@@ -63,6 +72,7 @@ export declare class MessageClient<T extends StoredMessage> {
63
72
  messages: T[];
64
73
  hasEarlier: boolean;
65
74
  }>;
75
+ private fillWindow;
66
76
  revoke(thread: string): Promise<void>;
67
77
  close(): Promise<void>;
68
78
  }
@@ -7,6 +7,7 @@ export class MessageClient {
7
7
  watches = new Map();
8
8
  reads = new Map();
9
9
  syncing = new Map();
10
+ paging = new Map();
10
11
  writes = new Map();
11
12
  activeWrites = new Set();
12
13
  writeTasks;
@@ -17,10 +18,18 @@ export class MessageClient {
17
18
  closing = false;
18
19
  revoked = new Set();
19
20
  batchSize;
21
+ transactionBytes;
22
+ queueBytes;
23
+ pendingBytes = 0;
24
+ pendingRecords = 0;
20
25
  constructor(options) {
21
26
  this.options = options;
22
27
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
23
28
  this.batchSize = options.batchSize ?? 128;
29
+ this.transactionBytes = options.transactionBytes ?? 1_048_576;
30
+ this.queueBytes = options.queueBytes ?? 8_388_608;
31
+ if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
32
+ throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
24
33
  if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
25
34
  throw new Error("INVALID_MESSAGE_BATCH_SIZE");
26
35
  this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
@@ -35,7 +44,7 @@ export class MessageClient {
35
44
  subscribe(thread, listener) {
36
45
  let watch = this.watches.get(thread);
37
46
  if (!watch) {
38
- watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
47
+ watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
39
48
  this.watches.set(thread, watch);
40
49
  }
41
50
  watch.listeners.add(listener);
@@ -55,7 +64,9 @@ export class MessageClient {
55
64
  await this.synchronize(thread);
56
65
  if (this.closed)
57
66
  throw new Error("MESSAGE_CLIENT_CLOSED");
58
- return this.reload(thread, limit);
67
+ const result = await this.fillWindow(thread, limit);
68
+ const snapshot = await this.reload(thread);
69
+ return limit === 100 ? snapshot : result;
59
70
  })().finally(() => { if (this.reads.get(key) === task)
60
71
  this.reads.delete(key); });
61
72
  this.reads.set(key, task);
@@ -66,7 +77,8 @@ export class MessageClient {
66
77
  if (!this.closed && !this.closing && !this.revoked.has(thread))
67
78
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
68
79
  }
69
- async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
80
+ async reload(thread) {
81
+ const limit = 100;
70
82
  const page = await this.storage.read(thread, limit);
71
83
  if (this.closed)
72
84
  throw new Error("MESSAGE_CLIENT_CLOSED");
@@ -81,10 +93,9 @@ export class MessageClient {
81
93
  return old;
82
94
  const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
83
95
  if (!watch) {
84
- watch = { limit, snapshot, listeners: new Set() };
96
+ watch = { snapshot, listeners: new Set() };
85
97
  this.watches.set(thread, watch);
86
98
  }
87
- watch.limit = limit;
88
99
  watch.snapshot = snapshot;
89
100
  watch.listeners.forEach(listener => listener());
90
101
  return snapshot;
@@ -94,9 +105,20 @@ export class MessageClient {
94
105
  if (this.closed || this.closing || this.revoked.has(thread))
95
106
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
96
107
  const queue = this.writes.get(thread) ?? [];
97
- if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
108
+ let bytes = 0;
109
+ try {
110
+ for (const message of messages)
111
+ bytes += this.messageBytes(message);
112
+ messages = structuredClone(messages);
113
+ }
114
+ catch (error) {
115
+ return Promise.reject(error);
116
+ }
117
+ if (messages.length > 1000 || this.pendingRecords + messages.length > 4096 || this.pendingBytes + bytes > this.queueBytes)
98
118
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
99
- const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
119
+ this.pendingBytes += bytes;
120
+ this.pendingRecords += messages.length;
121
+ const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
100
122
  this.writes.set(thread, queue);
101
123
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
102
124
  return promise;
@@ -104,11 +126,24 @@ export class MessageClient {
104
126
  async persist(thread, messages, checkpoint, complete, nextBefore) {
105
127
  let changed = 0;
106
128
  // Coverage advances only after all bounded batches have durably committed.
107
- for (let offset = 0; offset < messages.length; offset += this.batchSize) {
129
+ let batch = [], bytes = 0;
130
+ const commit = async () => {
131
+ if (!batch.length)
132
+ return;
108
133
  if (this.closed || this.revoked.has(thread))
109
134
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
110
- changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
135
+ changed += await this.storage.commit(thread, batch);
136
+ batch = [];
137
+ bytes = 0;
138
+ };
139
+ for (const message of messages) {
140
+ const size = this.messageBytes(message);
141
+ if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
142
+ await commit();
143
+ batch.push(message);
144
+ bytes += size;
111
145
  }
146
+ await commit();
112
147
  if (complete !== undefined || checkpoint !== undefined) {
113
148
  const old = (await this.storage.read(thread, 1)).coverage;
114
149
  if (this.closed || this.revoked.has(thread))
@@ -120,6 +155,18 @@ export class MessageClient {
120
155
  this.channel?.postMessage(thread);
121
156
  }
122
157
  }
158
+ messageBytes(message) {
159
+ const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
160
+ if (bytes > this.transactionBytes)
161
+ throw new Error("MESSAGE_RECORD_TOO_LARGE");
162
+ return bytes;
163
+ }
164
+ release(queue) {
165
+ for (const write of queue) {
166
+ this.pendingBytes -= write.bytes;
167
+ this.pendingRecords -= write.messages.length;
168
+ }
169
+ }
123
170
  flushWrites(thread) {
124
171
  const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
125
172
  this.activeWrites.add(task);
@@ -138,6 +185,9 @@ export class MessageClient {
138
185
  queue.forEach(write => write.reject(error));
139
186
  throw error;
140
187
  }
188
+ finally {
189
+ this.release(queue);
190
+ }
141
191
  }
142
192
  synchronize(thread) {
143
193
  if (this.closed || this.closing || this.revoked.has(thread))
@@ -183,19 +233,33 @@ export class MessageClient {
183
233
  throw new Error("MESSAGE_ACCESS_REVOKED");
184
234
  if (!(await this.storage.read(thread, 1)).coverage?.complete)
185
235
  await this.synchronize(thread);
236
+ return this.fillWindow(thread, limit, before);
237
+ }
238
+ async fillWindow(thread, limit, before) {
186
239
  let page = await this.storage.read(thread, limit, before);
187
240
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
188
- const token = page.coverage.nextBefore;
189
- const remote = await this.options.transport.older(thread, token, this.abort.signal);
190
- if (this.closed || this.revoked.has(thread))
191
- throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
192
- if (remote.resetRequired) {
193
- await this.storage.removeThread(thread);
194
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
241
+ const pending = this.paging.get(thread);
242
+ if (pending) {
243
+ await pending;
244
+ page = await this.storage.read(thread, limit, before);
245
+ continue;
195
246
  }
196
- if (remote.nextBefore === token)
197
- throw new Error("MESSAGE_SYNC_NO_PROGRESS");
198
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
247
+ const token = page.coverage.nextBefore;
248
+ const task = (async () => {
249
+ const remote = await this.options.transport.older(thread, token, this.abort.signal);
250
+ if (this.closed || this.revoked.has(thread))
251
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
252
+ if (remote.resetRequired) {
253
+ await this.storage.removeThread(thread);
254
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
255
+ }
256
+ if (remote.nextBefore === token)
257
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
258
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
259
+ })().finally(() => { if (this.paging.get(thread) === task)
260
+ this.paging.delete(thread); });
261
+ this.paging.set(thread, task);
262
+ await task;
199
263
  page = await this.storage.read(thread, limit, before);
200
264
  }
201
265
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
@@ -204,7 +268,10 @@ export class MessageClient {
204
268
  this.revoked.add(thread);
205
269
  const queue = this.writes.get(thread);
206
270
  this.writes.delete(thread);
207
- queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
271
+ if (queue) {
272
+ queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
273
+ this.release(queue);
274
+ }
208
275
  await this.storage.removeThread(thread);
209
276
  await this.reload(thread);
210
277
  this.channel?.postMessage(thread);
@@ -39,21 +39,33 @@ export class MessageReadersClient {
39
39
  }
40
40
  async request(input) {
41
41
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
42
- const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
43
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
44
- signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(8000)]), redirect: "error", cache: "no-store",
45
- });
46
- if (!response.ok)
47
- throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
48
- const envelope = await response.json(), data = envelope.data;
49
- if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
50
- || !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
51
- || !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
52
- || data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
53
- || typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
54
- || (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
55
- throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
56
- return data;
42
+ const controller = new AbortController();
43
+ const cancel = () => controller.abort(this.lifetime.signal.reason);
44
+ this.lifetime.signal.addEventListener("abort", cancel, { once: true });
45
+ const timeout = setTimeout(() => controller.abort(), 8000);
46
+ try {
47
+ const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
48
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
49
+ signal: controller.signal, redirect: "error", cache: "no-store",
50
+ });
51
+ if (!response.ok)
52
+ throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
53
+ const envelope = await response.json(), data = envelope.data;
54
+ if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
55
+ || !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
56
+ || !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
57
+ || data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
58
+ || typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
59
+ || (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
60
+ throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
61
+ if (this.lifetime.signal.aborted)
62
+ throw new Error("MESSAGE_READERS_CLOSED");
63
+ return data;
64
+ }
65
+ finally {
66
+ clearTimeout(timeout);
67
+ this.lifetime.signal.removeEventListener("abort", cancel);
68
+ }
57
69
  }
58
70
  close() { this.lifetime.abort(); this.pending.clear(); }
59
71
  }
@@ -84,11 +84,13 @@ export interface ReadIndexPersistence {
84
84
  }
85
85
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
86
86
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
87
+ private storageScope?;
87
88
  private database;
88
89
  private profiles;
89
90
  private closed;
90
- constructor(factory?: IDBFactory, name?: string);
91
+ constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
91
92
  private transaction;
93
+ private key;
92
94
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
93
95
  update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
94
96
  close(): Promise<void>;
@@ -166,10 +166,12 @@ export function unreadInThread(state, threadId) {
166
166
  }
167
167
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
168
  export class IndexedDbReadIndex {
169
+ storageScope;
169
170
  database;
170
171
  profiles;
171
172
  closed = false;
172
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
173
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
174
+ this.storageScope = storageScope;
173
175
  if (!factory)
174
176
  throw new Error("READ_STORAGE_UNAVAILABLE");
175
177
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
@@ -202,17 +204,18 @@ export class IndexedDbReadIndex {
202
204
  }
203
205
  throw new Error("READ_STORAGE_UNAVAILABLE");
204
206
  }
207
+ key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
205
208
  async load(appId, profileId) {
206
- return this.transaction("r", () => this.profiles.get([appId, profileId]));
209
+ return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
207
210
  }
208
211
  async update(appId, profileId, transform) {
209
212
  return this.transaction("rw", async () => {
210
- const previous = await this.profiles.get([appId, profileId]);
213
+ const previous = await this.profiles.get(this.key(appId, profileId));
211
214
  const next = transform(previous ?? createReadIndex(appId, profileId));
212
215
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
213
216
  throw new Error("READ_IDENTITY_MISMATCH");
214
217
  if (JSON.stringify(next) !== JSON.stringify(previous))
215
- await this.profiles.put(next, [appId, profileId]);
218
+ await this.profiles.put(next, this.key(appId, profileId));
216
219
  return next;
217
220
  });
218
221
  }
@@ -5,6 +5,7 @@ export type ReadingClientOptions = {
5
5
  sessionToken: string;
6
6
  hubHttpUrl: string;
7
7
  persistence?: ReadIndexPersistence;
8
+ storageScope?: string;
8
9
  fetch?: typeof fetch;
9
10
  onError?: (error: unknown) => void;
10
11
  onRecovered?: () => void;
@@ -19,7 +19,7 @@ export class ReadingClient {
19
19
  generation = 0;
20
20
  constructor(options) {
21
21
  this.options = options;
22
- const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex());
22
+ const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope));
23
23
  this.persistence = new BatchedReadIndex(storage);
24
24
  this.tasks = new WindowedTasks(1000, error => this.failed(error));
25
25
  this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.1",
3
+ "version": "0.12.0-next.3",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",