@pmate/chat-sdk 0.12.0-next.4 → 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 +44 -1
- package/lib/runtime/messageClient.d.ts +4 -0
- package/lib/runtime/messageClient.js +66 -21
- package/lib/runtime/messageStore.d.ts +8 -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,12 +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));
|
|
74
79
|
await this.sender.confirm(page.messages.map(message => message.id));
|
|
75
80
|
if (!options.before)
|
|
76
81
|
this.publish(threadId);
|
|
77
82
|
return page;
|
|
78
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
|
+
}
|
|
79
122
|
sendMessage(threadId, input) {
|
|
80
123
|
this.assertOpen();
|
|
81
124
|
return this.sender.enqueue(threadId, input);
|
|
@@ -99,7 +142,7 @@ export class ChatSession {
|
|
|
99
142
|
if (!snapshot) {
|
|
100
143
|
const messages = this.messages.getSnapshot(threadId);
|
|
101
144
|
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 } };
|
|
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 } };
|
|
103
146
|
this.snapshots.set(threadId, snapshot);
|
|
104
147
|
}
|
|
105
148
|
return snapshot;
|
|
@@ -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;
|
|
@@ -72,6 +74,8 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
72
74
|
}, limit?: number): Promise<{
|
|
73
75
|
messages: T[];
|
|
74
76
|
hasEarlier: boolean;
|
|
77
|
+
resetVersion: number;
|
|
78
|
+
complete: boolean;
|
|
75
79
|
}>;
|
|
76
80
|
private fillWindow;
|
|
77
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,11 +60,39 @@ 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);
|
|
69
97
|
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
70
98
|
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
@@ -92,9 +120,10 @@ export class MessageClient {
|
|
|
92
120
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
93
121
|
});
|
|
94
122
|
const old = watch?.snapshot;
|
|
95
|
-
|
|
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]))
|
|
96
125
|
return old;
|
|
97
|
-
const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
126
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
98
127
|
if (!watch) {
|
|
99
128
|
watch = { snapshot, listeners: new Set() };
|
|
100
129
|
this.watches.set(thread, watch);
|
|
@@ -129,7 +158,10 @@ export class MessageClient {
|
|
|
129
158
|
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
130
159
|
return promise;
|
|
131
160
|
}
|
|
132
|
-
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt) {
|
|
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 };
|
|
133
165
|
let changed = 0;
|
|
134
166
|
let navigationChanged = false;
|
|
135
167
|
// Coverage advances only after all bounded batches have durably committed.
|
|
@@ -139,7 +171,7 @@ export class MessageClient {
|
|
|
139
171
|
return;
|
|
140
172
|
if (this.closed || this.revoked.has(thread))
|
|
141
173
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
142
|
-
changed += await this.storage.commit(thread, batch);
|
|
174
|
+
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
143
175
|
batch = [];
|
|
144
176
|
bytes = 0;
|
|
145
177
|
};
|
|
@@ -155,8 +187,8 @@ export class MessageClient {
|
|
|
155
187
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
156
188
|
if (this.closed || this.revoked.has(thread))
|
|
157
189
|
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 });
|
|
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);
|
|
160
192
|
}
|
|
161
193
|
if (changed || navigationChanged) {
|
|
162
194
|
await this.reload(thread);
|
|
@@ -205,6 +237,7 @@ export class MessageClient {
|
|
|
205
237
|
return running;
|
|
206
238
|
const task = (async () => {
|
|
207
239
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
240
|
+
let version = coverage?.resetVersion ?? 0;
|
|
208
241
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
209
242
|
let page = after && this.options.transport.changes
|
|
210
243
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
@@ -212,7 +245,7 @@ export class MessageClient {
|
|
|
212
245
|
if (page.resetRequired) {
|
|
213
246
|
if (this.closed || this.revoked.has(thread))
|
|
214
247
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
215
|
-
await this.storage.
|
|
248
|
+
version = await this.storage.resetThread(thread);
|
|
216
249
|
after = undefined;
|
|
217
250
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
218
251
|
}
|
|
@@ -221,7 +254,7 @@ export class MessageClient {
|
|
|
221
254
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
222
255
|
if (page.resetRequired)
|
|
223
256
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
224
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now());
|
|
257
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
|
|
225
258
|
if (!page.hasMoreChanges)
|
|
226
259
|
break;
|
|
227
260
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -229,7 +262,11 @@ export class MessageClient {
|
|
|
229
262
|
after = page.checkpoint;
|
|
230
263
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
231
264
|
}
|
|
232
|
-
})().
|
|
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)
|
|
233
270
|
this.syncing.delete(thread); });
|
|
234
271
|
this.syncing.set(thread, task);
|
|
235
272
|
return task;
|
|
@@ -245,6 +282,7 @@ export class MessageClient {
|
|
|
245
282
|
}
|
|
246
283
|
async fillWindow(thread, limit, before) {
|
|
247
284
|
let page = await this.storage.read(thread, limit, before);
|
|
285
|
+
let resets = 0;
|
|
248
286
|
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
249
287
|
const pending = this.paging.get(thread);
|
|
250
288
|
if (pending) {
|
|
@@ -252,25 +290,32 @@ export class MessageClient {
|
|
|
252
290
|
page = await this.storage.read(thread, limit, before);
|
|
253
291
|
continue;
|
|
254
292
|
}
|
|
255
|
-
const token = page.coverage.nextBefore;
|
|
293
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
|
|
256
294
|
const task = (async () => {
|
|
257
295
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
258
296
|
if (this.closed || this.revoked.has(thread))
|
|
259
297
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
260
298
|
if (remote.resetRequired) {
|
|
261
|
-
|
|
262
|
-
|
|
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;
|
|
263
304
|
}
|
|
264
305
|
if (remote.nextBefore === token)
|
|
265
306
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
266
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
267
|
-
})().
|
|
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)
|
|
268
313
|
this.paging.delete(thread); });
|
|
269
314
|
this.paging.set(thread, task);
|
|
270
315
|
await task;
|
|
271
316
|
page = await this.storage.read(thread, limit, before);
|
|
272
317
|
}
|
|
273
|
-
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 };
|
|
274
319
|
}
|
|
275
320
|
async revoke(thread) {
|
|
276
321
|
this.revoked.add(thread);
|
|
@@ -280,7 +325,7 @@ export class MessageClient {
|
|
|
280
325
|
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
281
326
|
this.release(queue);
|
|
282
327
|
}
|
|
283
|
-
await this.storage.
|
|
328
|
+
await this.storage.resetThread(thread, true);
|
|
284
329
|
await this.reload(thread);
|
|
285
330
|
this.channel?.postMessage(thread);
|
|
286
331
|
}
|
|
@@ -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;
|
|
@@ -42,7 +44,12 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
42
44
|
hasEarlier: boolean;
|
|
43
45
|
coverage: CoverageRow | undefined;
|
|
44
46
|
}>;
|
|
45
|
-
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>;
|
|
46
53
|
removeThread(thread: string): Promise<void>;
|
|
47
54
|
clear(): Promise<void>;
|
|
48
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