@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 +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 +58 -9
- package/lib/runtime/createChat.d.ts +24 -0
- package/lib/runtime/createChat.js +39 -0
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/messageClient.d.ts +10 -0
- package/lib/runtime/messageClient.js +87 -20
- package/lib/runtime/messageReaders.js +27 -15
- 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);
|
|
@@ -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(
|
|
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(
|
|
119
|
+
listeners.add(notify);
|
|
99
120
|
const off = this.messages.subscribe(threadId, () => this.publish(threadId));
|
|
100
121
|
return () => {
|
|
101
122
|
off();
|
|
102
|
-
listeners.delete(
|
|
123
|
+
listeners.delete(notify);
|
|
103
124
|
if (!listeners.size)
|
|
104
125
|
this.listeners.delete(threadId);
|
|
105
126
|
};
|
|
106
127
|
}
|
|
107
|
-
publishAll() {
|
|
108
|
-
this.
|
|
109
|
-
|
|
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.
|
|
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
|
+
}
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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>;
|
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