@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.5
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 +7 -0
- package/lib/runtime/chatSession.d.ts +6 -2
- package/lib/runtime/chatSession.js +45 -1
- package/lib/runtime/durableSender.js +4 -1
- package/lib/runtime/messageClient.d.ts +5 -0
- package/lib/runtime/messageClient.js +77 -24
- package/lib/runtime/messageStore.d.ts +9 -1
- package/lib/runtime/messageStore.js +17 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,6 +143,8 @@ 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
|
+
// Explicit full-history queries reuse cached pages and internally rebase expired cursors.
|
|
147
|
+
const history = await chat.getMessages(threadId, { all: true })
|
|
146
148
|
const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
|
|
147
149
|
const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
148
150
|
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
@@ -162,6 +164,11 @@ snapshot reports transport progress and failure. Retry uses the original message
|
|
|
162
164
|
hash. The subscribed recent window remains 100 records regardless of individual
|
|
163
165
|
query limits; history requests return their own pages and do not replace it.
|
|
164
166
|
Global snapshots contain thread summaries and totals, not complete message arrays.
|
|
167
|
+
Message pages report `complete`; unavailable older pages return cached records with
|
|
168
|
+
`complete: false`. Authorization failures throw and never fall back to denied
|
|
169
|
+
records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
|
|
170
|
+
it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
|
|
171
|
+
pre-reset payload.
|
|
165
172
|
Voice visibility never acknowledges playback, including observations that arrive
|
|
166
173
|
before metadata. Create a new instance and close the previous owner when the
|
|
167
174
|
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> = {
|
|
@@ -57,7 +59,9 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
57
59
|
timestamp: number;
|
|
58
60
|
id: string;
|
|
59
61
|
};
|
|
60
|
-
|
|
62
|
+
all?: boolean;
|
|
63
|
+
}): Promise<MessageSnapshot<T>>;
|
|
64
|
+
private readAll;
|
|
61
65
|
sendMessage(threadId: string, input: {
|
|
62
66
|
to: string;
|
|
63
67
|
body: ChatBody;
|
|
@@ -70,11 +70,55 @@ export class ChatSession {
|
|
|
70
70
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
71
71
|
async getMessages(threadId, options = {}) {
|
|
72
72
|
this.assertOpen();
|
|
73
|
+
if (options.all) {
|
|
74
|
+
if (options.before || options.limit !== undefined)
|
|
75
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
76
|
+
return this.readAll(threadId);
|
|
77
|
+
}
|
|
73
78
|
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
79
|
+
await this.sender.confirm(page.messages.map(message => message.id));
|
|
74
80
|
if (!options.before)
|
|
75
81
|
this.publish(threadId);
|
|
76
82
|
return page;
|
|
77
83
|
}
|
|
84
|
+
async readAll(threadId) {
|
|
85
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
86
|
+
const pages = [];
|
|
87
|
+
let version = 0;
|
|
88
|
+
try {
|
|
89
|
+
let page = await this.getMessages(threadId);
|
|
90
|
+
version = page.resetVersion;
|
|
91
|
+
pages.push(page.messages);
|
|
92
|
+
if (!page.complete)
|
|
93
|
+
return { ...page, hasEarlier: true };
|
|
94
|
+
let restart = false;
|
|
95
|
+
while (page.hasEarlier) {
|
|
96
|
+
const first = page.messages[0];
|
|
97
|
+
if (!first)
|
|
98
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
99
|
+
page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
|
|
100
|
+
if (page.resetVersion !== version) {
|
|
101
|
+
restart = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
pages.push(page.messages);
|
|
105
|
+
}
|
|
106
|
+
if (restart)
|
|
107
|
+
continue;
|
|
108
|
+
return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
112
|
+
throw error;
|
|
113
|
+
if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
114
|
+
continue;
|
|
115
|
+
if (pages.length)
|
|
116
|
+
return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
121
|
+
}
|
|
78
122
|
sendMessage(threadId, input) {
|
|
79
123
|
this.assertOpen();
|
|
80
124
|
return this.sender.enqueue(threadId, input);
|
|
@@ -98,7 +142,7 @@ export class ChatSession {
|
|
|
98
142
|
if (!snapshot) {
|
|
99
143
|
const messages = this.messages.getSnapshot(threadId);
|
|
100
144
|
const state = this.reading.getSnapshot();
|
|
101
|
-
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 } };
|
|
145
|
+
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 } };
|
|
102
146
|
this.snapshots.set(threadId, snapshot);
|
|
103
147
|
}
|
|
104
148
|
return snapshot;
|
|
@@ -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
|
});
|
|
@@ -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;
|
|
@@ -27,6 +29,7 @@ export type MessageClientOptions<T extends StoredMessage> = {
|
|
|
27
29
|
syncWindowMs?: number;
|
|
28
30
|
transactionBytes?: number;
|
|
29
31
|
queueBytes?: number;
|
|
32
|
+
freshnessMs?: number;
|
|
30
33
|
};
|
|
31
34
|
/** Local-first message facade; hosts never select a cache or network source. */
|
|
32
35
|
export declare class MessageClient<T extends StoredMessage> {
|
|
@@ -71,6 +74,8 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
71
74
|
}, limit?: number): Promise<{
|
|
72
75
|
messages: T[];
|
|
73
76
|
hasEarlier: boolean;
|
|
77
|
+
resetVersion: number;
|
|
78
|
+
complete: boolean;
|
|
74
79
|
}>;
|
|
75
80
|
private fillWindow;
|
|
76
81
|
revoke(thread: string): Promise<void>;
|
|
@@ -44,14 +44,14 @@ export class MessageClient {
|
|
|
44
44
|
subscribe(thread, listener) {
|
|
45
45
|
let watch = this.watches.get(thread);
|
|
46
46
|
if (!watch) {
|
|
47
|
-
watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
|
|
47
|
+
watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
|
|
48
48
|
this.watches.set(thread, watch);
|
|
49
49
|
}
|
|
50
50
|
watch.listeners.add(listener);
|
|
51
51
|
return () => { watch.listeners.delete(listener); };
|
|
52
52
|
}
|
|
53
53
|
read(thread, limit = 100) {
|
|
54
|
-
if (this.closed || this.closing
|
|
54
|
+
if (this.closed || this.closing)
|
|
55
55
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
56
56
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
57
57
|
return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
|
|
@@ -60,12 +60,43 @@ export class MessageClient {
|
|
|
60
60
|
return running;
|
|
61
61
|
const task = (async () => {
|
|
62
62
|
const local = await this.storage.read(thread, limit);
|
|
63
|
-
if (!local.coverage?.complete)
|
|
64
|
-
|
|
63
|
+
if (!local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
64
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
65
|
+
if (local.messages.length && !local.coverage?.revoked)
|
|
66
|
+
return this.reload(thread);
|
|
67
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
68
|
+
}
|
|
69
|
+
this.revoked.delete(thread);
|
|
70
|
+
try {
|
|
71
|
+
await this.synchronize(thread);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!local.messages.length || [401, 403].includes(error?.status ?? 0))
|
|
75
|
+
throw error;
|
|
76
|
+
this.options.onError?.(error, thread);
|
|
77
|
+
return this.reload(thread);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
65
80
|
if (this.closed)
|
|
66
81
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
67
|
-
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await this.fillWindow(thread, limit);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
88
|
+
throw error;
|
|
89
|
+
const cached = await this.storage.read(thread, limit);
|
|
90
|
+
if (!cached.messages.length)
|
|
91
|
+
throw error;
|
|
92
|
+
this.options.onError?.(error, thread);
|
|
93
|
+
await this.reload(thread);
|
|
94
|
+
return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
|
|
95
|
+
}
|
|
68
96
|
const snapshot = await this.reload(thread);
|
|
97
|
+
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
98
|
+
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
99
|
+
this.invalidate(thread);
|
|
69
100
|
return limit === 100 ? snapshot : result;
|
|
70
101
|
})().finally(() => { if (this.reads.get(key) === task)
|
|
71
102
|
this.reads.delete(key); });
|
|
@@ -89,9 +120,10 @@ export class MessageClient {
|
|
|
89
120
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
90
121
|
});
|
|
91
122
|
const old = watch?.snapshot;
|
|
92
|
-
|
|
123
|
+
const resetVersion = page.coverage?.resetVersion ?? 0, complete = page.coverage?.complete ?? false;
|
|
124
|
+
if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
93
125
|
return old;
|
|
94
|
-
const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
126
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
95
127
|
if (!watch) {
|
|
96
128
|
watch = { snapshot, listeners: new Set() };
|
|
97
129
|
this.watches.set(thread, watch);
|
|
@@ -105,17 +137,20 @@ export class MessageClient {
|
|
|
105
137
|
if (this.closed || this.closing || this.revoked.has(thread))
|
|
106
138
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
107
139
|
const queue = this.writes.get(thread) ?? [];
|
|
140
|
+
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
141
|
+
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
108
142
|
let bytes = 0;
|
|
109
143
|
try {
|
|
110
|
-
for (const message of messages)
|
|
144
|
+
for (const message of messages) {
|
|
111
145
|
bytes += this.messageBytes(message);
|
|
146
|
+
if (this.pendingBytes + bytes > this.queueBytes)
|
|
147
|
+
throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
|
|
148
|
+
}
|
|
112
149
|
messages = structuredClone(messages);
|
|
113
150
|
}
|
|
114
151
|
catch (error) {
|
|
115
152
|
return Promise.reject(error);
|
|
116
153
|
}
|
|
117
|
-
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096 || this.pendingBytes + bytes > this.queueBytes)
|
|
118
|
-
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
119
154
|
this.pendingBytes += bytes;
|
|
120
155
|
this.pendingRecords += messages.length;
|
|
121
156
|
const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
|
|
@@ -123,8 +158,12 @@ export class MessageClient {
|
|
|
123
158
|
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
124
159
|
return promise;
|
|
125
160
|
}
|
|
126
|
-
async persist(thread, messages, checkpoint, complete, nextBefore) {
|
|
161
|
+
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
|
|
162
|
+
const initial = (await this.storage.read(thread, 1)).coverage;
|
|
163
|
+
const version = expectedVersion ?? initial?.resetVersion ?? 0;
|
|
164
|
+
const guard = { version, authoritative: complete === true };
|
|
127
165
|
let changed = 0;
|
|
166
|
+
let navigationChanged = false;
|
|
128
167
|
// Coverage advances only after all bounded batches have durably committed.
|
|
129
168
|
let batch = [], bytes = 0;
|
|
130
169
|
const commit = async () => {
|
|
@@ -132,7 +171,7 @@ export class MessageClient {
|
|
|
132
171
|
return;
|
|
133
172
|
if (this.closed || this.revoked.has(thread))
|
|
134
173
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
135
|
-
changed += await this.storage.commit(thread, batch);
|
|
174
|
+
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
136
175
|
batch = [];
|
|
137
176
|
bytes = 0;
|
|
138
177
|
};
|
|
@@ -148,9 +187,10 @@ export class MessageClient {
|
|
|
148
187
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
149
188
|
if (this.closed || this.revoked.has(thread))
|
|
150
189
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
151
|
-
|
|
190
|
+
navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
|
|
191
|
+
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);
|
|
152
192
|
}
|
|
153
|
-
if (changed) {
|
|
193
|
+
if (changed || navigationChanged) {
|
|
154
194
|
await this.reload(thread);
|
|
155
195
|
this.channel?.postMessage(thread);
|
|
156
196
|
}
|
|
@@ -197,6 +237,7 @@ export class MessageClient {
|
|
|
197
237
|
return running;
|
|
198
238
|
const task = (async () => {
|
|
199
239
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
240
|
+
let version = coverage?.resetVersion ?? 0;
|
|
200
241
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
201
242
|
let page = after && this.options.transport.changes
|
|
202
243
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
@@ -204,7 +245,7 @@ export class MessageClient {
|
|
|
204
245
|
if (page.resetRequired) {
|
|
205
246
|
if (this.closed || this.revoked.has(thread))
|
|
206
247
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
207
|
-
await this.storage.
|
|
248
|
+
version = await this.storage.resetThread(thread);
|
|
208
249
|
after = undefined;
|
|
209
250
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
210
251
|
}
|
|
@@ -213,7 +254,7 @@ export class MessageClient {
|
|
|
213
254
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
214
255
|
if (page.resetRequired)
|
|
215
256
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
216
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
|
|
257
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
|
|
217
258
|
if (!page.hasMoreChanges)
|
|
218
259
|
break;
|
|
219
260
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -221,7 +262,11 @@ export class MessageClient {
|
|
|
221
262
|
after = page.checkpoint;
|
|
222
263
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
223
264
|
}
|
|
224
|
-
})().
|
|
265
|
+
})().catch(async (error) => {
|
|
266
|
+
if (!this.closed && !this.closing && error?.status === 403)
|
|
267
|
+
await this.revoke(thread);
|
|
268
|
+
throw error;
|
|
269
|
+
}).finally(() => { if (this.syncing.get(thread) === task)
|
|
225
270
|
this.syncing.delete(thread); });
|
|
226
271
|
this.syncing.set(thread, task);
|
|
227
272
|
return task;
|
|
@@ -237,6 +282,7 @@ export class MessageClient {
|
|
|
237
282
|
}
|
|
238
283
|
async fillWindow(thread, limit, before) {
|
|
239
284
|
let page = await this.storage.read(thread, limit, before);
|
|
285
|
+
let resets = 0;
|
|
240
286
|
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
241
287
|
const pending = this.paging.get(thread);
|
|
242
288
|
if (pending) {
|
|
@@ -244,25 +290,32 @@ export class MessageClient {
|
|
|
244
290
|
page = await this.storage.read(thread, limit, before);
|
|
245
291
|
continue;
|
|
246
292
|
}
|
|
247
|
-
const token = page.coverage.nextBefore;
|
|
293
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
|
|
248
294
|
const task = (async () => {
|
|
249
295
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
250
296
|
if (this.closed || this.revoked.has(thread))
|
|
251
297
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
252
298
|
if (remote.resetRequired) {
|
|
253
|
-
|
|
254
|
-
|
|
299
|
+
if (++resets > 2)
|
|
300
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
301
|
+
await this.storage.resetThread(thread);
|
|
302
|
+
await this.synchronize(thread);
|
|
303
|
+
return;
|
|
255
304
|
}
|
|
256
305
|
if (remote.nextBefore === token)
|
|
257
306
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
258
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
259
|
-
})().
|
|
307
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
|
|
308
|
+
})().catch(async (error) => {
|
|
309
|
+
if (!this.closed && !this.closing && error?.status === 403)
|
|
310
|
+
await this.revoke(thread);
|
|
311
|
+
throw error;
|
|
312
|
+
}).finally(() => { if (this.paging.get(thread) === task)
|
|
260
313
|
this.paging.delete(thread); });
|
|
261
314
|
this.paging.set(thread, task);
|
|
262
315
|
await task;
|
|
263
316
|
page = await this.storage.read(thread, limit, before);
|
|
264
317
|
}
|
|
265
|
-
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
318
|
+
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
|
|
266
319
|
}
|
|
267
320
|
async revoke(thread) {
|
|
268
321
|
this.revoked.add(thread);
|
|
@@ -272,7 +325,7 @@ export class MessageClient {
|
|
|
272
325
|
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
273
326
|
this.release(queue);
|
|
274
327
|
}
|
|
275
|
-
await this.storage.
|
|
328
|
+
await this.storage.resetThread(thread, true);
|
|
276
329
|
await this.reload(thread);
|
|
277
330
|
this.channel?.postMessage(thread);
|
|
278
331
|
}
|
|
@@ -15,6 +15,9 @@ export type MessageCoverage = {
|
|
|
15
15
|
checkpoint?: string;
|
|
16
16
|
complete: boolean;
|
|
17
17
|
nextBefore?: string | null;
|
|
18
|
+
syncedAt?: number;
|
|
19
|
+
resetVersion?: number;
|
|
20
|
+
revoked?: boolean;
|
|
18
21
|
};
|
|
19
22
|
type CoverageRow = MessageCoverage & {
|
|
20
23
|
scope: string;
|
|
@@ -41,7 +44,12 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
41
44
|
hasEarlier: boolean;
|
|
42
45
|
coverage: CoverageRow | undefined;
|
|
43
46
|
}>;
|
|
44
|
-
commit(thread: string, messages: T[], coverage?: MessageCoverage
|
|
47
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
48
|
+
version: number;
|
|
49
|
+
authoritative?: boolean;
|
|
50
|
+
}): Promise<number>;
|
|
51
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
52
|
+
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
45
53
|
removeThread(thread: string): Promise<void>;
|
|
46
54
|
clear(): Promise<void>;
|
|
47
55
|
close(): void;
|
|
@@ -26,13 +26,18 @@ export class IndexedDbMessageStore {
|
|
|
26
26
|
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
|
-
async commit(thread, messages, coverage) {
|
|
29
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
30
|
for (const message of messages)
|
|
31
31
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
32
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
33
|
if (messages.length > 1000)
|
|
34
34
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
35
|
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
36
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
37
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
38
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
39
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
40
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
41
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
42
|
const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
|
|
38
43
|
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
|
|
@@ -48,13 +53,23 @@ export class IndexedDbMessageStore {
|
|
|
48
53
|
if (changed.size)
|
|
49
54
|
await this.rows.bulkPut([...changed.values()]);
|
|
50
55
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
56
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
57
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
58
|
await this.coverage.put(next);
|
|
54
59
|
}
|
|
55
60
|
return changed.size;
|
|
56
61
|
});
|
|
57
62
|
}
|
|
63
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
64
|
+
async resetThread(thread, revoked = false) {
|
|
65
|
+
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
66
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
67
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
68
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
69
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
70
|
+
return resetVersion;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
58
73
|
async removeThread(thread) {
|
|
59
74
|
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
60
75
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
package/package.json
CHANGED