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

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);
@@ -61,6 +71,7 @@ export class ChatSession {
61
71
  async getMessages(threadId, options = {}) {
62
72
  this.assertOpen();
63
73
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
74
+ await this.sender.confirm(page.messages.map(message => message.id));
64
75
  if (!options.before)
65
76
  this.publish(threadId);
66
77
  return page;
@@ -79,6 +90,11 @@ export class ChatSession {
79
90
  return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
91
  }
81
92
  getSnapshot(threadId) {
93
+ if (threadId === undefined) {
94
+ if (!this.globalSnapshot)
95
+ this.globalSnapshot = this.buildGlobal();
96
+ return this.globalSnapshot;
97
+ }
82
98
  let snapshot = this.snapshots.get(threadId);
83
99
  if (!snapshot) {
84
100
  const messages = this.messages.getSnapshot(threadId);
@@ -88,25 +104,54 @@ export class ChatSession {
88
104
  }
89
105
  return snapshot;
90
106
  }
91
- subscribe(threadId, listener) {
107
+ subscribe(target, listener) {
92
108
  this.assertOpen();
109
+ if (typeof target === "function") {
110
+ this.globalListeners.add(target);
111
+ return () => { this.globalListeners.delete(target); };
112
+ }
113
+ const threadId = target;
114
+ const notify = listener;
93
115
  let listeners = this.listeners.get(threadId);
94
116
  if (!listeners) {
95
117
  listeners = new Set();
96
118
  this.listeners.set(threadId, listeners);
97
119
  }
98
- listeners.add(listener);
120
+ listeners.add(notify);
99
121
  const off = this.messages.subscribe(threadId, () => this.publish(threadId));
100
122
  return () => {
101
123
  off();
102
- listeners.delete(listener);
124
+ listeners.delete(notify);
103
125
  if (!listeners.size)
104
126
  this.listeners.delete(threadId);
105
127
  };
106
128
  }
107
- publishAll() { for (const threadId of [...this.snapshots.keys()])
108
- this.publish(threadId); }
109
- publish(threadId) {
129
+ publishAll() {
130
+ for (const threadId of [...this.snapshots.keys()])
131
+ this.publish(threadId, false);
132
+ this.publishGlobal();
133
+ }
134
+ buildGlobal() {
135
+ const ids = new Set(this.snapshots.keys());
136
+ for (const thread of this.reading.getSnapshot()?.threads ?? [])
137
+ if (thread.teamId === this.options.messages.scope.teamId)
138
+ ids.add(thread.conversationId ?? thread.id);
139
+ for (const outgoing of this.sender.snapshot())
140
+ ids.add(outgoing.threadId);
141
+ const threads = [...ids].sort().map(threadId => {
142
+ const snapshot = this.getSnapshot(threadId);
143
+ return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
144
+ });
145
+ 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 };
146
+ }
147
+ publishGlobal() {
148
+ const next = this.buildGlobal();
149
+ if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
150
+ return;
151
+ this.globalSnapshot = next;
152
+ this.globalListeners.forEach(listener => listener());
153
+ }
154
+ publish(threadId, notifyGlobal = true) {
110
155
  const previous = this.snapshots.get(threadId);
111
156
  this.snapshots.delete(threadId);
112
157
  const next = this.getSnapshot(threadId);
@@ -115,16 +160,21 @@ export class ChatSession {
115
160
  return;
116
161
  }
117
162
  this.listeners.get(threadId)?.forEach(listener => listener());
163
+ if (notifyGlobal)
164
+ this.publishGlobal();
118
165
  }
119
166
  close() {
120
167
  return this.closing ??= (async () => {
121
168
  this.closed = true;
122
169
  this.off.splice(0).forEach(off => off());
123
- this.connection.close();
170
+ if (!this.options.sharedConnection)
171
+ this.connection.close();
124
172
  this.readers.close();
125
173
  await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
126
174
  this.listeners.clear();
175
+ this.globalListeners.clear();
127
176
  this.snapshots.clear();
177
+ this.globalSnapshot = undefined;
128
178
  })();
129
179
  }
130
180
  }
@@ -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";
@@ -25,6 +25,9 @@ export type MessageClientOptions<T extends StoredMessage> = {
25
25
  batchSize?: number;
26
26
  writeWindowMs?: number;
27
27
  syncWindowMs?: number;
28
+ transactionBytes?: number;
29
+ queueBytes?: number;
30
+ freshnessMs?: number;
28
31
  };
29
32
  /** Local-first message facade; hosts never select a cache or network source. */
30
33
  export declare class MessageClient<T extends StoredMessage> {
@@ -44,6 +47,10 @@ export declare class MessageClient<T extends StoredMessage> {
44
47
  private closing;
45
48
  private revoked;
46
49
  private batchSize;
50
+ private transactionBytes;
51
+ private queueBytes;
52
+ private pendingBytes;
53
+ private pendingRecords;
47
54
  constructor(options: MessageClientOptions<T>);
48
55
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
49
56
  subscribe(thread: string, listener: () => void): () => void;
@@ -54,6 +61,8 @@ export declare class MessageClient<T extends StoredMessage> {
54
61
  /** Events are visible only after their atomic batch commits. */
55
62
  receive(thread: string, messages: T[]): Promise<void>;
56
63
  private persist;
64
+ private messageBytes;
65
+ private release;
57
66
  private flushWrites;
58
67
  private runWrites;
59
68
  synchronize(thread: string): Promise<void>;
@@ -18,10 +18,18 @@ export class MessageClient {
18
18
  closing = false;
19
19
  revoked = new Set();
20
20
  batchSize;
21
+ transactionBytes;
22
+ queueBytes;
23
+ pendingBytes = 0;
24
+ pendingRecords = 0;
21
25
  constructor(options) {
22
26
  this.options = options;
23
27
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
24
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");
25
33
  if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
26
34
  throw new Error("INVALID_MESSAGE_BATCH_SIZE");
27
35
  this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
@@ -36,7 +44,7 @@ export class MessageClient {
36
44
  subscribe(thread, listener) {
37
45
  let watch = this.watches.get(thread);
38
46
  if (!watch) {
39
- watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
47
+ watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
40
48
  this.watches.set(thread, watch);
41
49
  }
42
50
  watch.listeners.add(listener);
@@ -56,8 +64,12 @@ export class MessageClient {
56
64
  await this.synchronize(thread);
57
65
  if (this.closed)
58
66
  throw new Error("MESSAGE_CLIENT_CLOSED");
59
- await this.fillWindow(thread, limit);
60
- return this.reload(thread, limit);
67
+ const result = await this.fillWindow(thread, limit);
68
+ const snapshot = await this.reload(thread);
69
+ if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
70
+ && (typeof navigator === "undefined" || navigator.onLine !== false))
71
+ this.invalidate(thread);
72
+ return limit === 100 ? snapshot : result;
61
73
  })().finally(() => { if (this.reads.get(key) === task)
62
74
  this.reads.delete(key); });
63
75
  this.reads.set(key, task);
@@ -68,7 +80,8 @@ export class MessageClient {
68
80
  if (!this.closed && !this.closing && !this.revoked.has(thread))
69
81
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
70
82
  }
71
- async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
83
+ async reload(thread) {
84
+ const limit = 100;
72
85
  const page = await this.storage.read(thread, limit);
73
86
  if (this.closed)
74
87
  throw new Error("MESSAGE_CLIENT_CLOSED");
@@ -83,10 +96,9 @@ export class MessageClient {
83
96
  return old;
84
97
  const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
85
98
  if (!watch) {
86
- watch = { limit, snapshot, listeners: new Set() };
99
+ watch = { snapshot, listeners: new Set() };
87
100
  this.watches.set(thread, watch);
88
101
  }
89
- watch.limit = limit;
90
102
  watch.snapshot = snapshot;
91
103
  watch.listeners.forEach(listener => listener());
92
104
  return snapshot;
@@ -96,32 +108,73 @@ export class MessageClient {
96
108
  if (this.closed || this.closing || this.revoked.has(thread))
97
109
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
98
110
  const queue = this.writes.get(thread) ?? [];
99
- if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
111
+ if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
100
112
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
101
- const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
113
+ let bytes = 0;
114
+ try {
115
+ for (const message of messages) {
116
+ bytes += this.messageBytes(message);
117
+ if (this.pendingBytes + bytes > this.queueBytes)
118
+ throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
119
+ }
120
+ messages = structuredClone(messages);
121
+ }
122
+ catch (error) {
123
+ return Promise.reject(error);
124
+ }
125
+ this.pendingBytes += bytes;
126
+ this.pendingRecords += messages.length;
127
+ const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
102
128
  this.writes.set(thread, queue);
103
129
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
104
130
  return promise;
105
131
  }
106
- async persist(thread, messages, checkpoint, complete, nextBefore) {
132
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt) {
107
133
  let changed = 0;
134
+ let navigationChanged = false;
108
135
  // Coverage advances only after all bounded batches have durably committed.
109
- for (let offset = 0; offset < messages.length; offset += this.batchSize) {
136
+ let batch = [], bytes = 0;
137
+ const commit = async () => {
138
+ if (!batch.length)
139
+ return;
110
140
  if (this.closed || this.revoked.has(thread))
111
141
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
112
- changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
142
+ changed += await this.storage.commit(thread, batch);
143
+ batch = [];
144
+ bytes = 0;
145
+ };
146
+ for (const message of messages) {
147
+ const size = this.messageBytes(message);
148
+ if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
149
+ await commit();
150
+ batch.push(message);
151
+ bytes += size;
113
152
  }
153
+ await commit();
114
154
  if (complete !== undefined || checkpoint !== undefined) {
115
155
  const old = (await this.storage.read(thread, 1)).coverage;
116
156
  if (this.closed || this.revoked.has(thread))
117
157
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
118
- await this.storage.commit(thread, [], { complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
158
+ navigationChanged = nextBefore !== undefined && nextBefore !== old?.nextBefore;
159
+ await this.storage.commit(thread, [], { syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
119
160
  }
120
- if (changed) {
161
+ if (changed || navigationChanged) {
121
162
  await this.reload(thread);
122
163
  this.channel?.postMessage(thread);
123
164
  }
124
165
  }
166
+ messageBytes(message) {
167
+ const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
168
+ if (bytes > this.transactionBytes)
169
+ throw new Error("MESSAGE_RECORD_TOO_LARGE");
170
+ return bytes;
171
+ }
172
+ release(queue) {
173
+ for (const write of queue) {
174
+ this.pendingBytes -= write.bytes;
175
+ this.pendingRecords -= write.messages.length;
176
+ }
177
+ }
125
178
  flushWrites(thread) {
126
179
  const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
127
180
  this.activeWrites.add(task);
@@ -140,6 +193,9 @@ export class MessageClient {
140
193
  queue.forEach(write => write.reject(error));
141
194
  throw error;
142
195
  }
196
+ finally {
197
+ this.release(queue);
198
+ }
143
199
  }
144
200
  synchronize(thread) {
145
201
  if (this.closed || this.closing || this.revoked.has(thread))
@@ -165,7 +221,7 @@ export class MessageClient {
165
221
  throw new Error("MESSAGE_ACCESS_REVOKED");
166
222
  if (page.resetRequired)
167
223
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
168
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
224
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now());
169
225
  if (!page.hasMoreChanges)
170
226
  break;
171
227
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -220,7 +276,10 @@ export class MessageClient {
220
276
  this.revoked.add(thread);
221
277
  const queue = this.writes.get(thread);
222
278
  this.writes.delete(thread);
223
- queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
279
+ if (queue) {
280
+ queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
281
+ this.release(queue);
282
+ }
224
283
  await this.storage.removeThread(thread);
225
284
  await this.reload(thread);
226
285
  this.channel?.postMessage(thread);
@@ -15,6 +15,7 @@ export type MessageCoverage = {
15
15
  checkpoint?: string;
16
16
  complete: boolean;
17
17
  nextBefore?: string | null;
18
+ syncedAt?: number;
18
19
  };
19
20
  type CoverageRow = MessageCoverage & {
20
21
  scope: string;
@@ -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.2",
3
+ "version": "0.12.0-next.4",
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",