@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.6
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 +10 -0
- package/lib/runtime/chatSession.d.ts +9 -2
- package/lib/runtime/chatSession.js +65 -8
- package/lib/runtime/messageClient.d.ts +10 -1
- package/lib/runtime/messageClient.js +172 -38
- package/lib/runtime/messageStore.d.ts +10 -1
- package/lib/runtime/messageStore.js +21 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,6 +143,11 @@ The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
|
|
|
143
143
|
const chat = createChat({ appId, accountId, profileId, teamId, token, onError })
|
|
144
144
|
await chat.start()
|
|
145
145
|
const page = await chat.getMessages(threadId, { limit: 50 })
|
|
146
|
+
// After an authoritative mutation (e.g. history assignment), await reconciliation.
|
|
147
|
+
// Concurrent refreshes share transport; errors do not masquerade as fresh cache.
|
|
148
|
+
const reconciled = await chat.getMessages(threadId, { refresh: true })
|
|
149
|
+
// Explicit full-history queries reuse cached pages and internally rebase expired cursors.
|
|
150
|
+
const history = await chat.getMessages(threadId, { all: true })
|
|
146
151
|
const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
|
|
147
152
|
const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
148
153
|
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
@@ -162,6 +167,11 @@ snapshot reports transport progress and failure. Retry uses the original message
|
|
|
162
167
|
hash. The subscribed recent window remains 100 records regardless of individual
|
|
163
168
|
query limits; history requests return their own pages and do not replace it.
|
|
164
169
|
Global snapshots contain thread summaries and totals, not complete message arrays.
|
|
170
|
+
Message pages report `complete`; unavailable older pages return cached records with
|
|
171
|
+
`complete: false`. Authorization failures throw and never fall back to denied
|
|
172
|
+
records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
|
|
173
|
+
it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
|
|
174
|
+
pre-reset payload.
|
|
165
175
|
Voice visibility never acknowledges playback, including observations that arrive
|
|
166
176
|
before metadata. Create a new instance and close the previous owner when the
|
|
167
177
|
account or active Team changes.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type MessageReadersQuery } from "./messageReaders.js";
|
|
2
|
-
import { type MessageClientOptions } from "./messageClient.js";
|
|
2
|
+
import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
|
|
3
3
|
import { DurableChatSender } from "./durableSender.js";
|
|
4
4
|
import { type ReadingClientOptions } from "./readingClient.js";
|
|
5
5
|
import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
|
|
@@ -18,6 +18,8 @@ export type ChatThreadSnapshot<T> = {
|
|
|
18
18
|
messages: readonly T[];
|
|
19
19
|
outgoing: ReturnType<DurableChatSender["snapshot"]>;
|
|
20
20
|
hasEarlier: boolean;
|
|
21
|
+
resetVersion: number;
|
|
22
|
+
complete: boolean;
|
|
21
23
|
unread: ReturnType<typeof unreadInConversation>;
|
|
22
24
|
};
|
|
23
25
|
export type ChatSummarySnapshot<T> = {
|
|
@@ -45,6 +47,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
45
47
|
private globalListeners;
|
|
46
48
|
private globalSnapshot?;
|
|
47
49
|
private connectionState;
|
|
50
|
+
private catchUp;
|
|
51
|
+
private readingStarted;
|
|
48
52
|
private closed;
|
|
49
53
|
private starting?;
|
|
50
54
|
private closing?;
|
|
@@ -57,7 +61,10 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
57
61
|
timestamp: number;
|
|
58
62
|
id: string;
|
|
59
63
|
};
|
|
60
|
-
|
|
64
|
+
all?: boolean;
|
|
65
|
+
refresh?: boolean;
|
|
66
|
+
}): Promise<MessageSnapshot<T>>;
|
|
67
|
+
private readAll;
|
|
61
68
|
sendMessage(threadId: string, input: {
|
|
62
69
|
to: string;
|
|
63
70
|
body: ChatBody;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
1
2
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
2
3
|
import { MessageClient } from "./messageClient.js";
|
|
3
4
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -18,6 +19,8 @@ export class ChatSession {
|
|
|
18
19
|
globalListeners = new Set();
|
|
19
20
|
globalSnapshot;
|
|
20
21
|
connectionState = "closed";
|
|
22
|
+
catchUp;
|
|
23
|
+
readingStarted = false;
|
|
21
24
|
closed = false;
|
|
22
25
|
starting;
|
|
23
26
|
closing;
|
|
@@ -36,12 +39,18 @@ export class ChatSession {
|
|
|
36
39
|
this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
|
|
37
40
|
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
38
41
|
this.sender = new DurableChatSender(this.connection, scope, options.onError);
|
|
42
|
+
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
43
|
+
options.onError(error); });
|
|
44
|
+
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
39
45
|
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
|
|
40
46
|
this.off.push(this.connection.watchMessages(message => {
|
|
41
47
|
try {
|
|
42
48
|
const decoded = options.decode(message);
|
|
43
49
|
if (decoded)
|
|
44
|
-
void this.messages.receive(decoded.threadId, [decoded]).then(() =>
|
|
50
|
+
void this.messages.receive(decoded.threadId, [decoded]).then(() => this.sender.confirm([message.hash])).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
51
|
+
this.messages.invalidate(decoded.threadId);
|
|
52
|
+
else if (!this.closed)
|
|
53
|
+
options.onError(error); });
|
|
45
54
|
}
|
|
46
55
|
catch (error) {
|
|
47
56
|
options.onError(error);
|
|
@@ -51,9 +60,10 @@ export class ChatSession {
|
|
|
51
60
|
this.connectionState = state;
|
|
52
61
|
this.publishGlobal();
|
|
53
62
|
if (state === "connected") {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
63
|
+
const since = Date.now();
|
|
64
|
+
this.catchUp.schedule("scope", () => this.messages.restore(since));
|
|
65
|
+
if (this.readingStarted)
|
|
66
|
+
this.reading.refresh();
|
|
57
67
|
}
|
|
58
68
|
}));
|
|
59
69
|
}
|
|
@@ -61,6 +71,7 @@ export class ChatSession {
|
|
|
61
71
|
this.assertOpen();
|
|
62
72
|
return this.starting ??= (async () => {
|
|
63
73
|
await this.sender.initialize();
|
|
74
|
+
this.readingStarted = true;
|
|
64
75
|
await this.reading.start();
|
|
65
76
|
this.assertOpen();
|
|
66
77
|
await this.connection.connect();
|
|
@@ -70,12 +81,57 @@ export class ChatSession {
|
|
|
70
81
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
71
82
|
async getMessages(threadId, options = {}) {
|
|
72
83
|
this.assertOpen();
|
|
84
|
+
if (options.all && (options.before || options.limit !== undefined))
|
|
85
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
86
|
+
// Explicit authoritative changes (for example history assignment) can invalidate
|
|
87
|
+
// even a recently cached empty window. Keep this on the existing read surface.
|
|
88
|
+
if (options.refresh)
|
|
89
|
+
await this.messages.read(threadId, options.limit ?? 100, true);
|
|
90
|
+
if (options.all) {
|
|
91
|
+
return this.readAll(threadId);
|
|
92
|
+
}
|
|
73
93
|
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
74
94
|
await this.sender.confirm(page.messages.map(message => message.id));
|
|
75
|
-
if (!options.before)
|
|
76
|
-
this.publish(threadId);
|
|
77
95
|
return page;
|
|
78
96
|
}
|
|
97
|
+
async readAll(threadId) {
|
|
98
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
99
|
+
const pages = [];
|
|
100
|
+
let version = 0;
|
|
101
|
+
try {
|
|
102
|
+
let page = await this.getMessages(threadId);
|
|
103
|
+
version = page.resetVersion;
|
|
104
|
+
pages.push(page.messages);
|
|
105
|
+
if (!page.complete)
|
|
106
|
+
return { ...page, hasEarlier: true };
|
|
107
|
+
let restart = false;
|
|
108
|
+
while (page.hasEarlier) {
|
|
109
|
+
const first = page.messages[0];
|
|
110
|
+
if (!first)
|
|
111
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
112
|
+
page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
|
|
113
|
+
if (page.resetVersion !== version) {
|
|
114
|
+
restart = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
pages.push(page.messages);
|
|
118
|
+
}
|
|
119
|
+
if (restart)
|
|
120
|
+
continue;
|
|
121
|
+
return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
125
|
+
throw error;
|
|
126
|
+
if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
127
|
+
continue;
|
|
128
|
+
if (pages.length)
|
|
129
|
+
return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
134
|
+
}
|
|
79
135
|
sendMessage(threadId, input) {
|
|
80
136
|
this.assertOpen();
|
|
81
137
|
return this.sender.enqueue(threadId, input);
|
|
@@ -99,7 +155,7 @@ export class ChatSession {
|
|
|
99
155
|
if (!snapshot) {
|
|
100
156
|
const messages = this.messages.getSnapshot(threadId);
|
|
101
157
|
const state = this.reading.getSnapshot();
|
|
102
|
-
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
|
|
158
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
|
|
103
159
|
this.snapshots.set(threadId, snapshot);
|
|
104
160
|
}
|
|
105
161
|
return snapshot;
|
|
@@ -118,7 +174,7 @@ export class ChatSession {
|
|
|
118
174
|
this.listeners.set(threadId, listeners);
|
|
119
175
|
}
|
|
120
176
|
listeners.add(notify);
|
|
121
|
-
const off = this.messages.subscribe(threadId, () =>
|
|
177
|
+
const off = this.messages.subscribe(threadId, () => { });
|
|
122
178
|
return () => {
|
|
123
179
|
off();
|
|
124
180
|
listeners.delete(notify);
|
|
@@ -166,6 +222,7 @@ export class ChatSession {
|
|
|
166
222
|
close() {
|
|
167
223
|
return this.closing ??= (async () => {
|
|
168
224
|
this.closed = true;
|
|
225
|
+
this.catchUp.close();
|
|
169
226
|
this.off.splice(0).forEach(off => off());
|
|
170
227
|
if (!this.options.sharedConnection)
|
|
171
228
|
this.connection.close();
|
|
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
|
|
|
15
15
|
export type MessageSnapshot<T> = {
|
|
16
16
|
messages: readonly T[];
|
|
17
17
|
hasEarlier: boolean;
|
|
18
|
+
resetVersion: number;
|
|
19
|
+
complete: boolean;
|
|
18
20
|
};
|
|
19
21
|
export type MessageClientOptions<T extends StoredMessage> = {
|
|
20
22
|
scope: MessageScope;
|
|
@@ -34,6 +36,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
34
36
|
private options;
|
|
35
37
|
private storage;
|
|
36
38
|
private watches;
|
|
39
|
+
private allListeners;
|
|
37
40
|
private reads;
|
|
38
41
|
private syncing;
|
|
39
42
|
private paging;
|
|
@@ -54,7 +57,10 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
54
57
|
constructor(options: MessageClientOptions<T>);
|
|
55
58
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
56
59
|
subscribe(thread: string, listener: () => void): () => void;
|
|
57
|
-
|
|
60
|
+
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
61
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
62
|
+
restore(since: number): Promise<void>;
|
|
63
|
+
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
58
64
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
59
65
|
invalidate(thread: string): void;
|
|
60
66
|
private reload;
|
|
@@ -72,8 +78,11 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
72
78
|
}, limit?: number): Promise<{
|
|
73
79
|
messages: T[];
|
|
74
80
|
hasEarlier: boolean;
|
|
81
|
+
resetVersion: number;
|
|
82
|
+
complete: boolean;
|
|
75
83
|
}>;
|
|
76
84
|
private fillWindow;
|
|
85
|
+
private denyView;
|
|
77
86
|
revoke(thread: string): Promise<void>;
|
|
78
87
|
close(): Promise<void>;
|
|
79
88
|
}
|
|
@@ -5,6 +5,7 @@ export class MessageClient {
|
|
|
5
5
|
options;
|
|
6
6
|
storage;
|
|
7
7
|
watches = new Map();
|
|
8
|
+
allListeners = new Set();
|
|
8
9
|
reads = new Map();
|
|
9
10
|
syncing = new Map();
|
|
10
11
|
paging = new Map();
|
|
@@ -32,39 +33,102 @@ export class MessageClient {
|
|
|
32
33
|
throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
|
|
33
34
|
if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
|
|
34
35
|
throw new Error("INVALID_MESSAGE_BATCH_SIZE");
|
|
35
|
-
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) =>
|
|
36
|
+
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
37
|
+
this.invalidate(thread);
|
|
38
|
+
else
|
|
39
|
+
options.onError?.(error, thread); });
|
|
36
40
|
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
|
|
37
41
|
if (typeof BroadcastChannel !== "undefined") {
|
|
38
|
-
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
|
|
39
|
-
this.channel.onmessage = event => {
|
|
40
|
-
|
|
42
|
+
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
|
|
43
|
+
this.channel.onmessage = event => {
|
|
44
|
+
if (this.closed)
|
|
45
|
+
return;
|
|
46
|
+
if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
|
|
47
|
+
const denied = event.data;
|
|
48
|
+
void (async () => {
|
|
49
|
+
const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
|
|
50
|
+
if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
|
|
51
|
+
return;
|
|
52
|
+
this.denyView(denied.thread, denied.version);
|
|
53
|
+
options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
|
|
54
|
+
})();
|
|
55
|
+
}
|
|
56
|
+
else if (typeof event.data === "string" && this.watches.has(event.data))
|
|
57
|
+
void this.reload(event.data).catch(error => { if (!this.closed)
|
|
58
|
+
options.onError?.(error, event.data); });
|
|
59
|
+
};
|
|
41
60
|
}
|
|
42
61
|
}
|
|
43
62
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
44
63
|
subscribe(thread, listener) {
|
|
45
64
|
let watch = this.watches.get(thread);
|
|
46
65
|
if (!watch) {
|
|
47
|
-
watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
|
|
66
|
+
watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
|
|
48
67
|
this.watches.set(thread, watch);
|
|
49
68
|
}
|
|
50
69
|
watch.listeners.add(listener);
|
|
51
70
|
return () => { watch.listeners.delete(listener); };
|
|
52
71
|
}
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
73
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
74
|
+
async restore(since) {
|
|
75
|
+
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked);
|
|
76
|
+
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
77
|
+
while (queue.length && !this.closed && !this.closing) {
|
|
78
|
+
const window = queue.shift();
|
|
79
|
+
try {
|
|
80
|
+
await this.synchronize(window.thread);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!this.closed && !this.closing)
|
|
84
|
+
this.options.onError?.(error, window.thread);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
read(thread, limit = 100, refresh = false) {
|
|
90
|
+
if (this.closed || this.closing)
|
|
55
91
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
56
92
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
57
93
|
return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
|
|
58
|
-
const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
|
|
94
|
+
const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
|
|
59
95
|
if (running)
|
|
60
96
|
return running;
|
|
61
97
|
const task = (async () => {
|
|
62
98
|
const local = await this.storage.read(thread, limit);
|
|
63
|
-
if (!local.coverage?.complete)
|
|
64
|
-
|
|
99
|
+
if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
100
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
101
|
+
if (!refresh && local.messages.length && !local.coverage?.revoked)
|
|
102
|
+
return this.reload(thread);
|
|
103
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
104
|
+
}
|
|
105
|
+
this.revoked.delete(thread);
|
|
106
|
+
try {
|
|
107
|
+
await this.synchronize(thread);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
|
|
111
|
+
throw error;
|
|
112
|
+
this.options.onError?.(error, thread);
|
|
113
|
+
return this.reload(thread);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
65
116
|
if (this.closed)
|
|
66
117
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
67
|
-
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await this.fillWindow(thread, limit);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
124
|
+
throw error;
|
|
125
|
+
const cached = await this.storage.read(thread, limit);
|
|
126
|
+
if (!cached.messages.length)
|
|
127
|
+
throw error;
|
|
128
|
+
this.options.onError?.(error, thread);
|
|
129
|
+
await this.reload(thread);
|
|
130
|
+
return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
|
|
131
|
+
}
|
|
68
132
|
const snapshot = await this.reload(thread);
|
|
69
133
|
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
70
134
|
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
@@ -85,6 +149,10 @@ export class MessageClient {
|
|
|
85
149
|
const page = await this.storage.read(thread, limit);
|
|
86
150
|
if (this.closed)
|
|
87
151
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
152
|
+
if (this.revoked.has(thread)) {
|
|
153
|
+
page.messages = [];
|
|
154
|
+
page.hasEarlier = false;
|
|
155
|
+
}
|
|
88
156
|
let watch = this.watches.get(thread);
|
|
89
157
|
const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
|
|
90
158
|
const messages = page.messages.map(message => {
|
|
@@ -92,21 +160,26 @@ export class MessageClient {
|
|
|
92
160
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
93
161
|
});
|
|
94
162
|
const old = watch?.snapshot;
|
|
95
|
-
|
|
163
|
+
const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !this.revoked.has(thread) && (page.coverage?.complete ?? false);
|
|
164
|
+
const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
|
|
165
|
+
if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
96
166
|
return old;
|
|
97
|
-
const snapshot = { messages,
|
|
167
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier };
|
|
98
168
|
if (!watch) {
|
|
99
169
|
watch = { snapshot, listeners: new Set() };
|
|
100
170
|
this.watches.set(thread, watch);
|
|
101
171
|
}
|
|
102
172
|
watch.snapshot = snapshot;
|
|
103
173
|
watch.listeners.forEach(listener => listener());
|
|
174
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
104
175
|
return snapshot;
|
|
105
176
|
}
|
|
106
177
|
/** Events are visible only after their atomic batch commits. */
|
|
107
178
|
receive(thread, messages) {
|
|
108
|
-
if (this.closed || this.closing
|
|
179
|
+
if (this.closed || this.closing)
|
|
109
180
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
181
|
+
if (this.revoked.has(thread))
|
|
182
|
+
return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
|
|
110
183
|
const queue = this.writes.get(thread) ?? [];
|
|
111
184
|
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
112
185
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
@@ -124,12 +197,15 @@ export class MessageClient {
|
|
|
124
197
|
}
|
|
125
198
|
this.pendingBytes += bytes;
|
|
126
199
|
this.pendingRecords += messages.length;
|
|
127
|
-
const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
|
|
200
|
+
const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
|
|
128
201
|
this.writes.set(thread, queue);
|
|
129
202
|
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
130
203
|
return promise;
|
|
131
204
|
}
|
|
132
|
-
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt) {
|
|
205
|
+
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
|
|
206
|
+
const initial = (await this.storage.read(thread, 1)).coverage;
|
|
207
|
+
const version = expectedVersion ?? initial?.resetVersion ?? 0;
|
|
208
|
+
const guard = { version, authoritative: complete === true };
|
|
133
209
|
let changed = 0;
|
|
134
210
|
let navigationChanged = false;
|
|
135
211
|
// Coverage advances only after all bounded batches have durably committed.
|
|
@@ -139,7 +215,7 @@ export class MessageClient {
|
|
|
139
215
|
return;
|
|
140
216
|
if (this.closed || this.revoked.has(thread))
|
|
141
217
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
142
|
-
changed += await this.storage.commit(thread, batch);
|
|
218
|
+
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
143
219
|
batch = [];
|
|
144
220
|
bytes = 0;
|
|
145
221
|
};
|
|
@@ -155,8 +231,8 @@ export class MessageClient {
|
|
|
155
231
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
156
232
|
if (this.closed || this.revoked.has(thread))
|
|
157
233
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
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 });
|
|
234
|
+
navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
|
|
235
|
+
await this.storage.commit(thread, [], { resetVersion: version, revoked: complete === true ? false : old?.revoked, syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore }, guard);
|
|
160
236
|
}
|
|
161
237
|
if (changed || navigationChanged) {
|
|
162
238
|
await this.reload(thread);
|
|
@@ -186,12 +262,26 @@ export class MessageClient {
|
|
|
186
262
|
return;
|
|
187
263
|
this.writes.delete(thread);
|
|
188
264
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
265
|
+
const groups = new Map();
|
|
266
|
+
for (const write of queue) {
|
|
267
|
+
const group = groups.get(write.version) ?? [];
|
|
268
|
+
group.push(write);
|
|
269
|
+
groups.set(write.version, group);
|
|
270
|
+
}
|
|
271
|
+
let failed = false, failure;
|
|
272
|
+
for (const [version, writes] of groups) {
|
|
273
|
+
try {
|
|
274
|
+
await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
|
|
275
|
+
writes.forEach(write => write.resolve());
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
failed = true;
|
|
279
|
+
failure = error;
|
|
280
|
+
writes.forEach(write => write.reject(error));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (failed)
|
|
284
|
+
throw failure;
|
|
195
285
|
}
|
|
196
286
|
finally {
|
|
197
287
|
this.release(queue);
|
|
@@ -205,6 +295,7 @@ export class MessageClient {
|
|
|
205
295
|
return running;
|
|
206
296
|
const task = (async () => {
|
|
207
297
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
298
|
+
let version = coverage?.resetVersion ?? 0;
|
|
208
299
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
209
300
|
let page = after && this.options.transport.changes
|
|
210
301
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
@@ -212,7 +303,7 @@ export class MessageClient {
|
|
|
212
303
|
if (page.resetRequired) {
|
|
213
304
|
if (this.closed || this.revoked.has(thread))
|
|
214
305
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
215
|
-
await this.storage.
|
|
306
|
+
version = await this.storage.resetThread(thread);
|
|
216
307
|
after = undefined;
|
|
217
308
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
218
309
|
}
|
|
@@ -221,7 +312,7 @@ export class MessageClient {
|
|
|
221
312
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
222
313
|
if (page.resetRequired)
|
|
223
314
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
224
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now());
|
|
315
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
|
|
225
316
|
if (!page.hasMoreChanges)
|
|
226
317
|
break;
|
|
227
318
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -229,7 +320,17 @@ export class MessageClient {
|
|
|
229
320
|
after = page.checkpoint;
|
|
230
321
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
231
322
|
}
|
|
232
|
-
})().
|
|
323
|
+
})().catch(async (error) => {
|
|
324
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
325
|
+
try {
|
|
326
|
+
await this.revoke(thread);
|
|
327
|
+
}
|
|
328
|
+
catch (cacheError) {
|
|
329
|
+
Object.assign(error, { cacheError });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}).finally(() => { if (this.syncing.get(thread) === task)
|
|
233
334
|
this.syncing.delete(thread); });
|
|
234
335
|
this.syncing.set(thread, task);
|
|
235
336
|
return task;
|
|
@@ -245,6 +346,7 @@ export class MessageClient {
|
|
|
245
346
|
}
|
|
246
347
|
async fillWindow(thread, limit, before) {
|
|
247
348
|
let page = await this.storage.read(thread, limit, before);
|
|
349
|
+
let resets = 0;
|
|
248
350
|
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
249
351
|
const pending = this.paging.get(thread);
|
|
250
352
|
if (pending) {
|
|
@@ -252,37 +354,68 @@ export class MessageClient {
|
|
|
252
354
|
page = await this.storage.read(thread, limit, before);
|
|
253
355
|
continue;
|
|
254
356
|
}
|
|
255
|
-
const token = page.coverage.nextBefore;
|
|
357
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
|
|
256
358
|
const task = (async () => {
|
|
257
359
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
258
360
|
if (this.closed || this.revoked.has(thread))
|
|
259
361
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
260
362
|
if (remote.resetRequired) {
|
|
261
|
-
|
|
262
|
-
|
|
363
|
+
if (++resets > 2)
|
|
364
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
365
|
+
await this.storage.resetThread(thread);
|
|
366
|
+
await this.synchronize(thread);
|
|
367
|
+
return;
|
|
263
368
|
}
|
|
264
369
|
if (remote.nextBefore === token)
|
|
265
370
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
266
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
267
|
-
})().
|
|
371
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
|
|
372
|
+
})().catch(async (error) => {
|
|
373
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
374
|
+
try {
|
|
375
|
+
await this.revoke(thread);
|
|
376
|
+
}
|
|
377
|
+
catch (cacheError) {
|
|
378
|
+
Object.assign(error, { cacheError });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
throw error;
|
|
382
|
+
}).finally(() => { if (this.paging.get(thread) === task)
|
|
268
383
|
this.paging.delete(thread); });
|
|
269
384
|
this.paging.set(thread, task);
|
|
270
385
|
await task;
|
|
271
386
|
page = await this.storage.read(thread, limit, before);
|
|
272
387
|
}
|
|
273
|
-
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
388
|
+
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
|
|
274
389
|
}
|
|
275
|
-
|
|
390
|
+
denyView(thread, version) {
|
|
276
391
|
this.revoked.add(thread);
|
|
392
|
+
const watch = this.watches.get(thread);
|
|
393
|
+
const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
|
|
394
|
+
if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
|
|
395
|
+
return;
|
|
396
|
+
if (watch) {
|
|
397
|
+
watch.snapshot = snapshot;
|
|
398
|
+
watch.listeners.forEach(listener => listener());
|
|
399
|
+
}
|
|
400
|
+
else
|
|
401
|
+
this.watches.set(thread, { snapshot, listeners: new Set() });
|
|
402
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
403
|
+
}
|
|
404
|
+
async revoke(thread) {
|
|
405
|
+
this.denyView(thread);
|
|
277
406
|
const queue = this.writes.get(thread);
|
|
278
407
|
this.writes.delete(thread);
|
|
279
408
|
if (queue) {
|
|
280
409
|
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
281
410
|
this.release(queue);
|
|
282
411
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
412
|
+
try {
|
|
413
|
+
await this.storage.resetThread(thread, true);
|
|
414
|
+
await this.reload(thread);
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
|
|
418
|
+
}
|
|
286
419
|
}
|
|
287
420
|
async close() {
|
|
288
421
|
// Flush accepted event batches before closing; new requests are rejected afterwards.
|
|
@@ -295,6 +428,7 @@ export class MessageClient {
|
|
|
295
428
|
this.abort.abort();
|
|
296
429
|
this.channel?.close();
|
|
297
430
|
this.watches.clear();
|
|
431
|
+
this.allListeners.clear();
|
|
298
432
|
this.storage.close();
|
|
299
433
|
}
|
|
300
434
|
}
|
|
@@ -16,6 +16,8 @@ export type MessageCoverage = {
|
|
|
16
16
|
complete: boolean;
|
|
17
17
|
nextBefore?: string | null;
|
|
18
18
|
syncedAt?: number;
|
|
19
|
+
resetVersion?: number;
|
|
20
|
+
revoked?: boolean;
|
|
19
21
|
};
|
|
20
22
|
type CoverageRow = MessageCoverage & {
|
|
21
23
|
scope: string;
|
|
@@ -34,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
34
36
|
indexedDB?: IDBFactory;
|
|
35
37
|
name?: string;
|
|
36
38
|
});
|
|
39
|
+
get namespace(): string;
|
|
37
40
|
read(thread: string, limit?: number, before?: {
|
|
38
41
|
timestamp: number;
|
|
39
42
|
id: string;
|
|
@@ -42,7 +45,13 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
42
45
|
hasEarlier: boolean;
|
|
43
46
|
coverage: CoverageRow | undefined;
|
|
44
47
|
}>;
|
|
45
|
-
commit(thread: string, messages: T[], coverage?: MessageCoverage
|
|
48
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
49
|
+
version: number;
|
|
50
|
+
authoritative?: boolean;
|
|
51
|
+
}): Promise<number>;
|
|
52
|
+
windows(): Promise<CoverageRow[]>;
|
|
53
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
54
|
+
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
46
55
|
removeThread(thread: string): Promise<void>;
|
|
47
56
|
clear(): Promise<void>;
|
|
48
57
|
close(): void;
|
|
@@ -17,6 +17,7 @@ export class IndexedDbMessageStore {
|
|
|
17
17
|
this.coverage = this.db.table("coverage");
|
|
18
18
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
19
|
}
|
|
20
|
+
get namespace() { return this.db.name; }
|
|
20
21
|
async read(thread, limit = 100, before) {
|
|
21
22
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
23
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
@@ -26,13 +27,18 @@ export class IndexedDbMessageStore {
|
|
|
26
27
|
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
|
-
async commit(thread, messages, coverage) {
|
|
30
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
31
|
for (const message of messages)
|
|
31
32
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
33
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
34
|
if (messages.length > 1000)
|
|
34
35
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
36
|
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
37
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
38
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
39
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
40
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
41
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
42
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
43
|
const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
|
|
38
44
|
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
|
|
@@ -48,13 +54,26 @@ export class IndexedDbMessageStore {
|
|
|
48
54
|
if (changed.size)
|
|
49
55
|
await this.rows.bulkPut([...changed.values()]);
|
|
50
56
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
57
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
58
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
59
|
await this.coverage.put(next);
|
|
54
60
|
}
|
|
55
61
|
return changed.size;
|
|
56
62
|
});
|
|
57
63
|
}
|
|
64
|
+
async windows() {
|
|
65
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
66
|
+
}
|
|
67
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
68
|
+
async resetThread(thread, revoked = false) {
|
|
69
|
+
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
70
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
71
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
72
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
73
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
74
|
+
return resetVersion;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
58
77
|
async removeThread(thread) {
|
|
59
78
|
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
60
79
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
package/package.json
CHANGED