@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 +37 -3
- package/lib/runtime/chatClient.d.ts +7 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +20 -1
- package/lib/runtime/chatSession.js +59 -9
- package/lib/runtime/createChat.d.ts +24 -0
- package/lib/runtime/createChat.js +39 -0
- package/lib/runtime/durableSender.js +4 -1
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/messageClient.d.ts +9 -0
- package/lib/runtime/messageClient.js +74 -15
- package/lib/runtime/messageStore.d.ts +1 -0
- package/lib/runtime/readIndex.d.ts +3 -1
- package/lib/runtime/readIndex.js +7 -4
- package/lib/runtime/readingClient.d.ts +1 -0
- package/lib/runtime/readingClient.js +1 -1
- package/package.json +1 -1
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
120
|
+
listeners.add(notify);
|
|
99
121
|
const off = this.messages.subscribe(threadId, () => this.publish(threadId));
|
|
100
122
|
return () => {
|
|
101
123
|
off();
|
|
102
|
-
listeners.delete(
|
|
124
|
+
listeners.delete(notify);
|
|
103
125
|
if (!listeners.size)
|
|
104
126
|
this.listeners.delete(threadId);
|
|
105
127
|
};
|
|
106
128
|
}
|
|
107
|
-
publishAll() {
|
|
108
|
-
this.
|
|
109
|
-
|
|
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.
|
|
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
|
});
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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 ||
|
|
111
|
+
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
100
112
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
101
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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>;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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,
|
|
218
|
+
await this.profiles.put(next, this.key(appId, profileId));
|
|
216
219
|
return next;
|
|
217
220
|
});
|
|
218
221
|
}
|
|
@@ -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