@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.7
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 +8 -0
- package/lib/runtime/chatSession.d.ts +3 -0
- package/lib/runtime/chatSession.js +23 -9
- package/lib/runtime/messageClient.d.ts +6 -1
- package/lib/runtime/messageClient.js +117 -28
- package/lib/runtime/messageStore.d.ts +2 -0
- package/lib/runtime/messageStore.js +4 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -143,6 +143,9 @@ 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 })
|
|
146
149
|
// Explicit full-history queries reuse cached pages and internally rebase expired cursors.
|
|
147
150
|
const history = await chat.getMessages(threadId, { all: true })
|
|
148
151
|
const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
|
|
@@ -198,6 +201,11 @@ legacy cleanup happens after durable migration. Normal reads use memory and writ
|
|
|
198
201
|
are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
|
|
199
202
|
the host, and pending operations are not reported as saved before their promise
|
|
200
203
|
resolves.
|
|
204
|
+
Control-event batches are bounded by both 128 records and 1 MiB per transaction;
|
|
205
|
+
the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
|
|
206
|
+
`transactionBytes` and `queueBytes` can lower these limits. Oversized operations
|
|
207
|
+
fail before changing the memory snapshot. Repeating an in-flight write waits for
|
|
208
|
+
that physical write, including its failure, rather than acknowledging it early.
|
|
201
209
|
|
|
202
210
|
`DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
|
|
203
211
|
before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
|
|
@@ -47,6 +47,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
47
47
|
private globalListeners;
|
|
48
48
|
private globalSnapshot?;
|
|
49
49
|
private connectionState;
|
|
50
|
+
private catchUp;
|
|
51
|
+
private readingStarted;
|
|
50
52
|
private closed;
|
|
51
53
|
private starting?;
|
|
52
54
|
private closing?;
|
|
@@ -60,6 +62,7 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
60
62
|
id: string;
|
|
61
63
|
};
|
|
62
64
|
all?: boolean;
|
|
65
|
+
refresh?: boolean;
|
|
63
66
|
}): Promise<MessageSnapshot<T>>;
|
|
64
67
|
private readAll;
|
|
65
68
|
sendMessage(threadId: string, input: {
|
|
@@ -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,15 +81,17 @@ 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);
|
|
73
90
|
if (options.all) {
|
|
74
|
-
if (options.before || options.limit !== undefined)
|
|
75
|
-
throw new Error("INVALID_MESSAGE_QUERY");
|
|
76
91
|
return this.readAll(threadId);
|
|
77
92
|
}
|
|
78
93
|
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
79
94
|
await this.sender.confirm(page.messages.map(message => message.id));
|
|
80
|
-
if (!options.before)
|
|
81
|
-
this.publish(threadId);
|
|
82
95
|
return page;
|
|
83
96
|
}
|
|
84
97
|
async readAll(threadId) {
|
|
@@ -161,7 +174,7 @@ export class ChatSession {
|
|
|
161
174
|
this.listeners.set(threadId, listeners);
|
|
162
175
|
}
|
|
163
176
|
listeners.add(notify);
|
|
164
|
-
const off = this.messages.subscribe(threadId, () =>
|
|
177
|
+
const off = this.messages.subscribe(threadId, () => { });
|
|
165
178
|
return () => {
|
|
166
179
|
off();
|
|
167
180
|
listeners.delete(notify);
|
|
@@ -209,6 +222,7 @@ export class ChatSession {
|
|
|
209
222
|
close() {
|
|
210
223
|
return this.closing ??= (async () => {
|
|
211
224
|
this.closed = true;
|
|
225
|
+
this.catchUp.close();
|
|
212
226
|
this.off.splice(0).forEach(off => off());
|
|
213
227
|
if (!this.options.sharedConnection)
|
|
214
228
|
this.connection.close();
|
|
@@ -36,6 +36,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
36
36
|
private options;
|
|
37
37
|
private storage;
|
|
38
38
|
private watches;
|
|
39
|
+
private allListeners;
|
|
39
40
|
private reads;
|
|
40
41
|
private syncing;
|
|
41
42
|
private paging;
|
|
@@ -56,7 +57,10 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
56
57
|
constructor(options: MessageClientOptions<T>);
|
|
57
58
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
58
59
|
subscribe(thread: string, listener: () => void): () => void;
|
|
59
|
-
|
|
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>>;
|
|
60
64
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
61
65
|
invalidate(thread: string): void;
|
|
62
66
|
private reload;
|
|
@@ -78,6 +82,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
78
82
|
complete: boolean;
|
|
79
83
|
}>;
|
|
80
84
|
private fillWindow;
|
|
85
|
+
private denyView;
|
|
81
86
|
revoke(thread: string): Promise<void>;
|
|
82
87
|
close(): Promise<void>;
|
|
83
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,12 +33,30 @@ 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; }
|
|
@@ -50,19 +69,36 @@ export class MessageClient {
|
|
|
50
69
|
watch.listeners.add(listener);
|
|
51
70
|
return () => { watch.listeners.delete(listener); };
|
|
52
71
|
}
|
|
53
|
-
|
|
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) {
|
|
54
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 || local.coverage.revoked || this.revoked.has(thread)) {
|
|
99
|
+
if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
64
100
|
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
65
|
-
if (local.messages.length && !local.coverage?.revoked)
|
|
101
|
+
if (!refresh && local.messages.length && !local.coverage?.revoked)
|
|
66
102
|
return this.reload(thread);
|
|
67
103
|
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
68
104
|
}
|
|
@@ -71,7 +107,7 @@ export class MessageClient {
|
|
|
71
107
|
await this.synchronize(thread);
|
|
72
108
|
}
|
|
73
109
|
catch (error) {
|
|
74
|
-
if (!local.messages.length || [401, 403].includes(error?.status ?? 0))
|
|
110
|
+
if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
|
|
75
111
|
throw error;
|
|
76
112
|
this.options.onError?.(error, thread);
|
|
77
113
|
return this.reload(thread);
|
|
@@ -113,6 +149,10 @@ export class MessageClient {
|
|
|
113
149
|
const page = await this.storage.read(thread, limit);
|
|
114
150
|
if (this.closed)
|
|
115
151
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
152
|
+
if (this.revoked.has(thread)) {
|
|
153
|
+
page.messages = [];
|
|
154
|
+
page.hasEarlier = false;
|
|
155
|
+
}
|
|
116
156
|
let watch = this.watches.get(thread);
|
|
117
157
|
const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
|
|
118
158
|
const messages = page.messages.map(message => {
|
|
@@ -120,22 +160,26 @@ export class MessageClient {
|
|
|
120
160
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
121
161
|
});
|
|
122
162
|
const old = watch?.snapshot;
|
|
123
|
-
const resetVersion = page.coverage?.resetVersion ?? 0, complete = page.coverage?.complete ?? false;
|
|
124
|
-
|
|
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]))
|
|
125
166
|
return old;
|
|
126
|
-
const snapshot = { messages, resetVersion, complete, hasEarlier
|
|
167
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier };
|
|
127
168
|
if (!watch) {
|
|
128
169
|
watch = { snapshot, listeners: new Set() };
|
|
129
170
|
this.watches.set(thread, watch);
|
|
130
171
|
}
|
|
131
172
|
watch.snapshot = snapshot;
|
|
132
173
|
watch.listeners.forEach(listener => listener());
|
|
174
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
133
175
|
return snapshot;
|
|
134
176
|
}
|
|
135
177
|
/** Events are visible only after their atomic batch commits. */
|
|
136
178
|
receive(thread, messages) {
|
|
137
|
-
if (this.closed || this.closing
|
|
179
|
+
if (this.closed || this.closing)
|
|
138
180
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
181
|
+
if (this.revoked.has(thread))
|
|
182
|
+
return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
|
|
139
183
|
const queue = this.writes.get(thread) ?? [];
|
|
140
184
|
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
141
185
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
@@ -153,7 +197,7 @@ export class MessageClient {
|
|
|
153
197
|
}
|
|
154
198
|
this.pendingBytes += bytes;
|
|
155
199
|
this.pendingRecords += messages.length;
|
|
156
|
-
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 }));
|
|
157
201
|
this.writes.set(thread, queue);
|
|
158
202
|
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
159
203
|
return promise;
|
|
@@ -218,12 +262,26 @@ export class MessageClient {
|
|
|
218
262
|
return;
|
|
219
263
|
this.writes.delete(thread);
|
|
220
264
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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;
|
|
227
285
|
}
|
|
228
286
|
finally {
|
|
229
287
|
this.release(queue);
|
|
@@ -263,8 +321,14 @@ export class MessageClient {
|
|
|
263
321
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
264
322
|
}
|
|
265
323
|
})().catch(async (error) => {
|
|
266
|
-
if (!this.closed && !this.closing && error?.status === 403)
|
|
267
|
-
|
|
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
|
+
}
|
|
268
332
|
throw error;
|
|
269
333
|
}).finally(() => { if (this.syncing.get(thread) === task)
|
|
270
334
|
this.syncing.delete(thread); });
|
|
@@ -306,8 +370,14 @@ export class MessageClient {
|
|
|
306
370
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
307
371
|
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
|
|
308
372
|
})().catch(async (error) => {
|
|
309
|
-
if (!this.closed && !this.closing && error?.status === 403)
|
|
310
|
-
|
|
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
|
+
}
|
|
311
381
|
throw error;
|
|
312
382
|
}).finally(() => { if (this.paging.get(thread) === task)
|
|
313
383
|
this.paging.delete(thread); });
|
|
@@ -317,17 +387,35 @@ export class MessageClient {
|
|
|
317
387
|
}
|
|
318
388
|
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
|
|
319
389
|
}
|
|
320
|
-
|
|
390
|
+
denyView(thread, version) {
|
|
321
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);
|
|
322
406
|
const queue = this.writes.get(thread);
|
|
323
407
|
this.writes.delete(thread);
|
|
324
408
|
if (queue) {
|
|
325
409
|
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
326
410
|
this.release(queue);
|
|
327
411
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|
|
331
419
|
}
|
|
332
420
|
async close() {
|
|
333
421
|
// Flush accepted event batches before closing; new requests are rejected afterwards.
|
|
@@ -340,6 +428,7 @@ export class MessageClient {
|
|
|
340
428
|
this.abort.abort();
|
|
341
429
|
this.channel?.close();
|
|
342
430
|
this.watches.clear();
|
|
431
|
+
this.allListeners.clear();
|
|
343
432
|
this.storage.close();
|
|
344
433
|
}
|
|
345
434
|
}
|
|
@@ -36,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
36
36
|
indexedDB?: IDBFactory;
|
|
37
37
|
name?: string;
|
|
38
38
|
});
|
|
39
|
+
get namespace(): string;
|
|
39
40
|
read(thread: string, limit?: number, before?: {
|
|
40
41
|
timestamp: number;
|
|
41
42
|
id: string;
|
|
@@ -48,6 +49,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
48
49
|
version: number;
|
|
49
50
|
authoritative?: boolean;
|
|
50
51
|
}): Promise<number>;
|
|
52
|
+
windows(): Promise<CoverageRow[]>;
|
|
51
53
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
52
54
|
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
53
55
|
removeThread(thread: string): Promise<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");
|
|
@@ -60,6 +61,9 @@ export class IndexedDbMessageStore {
|
|
|
60
61
|
return changed.size;
|
|
61
62
|
});
|
|
62
63
|
}
|
|
64
|
+
async windows() {
|
|
65
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
66
|
+
}
|
|
63
67
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
64
68
|
async resetThread(thread, revoked = false) {
|
|
65
69
|
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
@@ -5,6 +5,8 @@ export type RecordCacheOptions<T> = {
|
|
|
5
5
|
indexedDB?: IDBFactory;
|
|
6
6
|
name?: string;
|
|
7
7
|
onError?: (error: unknown) => void;
|
|
8
|
+
transactionBytes?: number;
|
|
9
|
+
queueBytes?: number;
|
|
8
10
|
};
|
|
9
11
|
/** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
|
|
10
12
|
* Legacy storage is read only during open; the hot path never uses localStorage. */
|
|
@@ -23,6 +25,11 @@ export declare class IndexedDbRecordCache<T> {
|
|
|
23
25
|
private closing;
|
|
24
26
|
private initialized;
|
|
25
27
|
private running?;
|
|
28
|
+
private activeBytes;
|
|
29
|
+
private pendingBytes;
|
|
30
|
+
private activeRecords;
|
|
31
|
+
private transactionBytes;
|
|
32
|
+
private queueBytes;
|
|
26
33
|
private channel?;
|
|
27
34
|
private listeners;
|
|
28
35
|
constructor(options: RecordCacheOptions<T>);
|
|
@@ -34,7 +41,9 @@ export declare class IndexedDbRecordCache<T> {
|
|
|
34
41
|
subscribe(listener: () => void): () => void;
|
|
35
42
|
private reload;
|
|
36
43
|
upsert(incoming: T[]): Promise<void>;
|
|
44
|
+
private bytes;
|
|
37
45
|
private write;
|
|
46
|
+
private writeBatch;
|
|
38
47
|
flush(): Promise<void>;
|
|
39
48
|
private runFlush;
|
|
40
49
|
close(): Promise<void>;
|
|
@@ -17,17 +17,26 @@ export class IndexedDbRecordCache {
|
|
|
17
17
|
closing = false;
|
|
18
18
|
initialized = false;
|
|
19
19
|
running;
|
|
20
|
+
activeBytes = 0;
|
|
21
|
+
pendingBytes = 0;
|
|
22
|
+
activeRecords = 0;
|
|
23
|
+
transactionBytes;
|
|
24
|
+
queueBytes;
|
|
20
25
|
channel;
|
|
21
26
|
listeners = new Set();
|
|
22
27
|
constructor(options) {
|
|
23
28
|
this.options = options;
|
|
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_RECORD_CACHE_BYTE_LIMIT");
|
|
24
33
|
this.db = new Dexie(options.name ?? "pmate-chat-records", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
34
|
this.db.version(1).stores({ records: "[scope+id],scope", metadata: "scope" });
|
|
26
35
|
this.records = this.db.table("records");
|
|
27
36
|
this.metadata = this.db.table("metadata");
|
|
28
37
|
this.tasks = new WindowedTasks(16, error => options.onError?.(error));
|
|
29
38
|
if (typeof BroadcastChannel !== "undefined") {
|
|
30
|
-
this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}`);
|
|
39
|
+
this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}${this.db.name === "pmate-chat-records" ? "" : `:${this.db.name}`}`);
|
|
31
40
|
this.channel.onmessage = () => this.tasks.schedule("reload", () => this.reload());
|
|
32
41
|
}
|
|
33
42
|
}
|
|
@@ -83,30 +92,70 @@ export class IndexedDbRecordCache {
|
|
|
83
92
|
await this.ready;
|
|
84
93
|
if (this.closed || this.closing)
|
|
85
94
|
throw new Error("RECORD_CACHE_CLOSED");
|
|
86
|
-
if (incoming.length
|
|
95
|
+
if (incoming.length > 4096)
|
|
87
96
|
throw new Error("RECORD_CACHE_BACKPRESSURE");
|
|
88
|
-
|
|
97
|
+
// Plan and validate the whole operation before publishing any in-memory state.
|
|
98
|
+
const staged = new Map();
|
|
89
99
|
for (const value of incoming) {
|
|
90
|
-
const key = this.options.key(value), old =
|
|
100
|
+
const key = this.options.key(value), old = staged.get(key) ?? this.values.get(key);
|
|
101
|
+
const next = this.options.merge(old, value);
|
|
91
102
|
if (JSON.stringify(old) === JSON.stringify(next))
|
|
92
103
|
continue;
|
|
104
|
+
if (this.bytes(next) > this.transactionBytes)
|
|
105
|
+
throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
|
|
106
|
+
staged.set(key, structuredClone(next));
|
|
107
|
+
}
|
|
108
|
+
let nextBytes = this.pendingBytes, nextRecords = this.pending.size;
|
|
109
|
+
for (const [key, value] of staged) {
|
|
110
|
+
const previous = this.pending.get(key);
|
|
111
|
+
nextBytes += this.bytes(value) - (previous === undefined ? 0 : this.bytes(previous));
|
|
112
|
+
if (!this.pending.has(key))
|
|
113
|
+
nextRecords++;
|
|
114
|
+
}
|
|
115
|
+
if (nextRecords + this.activeRecords > 4096 || nextBytes + this.activeBytes > this.queueBytes)
|
|
116
|
+
throw new Error("RECORD_CACHE_BACKPRESSURE");
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const [key, next] of staged) {
|
|
93
119
|
this.values.set(key, next);
|
|
94
120
|
this.pending.set(key, next);
|
|
95
121
|
changed = true;
|
|
96
122
|
}
|
|
123
|
+
this.pendingBytes = nextBytes;
|
|
97
124
|
if (changed)
|
|
98
125
|
this.snapshotValue = [...this.values.values()];
|
|
99
|
-
if (!changed && !this.pending.size)
|
|
126
|
+
if (!changed && !this.pending.size) {
|
|
127
|
+
await this.running;
|
|
100
128
|
return;
|
|
129
|
+
}
|
|
101
130
|
const committed = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
102
131
|
this.tasks.schedule("write", () => this.flush());
|
|
103
132
|
return committed;
|
|
104
133
|
}
|
|
134
|
+
bytes(value) { return new TextEncoder().encode(JSON.stringify({ scope: this.options.scope, id: this.options.key(value), value })).byteLength; }
|
|
105
135
|
async write(values) {
|
|
136
|
+
let batch = [], bytes = 0;
|
|
137
|
+
for (const value of values) {
|
|
138
|
+
const size = this.bytes(value);
|
|
139
|
+
if (size > this.transactionBytes)
|
|
140
|
+
throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
|
|
141
|
+
if (batch.length && (batch.length === 128 || bytes + size > this.transactionBytes)) {
|
|
142
|
+
await this.writeBatch(batch);
|
|
143
|
+
batch = [];
|
|
144
|
+
bytes = 0;
|
|
145
|
+
}
|
|
146
|
+
batch.push(value);
|
|
147
|
+
bytes += size;
|
|
148
|
+
}
|
|
149
|
+
if (batch.length)
|
|
150
|
+
await this.writeBatch(batch);
|
|
151
|
+
}
|
|
152
|
+
async writeBatch(values) {
|
|
106
153
|
await this.db.transaction("rw", this.records, async () => {
|
|
107
154
|
const previous = await this.records.bulkGet(values.map(value => [this.options.scope, this.options.key(value)]));
|
|
108
155
|
const records = values.map((value, index) => ({ scope: this.options.scope, id: this.options.key(value), value: this.options.merge(previous[index]?.value, value) }));
|
|
109
156
|
const changed = records.filter((row, index) => JSON.stringify(previous[index]?.value) !== JSON.stringify(row.value));
|
|
157
|
+
if (changed.reduce((sum, row) => sum + this.bytes(row.value), 0) > this.transactionBytes)
|
|
158
|
+
throw new Error("RECORD_CACHE_MERGE_TOO_LARGE");
|
|
110
159
|
if (changed.length)
|
|
111
160
|
await this.records.bulkPut(changed);
|
|
112
161
|
});
|
|
@@ -126,8 +175,11 @@ export class IndexedDbRecordCache {
|
|
|
126
175
|
async runFlush() {
|
|
127
176
|
const values = [...this.pending.values()], waiters = this.waiters.splice(0);
|
|
128
177
|
this.pending.clear();
|
|
178
|
+
this.pendingBytes = 0;
|
|
129
179
|
if (!values.length)
|
|
130
180
|
return;
|
|
181
|
+
this.activeBytes = values.reduce((sum, value) => sum + this.bytes(value), 0);
|
|
182
|
+
this.activeRecords = values.length;
|
|
131
183
|
try {
|
|
132
184
|
for (let offset = 0; offset < values.length; offset += 128)
|
|
133
185
|
await this.write(values.slice(offset, offset + 128));
|
|
@@ -142,9 +194,14 @@ export class IndexedDbRecordCache {
|
|
|
142
194
|
const key = this.options.key(value);
|
|
143
195
|
this.pending.set(key, this.options.merge(value, this.pending.get(key) ?? value));
|
|
144
196
|
}
|
|
197
|
+
this.pendingBytes = [...this.pending.values()].reduce((sum, value) => sum + this.bytes(value), 0);
|
|
145
198
|
waiters.forEach(waiter => waiter.reject(error));
|
|
146
199
|
throw error;
|
|
147
200
|
}
|
|
201
|
+
finally {
|
|
202
|
+
this.activeBytes = 0;
|
|
203
|
+
this.activeRecords = 0;
|
|
204
|
+
}
|
|
148
205
|
}
|
|
149
206
|
async close() {
|
|
150
207
|
this.closing = true;
|
package/package.json
CHANGED