@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.21
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 +62 -3
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +8 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +79 -7
- package/lib/runtime/chatSession.js +439 -30
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/createChat.d.ts +26 -0
- package/lib/runtime/createChat.js +42 -0
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +158 -35
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageClient.d.ts +39 -6
- package/lib/runtime/messageClient.js +448 -69
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +64 -3
- package/lib/runtime/messageStore.d.ts +37 -2
- package/lib/runtime/messageStore.js +150 -10
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +85 -7
- package/lib/runtime/readIndex.d.ts +26 -3
- package/lib/runtime/readIndex.js +75 -6
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +15 -0
- package/lib/runtime/readingClient.js +83 -12
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +1 -1
|
@@ -5,6 +5,8 @@ export class MessageClient {
|
|
|
5
5
|
options;
|
|
6
6
|
storage;
|
|
7
7
|
watches = new Map();
|
|
8
|
+
accessListeners = new Set();
|
|
9
|
+
allListeners = new Set();
|
|
8
10
|
reads = new Map();
|
|
9
11
|
syncing = new Map();
|
|
10
12
|
paging = new Map();
|
|
@@ -18,46 +20,177 @@ export class MessageClient {
|
|
|
18
20
|
closing = false;
|
|
19
21
|
revoked = new Set();
|
|
20
22
|
batchSize;
|
|
23
|
+
transactionBytes;
|
|
24
|
+
queueBytes;
|
|
25
|
+
pendingBytes = 0;
|
|
26
|
+
pendingRecords = 0;
|
|
27
|
+
legacyImports = 0;
|
|
28
|
+
revokingScope;
|
|
29
|
+
scopeReady;
|
|
30
|
+
scopeVersion = 0;
|
|
31
|
+
scopeAllowed = true;
|
|
32
|
+
scopeDenied = false;
|
|
33
|
+
enqueuing = new Set();
|
|
34
|
+
ensureScope() {
|
|
35
|
+
return this.scopeReady ??= this.storage.scopeAccess().then(access => { this.scopeVersion = access.version; this.scopeAllowed = !access.revoked && !this.scopeDenied; });
|
|
36
|
+
}
|
|
21
37
|
constructor(options) {
|
|
22
38
|
this.options = options;
|
|
23
39
|
this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
|
|
24
40
|
this.batchSize = options.batchSize ?? 128;
|
|
41
|
+
this.transactionBytes = options.transactionBytes ?? 1_048_576;
|
|
42
|
+
this.queueBytes = options.queueBytes ?? 8_388_608;
|
|
43
|
+
if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
|
|
44
|
+
throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
|
|
25
45
|
if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
|
|
26
46
|
throw new Error("INVALID_MESSAGE_BATCH_SIZE");
|
|
27
|
-
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) =>
|
|
47
|
+
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
48
|
+
this.invalidate(thread);
|
|
49
|
+
else
|
|
50
|
+
options.onError?.(error, thread); });
|
|
28
51
|
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
|
|
29
52
|
if (typeof BroadcastChannel !== "undefined") {
|
|
30
|
-
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
|
|
31
|
-
this.channel.onmessage = event => {
|
|
32
|
-
|
|
53
|
+
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
|
|
54
|
+
this.channel.onmessage = event => {
|
|
55
|
+
if (this.closed)
|
|
56
|
+
return;
|
|
57
|
+
if (event.data?.type === "scope-revoked" && Number.isSafeInteger(event.data.version)) {
|
|
58
|
+
const version = event.data.version;
|
|
59
|
+
void this.ensureScope().then(async () => {
|
|
60
|
+
const current = await this.storage.scopeAccess();
|
|
61
|
+
if (!this.closed && (this.scopeVersion < version || current.revoked)) {
|
|
62
|
+
this.denyScope();
|
|
63
|
+
options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), "");
|
|
64
|
+
}
|
|
65
|
+
}).catch(error => options.onError?.(error, ""));
|
|
66
|
+
}
|
|
67
|
+
else if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
|
|
68
|
+
const denied = event.data;
|
|
69
|
+
void (async () => {
|
|
70
|
+
const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
|
|
71
|
+
if ((current?.scopeVersion ?? 0) !== (denied.scopeVersion ?? 0))
|
|
72
|
+
return;
|
|
73
|
+
if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
|
|
74
|
+
return;
|
|
75
|
+
this.denyView(denied.thread, denied.version);
|
|
76
|
+
options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
|
|
77
|
+
})();
|
|
78
|
+
}
|
|
79
|
+
else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
|
|
80
|
+
const thread = event.data.thread;
|
|
81
|
+
void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
|
|
82
|
+
options.onError?.(error, thread); });
|
|
83
|
+
}
|
|
84
|
+
else if (typeof event.data === "string" && this.watches.has(event.data))
|
|
85
|
+
void this.reload(event.data).catch(error => { if (!this.closed)
|
|
86
|
+
options.onError?.(error, event.data); });
|
|
87
|
+
};
|
|
33
88
|
}
|
|
34
89
|
}
|
|
90
|
+
/** Shared guard for sibling caches; it never authorizes a denied old instance. */
|
|
91
|
+
subscribeAccessRevoked(listener) { this.accessListeners.add(listener); return () => { this.accessListeners.delete(listener); }; }
|
|
92
|
+
async scopeAccess() {
|
|
93
|
+
await this.ensureScope();
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return { version: this.scopeVersion, allowed: false };
|
|
96
|
+
const current = await this.storage.scopeAccess();
|
|
97
|
+
return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
|
|
98
|
+
}
|
|
35
99
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
36
100
|
subscribe(thread, listener) {
|
|
37
101
|
let watch = this.watches.get(thread);
|
|
38
102
|
if (!watch) {
|
|
39
|
-
watch = {
|
|
103
|
+
watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
|
|
40
104
|
this.watches.set(thread, watch);
|
|
41
105
|
}
|
|
42
106
|
watch.listeners.add(listener);
|
|
43
107
|
return () => { watch.listeners.delete(listener); };
|
|
44
108
|
}
|
|
45
|
-
|
|
46
|
-
|
|
109
|
+
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
110
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
111
|
+
async restore(since, threadId) {
|
|
112
|
+
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
|
|
113
|
+
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
114
|
+
while (queue.length && !this.closed && !this.closing) {
|
|
115
|
+
const window = queue.shift();
|
|
116
|
+
try {
|
|
117
|
+
await this.synchronize(window.thread);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!this.closed && !this.closing)
|
|
121
|
+
this.options.onError?.(error, window.thread);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
read(thread, limit = 100, refresh = false) {
|
|
127
|
+
if (this.closed || this.closing)
|
|
47
128
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
48
129
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
49
130
|
return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
|
|
50
|
-
const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
|
|
131
|
+
const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
|
|
51
132
|
if (running)
|
|
52
133
|
return running;
|
|
53
134
|
const task = (async () => {
|
|
135
|
+
await this.ensureScope();
|
|
136
|
+
if (this.scopeDenied)
|
|
137
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
54
138
|
const local = await this.storage.read(thread, limit);
|
|
55
|
-
if (
|
|
56
|
-
|
|
139
|
+
if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
140
|
+
this.denyScope();
|
|
141
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
142
|
+
}
|
|
143
|
+
if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
144
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
145
|
+
if (!refresh && !local.coverage?.revoked) {
|
|
146
|
+
const fallback = await this.fallback(thread, limit);
|
|
147
|
+
if (fallback.messages.length)
|
|
148
|
+
return fallback;
|
|
149
|
+
}
|
|
150
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
151
|
+
}
|
|
152
|
+
this.revoked.delete(thread);
|
|
153
|
+
try {
|
|
154
|
+
await this.synchronize(thread);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
158
|
+
this.denyScope();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
if (refresh || [401, 403].includes(error?.status ?? 0))
|
|
162
|
+
throw error;
|
|
163
|
+
const fallback = await this.fallback(thread, limit);
|
|
164
|
+
if (!fallback.messages.length)
|
|
165
|
+
throw error;
|
|
166
|
+
this.options.onError?.(error, thread);
|
|
167
|
+
return fallback;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
57
170
|
if (this.closed)
|
|
58
171
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
59
|
-
|
|
60
|
-
|
|
172
|
+
let result;
|
|
173
|
+
try {
|
|
174
|
+
result = await this.fillWindow(thread, limit);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
178
|
+
this.denyScope();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
182
|
+
throw error;
|
|
183
|
+
const cached = await this.fallback(thread, limit);
|
|
184
|
+
if (!cached.messages.length)
|
|
185
|
+
throw error;
|
|
186
|
+
this.options.onError?.(error, thread);
|
|
187
|
+
return cached;
|
|
188
|
+
}
|
|
189
|
+
const snapshot = await this.reload(thread);
|
|
190
|
+
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
191
|
+
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
192
|
+
this.invalidate(thread);
|
|
193
|
+
return limit === 100 ? snapshot : result;
|
|
61
194
|
})().finally(() => { if (this.reads.get(key) === task)
|
|
62
195
|
this.reads.delete(key); });
|
|
63
196
|
this.reads.set(key, task);
|
|
@@ -68,10 +201,61 @@ export class MessageClient {
|
|
|
68
201
|
if (!this.closed && !this.closing && !this.revoked.has(thread))
|
|
69
202
|
this.syncTasks.schedule(thread, () => this.synchronize(thread));
|
|
70
203
|
}
|
|
71
|
-
async
|
|
72
|
-
|
|
204
|
+
async fallback(thread, limit, before) {
|
|
205
|
+
await this.ensureScope();
|
|
206
|
+
const page = await this.storage.readFallback(thread, limit, before);
|
|
207
|
+
if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
208
|
+
this.denyScope();
|
|
209
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
210
|
+
}
|
|
211
|
+
if (!before && page.messages.length)
|
|
212
|
+
await this.reload(thread, true);
|
|
213
|
+
return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
|
|
214
|
+
}
|
|
215
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
216
|
+
importLegacy(thread, messages) {
|
|
217
|
+
if (this.closed || this.closing)
|
|
218
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
219
|
+
if (messages.length > 128)
|
|
220
|
+
return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
|
|
221
|
+
if (this.legacyImports >= 4)
|
|
222
|
+
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
223
|
+
let records;
|
|
224
|
+
try {
|
|
225
|
+
let bytes = 0;
|
|
226
|
+
for (const message of messages) {
|
|
227
|
+
bytes += this.messageBytes(message);
|
|
228
|
+
if (bytes > Math.min(this.transactionBytes, 1_048_576))
|
|
229
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
230
|
+
}
|
|
231
|
+
records = structuredClone(messages);
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return Promise.reject(error);
|
|
235
|
+
}
|
|
236
|
+
this.legacyImports++;
|
|
237
|
+
const task = this.storage.importLegacy(thread, records).then(async () => {
|
|
238
|
+
const page = await this.storage.read(thread, 1);
|
|
239
|
+
await this.reload(thread, !page.coverage?.complete);
|
|
240
|
+
this.channel?.postMessage({ type: "legacy", thread });
|
|
241
|
+
}).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
|
|
242
|
+
this.activeWrites.add(task);
|
|
243
|
+
return task;
|
|
244
|
+
}
|
|
245
|
+
async reload(thread, fallback = false) {
|
|
246
|
+
await this.ensureScope();
|
|
247
|
+
const limit = 100;
|
|
248
|
+
const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
|
|
73
249
|
if (this.closed)
|
|
74
250
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
251
|
+
if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
252
|
+
this.denyScope();
|
|
253
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
254
|
+
}
|
|
255
|
+
if (this.scopeDenied || this.revoked.has(thread)) {
|
|
256
|
+
page.messages = [];
|
|
257
|
+
page.hasEarlier = false;
|
|
258
|
+
}
|
|
75
259
|
let watch = this.watches.get(thread);
|
|
76
260
|
const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
|
|
77
261
|
const messages = page.messages.map(message => {
|
|
@@ -79,49 +263,108 @@ export class MessageClient {
|
|
|
79
263
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
80
264
|
});
|
|
81
265
|
const old = watch?.snapshot;
|
|
82
|
-
|
|
266
|
+
const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.scopeDenied && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
|
|
267
|
+
const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
|
|
268
|
+
if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
83
269
|
return old;
|
|
84
|
-
const snapshot = { messages,
|
|
270
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier };
|
|
85
271
|
if (!watch) {
|
|
86
|
-
watch = {
|
|
272
|
+
watch = { snapshot, listeners: new Set() };
|
|
87
273
|
this.watches.set(thread, watch);
|
|
88
274
|
}
|
|
89
|
-
watch.limit = limit;
|
|
90
275
|
watch.snapshot = snapshot;
|
|
91
276
|
watch.listeners.forEach(listener => listener());
|
|
277
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
92
278
|
return snapshot;
|
|
93
279
|
}
|
|
94
280
|
/** Events are visible only after their atomic batch commits. */
|
|
95
281
|
receive(thread, messages) {
|
|
96
|
-
if (this.closed || this.closing
|
|
282
|
+
if (this.closed || this.closing)
|
|
97
283
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
98
|
-
|
|
99
|
-
|
|
284
|
+
if (this.revoked.has(thread))
|
|
285
|
+
return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
|
|
286
|
+
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
100
287
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
288
|
+
let bytes = 0;
|
|
289
|
+
try {
|
|
290
|
+
for (const message of messages) {
|
|
291
|
+
bytes += this.messageBytes(message);
|
|
292
|
+
if (this.pendingBytes + bytes > this.queueBytes)
|
|
293
|
+
throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
|
|
294
|
+
}
|
|
295
|
+
messages = structuredClone(messages);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return Promise.reject(error);
|
|
299
|
+
}
|
|
300
|
+
this.pendingBytes += bytes;
|
|
301
|
+
this.pendingRecords += messages.length;
|
|
302
|
+
let resolve, reject;
|
|
303
|
+
const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
304
|
+
const enqueue = this.ensureScope().then(() => {
|
|
305
|
+
if (this.closed || this.scopeDenied)
|
|
306
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
307
|
+
if (!this.scopeAllowed)
|
|
308
|
+
throw new Error("MESSAGE_SCOPE_NOT_READY");
|
|
309
|
+
const queue = this.writes.get(thread) ?? [];
|
|
310
|
+
queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, scopeVersion: this.scopeVersion, resolve, reject });
|
|
311
|
+
this.writes.set(thread, queue);
|
|
312
|
+
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
313
|
+
}).catch(error => { this.pendingBytes -= bytes; this.pendingRecords -= messages.length; reject(error); }).finally(() => this.enqueuing.delete(enqueue));
|
|
314
|
+
this.enqueuing.add(enqueue);
|
|
315
|
+
return committed;
|
|
105
316
|
}
|
|
106
|
-
async persist(thread, messages, checkpoint, complete, nextBefore) {
|
|
317
|
+
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
|
|
318
|
+
const initial = (await this.storage.read(thread, 1)).coverage;
|
|
319
|
+
const version = expectedVersion ?? initial?.resetVersion ?? 0;
|
|
320
|
+
const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
|
|
107
321
|
let changed = 0;
|
|
322
|
+
let navigationChanged = false;
|
|
108
323
|
// Coverage advances only after all bounded batches have durably committed.
|
|
109
|
-
|
|
110
|
-
|
|
324
|
+
let batch = [], bytes = 0;
|
|
325
|
+
const commit = async () => {
|
|
326
|
+
if (!batch.length)
|
|
327
|
+
return;
|
|
328
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
111
329
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
112
|
-
changed += await this.storage.commit(thread,
|
|
330
|
+
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
331
|
+
batch = [];
|
|
332
|
+
bytes = 0;
|
|
333
|
+
};
|
|
334
|
+
for (const message of messages) {
|
|
335
|
+
const size = this.messageBytes(message);
|
|
336
|
+
if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
|
|
337
|
+
await commit();
|
|
338
|
+
batch.push(message);
|
|
339
|
+
bytes += size;
|
|
113
340
|
}
|
|
341
|
+
await commit();
|
|
114
342
|
if (complete !== undefined || checkpoint !== undefined) {
|
|
115
343
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
116
|
-
if (this.closed || this.revoked.has(thread))
|
|
344
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
117
345
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
118
|
-
|
|
346
|
+
navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
|
|
347
|
+
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);
|
|
119
348
|
}
|
|
120
|
-
if (
|
|
349
|
+
if (complete === true)
|
|
350
|
+
this.scopeAllowed = true;
|
|
351
|
+
if (changed || navigationChanged) {
|
|
121
352
|
await this.reload(thread);
|
|
122
353
|
this.channel?.postMessage(thread);
|
|
123
354
|
}
|
|
124
355
|
}
|
|
356
|
+
messageBytes(message) {
|
|
357
|
+
const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
358
|
+
if (bytes > this.transactionBytes)
|
|
359
|
+
throw new Error("MESSAGE_RECORD_TOO_LARGE");
|
|
360
|
+
return bytes;
|
|
361
|
+
}
|
|
362
|
+
release(queue) {
|
|
363
|
+
for (const write of queue) {
|
|
364
|
+
this.pendingBytes -= write.bytes;
|
|
365
|
+
this.pendingRecords -= write.messages.length;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
125
368
|
flushWrites(thread) {
|
|
126
369
|
const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
|
|
127
370
|
this.activeWrites.add(task);
|
|
@@ -133,39 +376,63 @@ export class MessageClient {
|
|
|
133
376
|
return;
|
|
134
377
|
this.writes.delete(thread);
|
|
135
378
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
379
|
+
const groups = new Map();
|
|
380
|
+
for (const write of queue) {
|
|
381
|
+
const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
|
|
382
|
+
group.push(write);
|
|
383
|
+
groups.set(key, group);
|
|
384
|
+
}
|
|
385
|
+
let failed = false, failure;
|
|
386
|
+
for (const writes of groups.values()) {
|
|
387
|
+
try {
|
|
388
|
+
await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
|
|
389
|
+
writes.forEach(write => write.resolve());
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
failed = true;
|
|
393
|
+
failure = error;
|
|
394
|
+
writes.forEach(write => write.reject(error));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (failed)
|
|
398
|
+
throw failure;
|
|
138
399
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
throw error;
|
|
400
|
+
finally {
|
|
401
|
+
this.release(queue);
|
|
142
402
|
}
|
|
143
403
|
}
|
|
144
404
|
synchronize(thread) {
|
|
145
|
-
if (this.closed || this.closing || this.revoked.has(thread))
|
|
405
|
+
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
146
406
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
147
407
|
const running = this.syncing.get(thread);
|
|
148
408
|
if (running)
|
|
149
409
|
return running;
|
|
150
410
|
const task = (async () => {
|
|
411
|
+
await this.ensureScope();
|
|
151
412
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
413
|
+
const scopeVersion = coverage?.scopeVersion ?? 0;
|
|
414
|
+
if (scopeVersion !== this.scopeVersion) {
|
|
415
|
+
this.denyScope();
|
|
416
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
417
|
+
}
|
|
418
|
+
let version = coverage?.resetVersion ?? 0;
|
|
152
419
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
153
420
|
let page = after && this.options.transport.changes
|
|
154
421
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
155
422
|
: await this.options.transport.load(thread, this.abort.signal);
|
|
156
423
|
if (page.resetRequired) {
|
|
157
|
-
if (this.closed || this.revoked.has(thread))
|
|
424
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
158
425
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
159
|
-
await this.storage.
|
|
426
|
+
version = await this.storage.resetThread(thread, false, scopeVersion);
|
|
160
427
|
after = undefined;
|
|
161
428
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
162
429
|
}
|
|
163
430
|
for (;;) {
|
|
164
|
-
if (this.closed || this.revoked.has(thread))
|
|
431
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
165
432
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
166
433
|
if (page.resetRequired)
|
|
167
434
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
168
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
|
|
435
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
|
|
169
436
|
if (!page.hasMoreChanges)
|
|
170
437
|
break;
|
|
171
438
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -173,22 +440,56 @@ export class MessageClient {
|
|
|
173
440
|
after = page.checkpoint;
|
|
174
441
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
175
442
|
}
|
|
176
|
-
})().
|
|
443
|
+
})().catch(async (error) => {
|
|
444
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
|
|
445
|
+
this.denyScope();
|
|
446
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
447
|
+
try {
|
|
448
|
+
await this.revoke(thread);
|
|
449
|
+
}
|
|
450
|
+
catch (cacheError) {
|
|
451
|
+
Object.assign(error, { cacheError });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}).finally(() => { if (this.syncing.get(thread) === task)
|
|
177
456
|
this.syncing.delete(thread); });
|
|
178
457
|
this.syncing.set(thread, task);
|
|
179
458
|
return task;
|
|
180
459
|
}
|
|
181
460
|
async earlier(thread, before, limit = 100) {
|
|
461
|
+
await this.ensureScope();
|
|
462
|
+
if (this.scopeDenied)
|
|
463
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
182
464
|
if (this.closed)
|
|
183
465
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
184
466
|
if (this.revoked.has(thread))
|
|
185
467
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
186
|
-
|
|
187
|
-
await this.
|
|
188
|
-
|
|
468
|
+
try {
|
|
469
|
+
if (!(await this.storage.read(thread, 1)).coverage?.complete) {
|
|
470
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
|
471
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
472
|
+
await this.synchronize(thread);
|
|
473
|
+
}
|
|
474
|
+
return await this.fillWindow(thread, limit, before);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
478
|
+
this.denyScope();
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
482
|
+
throw error;
|
|
483
|
+
const fallback = await this.fallback(thread, limit, before);
|
|
484
|
+
if (!fallback.messages.length)
|
|
485
|
+
throw error;
|
|
486
|
+
this.options.onError?.(error, thread);
|
|
487
|
+
return fallback;
|
|
488
|
+
}
|
|
189
489
|
}
|
|
190
490
|
async fillWindow(thread, limit, before) {
|
|
191
491
|
let page = await this.storage.read(thread, limit, before);
|
|
492
|
+
let resets = 0;
|
|
192
493
|
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
193
494
|
const pending = this.paging.get(thread);
|
|
194
495
|
if (pending) {
|
|
@@ -196,46 +497,124 @@ export class MessageClient {
|
|
|
196
497
|
page = await this.storage.read(thread, limit, before);
|
|
197
498
|
continue;
|
|
198
499
|
}
|
|
199
|
-
const token = page.coverage.nextBefore;
|
|
500
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
|
|
200
501
|
const task = (async () => {
|
|
201
502
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
202
|
-
if (this.closed || this.revoked.has(thread))
|
|
503
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
203
504
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
204
505
|
if (remote.resetRequired) {
|
|
205
|
-
|
|
206
|
-
|
|
506
|
+
if (++resets > 2)
|
|
507
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
508
|
+
await this.storage.resetThread(thread, false, scopeVersion);
|
|
509
|
+
await this.synchronize(thread);
|
|
510
|
+
return;
|
|
207
511
|
}
|
|
208
512
|
if (remote.nextBefore === token)
|
|
209
513
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
210
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
211
|
-
})().
|
|
514
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
|
|
515
|
+
})().catch(async (error) => {
|
|
516
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
517
|
+
try {
|
|
518
|
+
await this.revoke(thread);
|
|
519
|
+
}
|
|
520
|
+
catch (cacheError) {
|
|
521
|
+
Object.assign(error, { cacheError });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
throw error;
|
|
525
|
+
}).finally(() => { if (this.paging.get(thread) === task)
|
|
212
526
|
this.paging.delete(thread); });
|
|
213
527
|
this.paging.set(thread, task);
|
|
214
528
|
await task;
|
|
215
529
|
page = await this.storage.read(thread, limit, before);
|
|
216
530
|
}
|
|
217
|
-
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
531
|
+
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
|
|
218
532
|
}
|
|
219
|
-
|
|
533
|
+
denyView(thread, version) {
|
|
220
534
|
this.revoked.add(thread);
|
|
535
|
+
const watch = this.watches.get(thread);
|
|
536
|
+
const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
|
|
537
|
+
if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
|
|
538
|
+
return;
|
|
539
|
+
if (watch) {
|
|
540
|
+
watch.snapshot = snapshot;
|
|
541
|
+
watch.listeners.forEach(listener => listener());
|
|
542
|
+
}
|
|
543
|
+
else
|
|
544
|
+
this.watches.set(thread, { snapshot, listeners: new Set() });
|
|
545
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
546
|
+
}
|
|
547
|
+
denyScope() {
|
|
548
|
+
const notify = !this.scopeDenied;
|
|
549
|
+
this.scopeDenied = true;
|
|
550
|
+
this.scopeAllowed = false;
|
|
551
|
+
this.abort.abort();
|
|
552
|
+
this.syncTasks.close();
|
|
553
|
+
this.writeTasks.close();
|
|
554
|
+
for (const thread of this.watches.keys())
|
|
555
|
+
this.denyView(thread);
|
|
556
|
+
for (const queue of this.writes.values()) {
|
|
557
|
+
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
558
|
+
this.release(queue);
|
|
559
|
+
}
|
|
560
|
+
this.writes.clear();
|
|
561
|
+
if (notify)
|
|
562
|
+
this.accessListeners.forEach(listener => listener());
|
|
563
|
+
}
|
|
564
|
+
revokeScope() {
|
|
565
|
+
if (this.closed || this.closing)
|
|
566
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
567
|
+
return this.revokingScope ??= (async () => {
|
|
568
|
+
this.denyScope();
|
|
569
|
+
try {
|
|
570
|
+
await this.ensureScope();
|
|
571
|
+
return await this.storage.revokeScope(this.scopeVersion);
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
const access = await this.storage.scopeAccess();
|
|
575
|
+
this.channel?.postMessage({ type: "scope-revoked", version: access.version });
|
|
576
|
+
}
|
|
577
|
+
})();
|
|
578
|
+
}
|
|
579
|
+
async revoke(thread) {
|
|
580
|
+
this.denyView(thread);
|
|
221
581
|
const queue = this.writes.get(thread);
|
|
222
582
|
this.writes.delete(thread);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
583
|
+
if (queue) {
|
|
584
|
+
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
585
|
+
this.release(queue);
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
await this.storage.resetThread(thread, true, this.scopeVersion);
|
|
589
|
+
await this.reload(thread);
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
|
|
593
|
+
}
|
|
227
594
|
}
|
|
228
|
-
async close() {
|
|
229
|
-
|
|
595
|
+
async close(beforeDispose) {
|
|
596
|
+
if (this.closed)
|
|
597
|
+
return;
|
|
230
598
|
this.closing = true;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
599
|
+
try {
|
|
600
|
+
await Promise.all([...this.enqueuing]);
|
|
601
|
+
this.syncTasks.close();
|
|
602
|
+
this.writeTasks.close();
|
|
603
|
+
const flushes = [...this.writes.keys()].map(thread => this.flushWrites(thread));
|
|
604
|
+
const results = await Promise.allSettled([...flushes, ...this.activeWrites, ...(this.revokingScope ? [this.revokingScope] : [])]);
|
|
605
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
606
|
+
if (failed)
|
|
607
|
+
throw failed.reason;
|
|
608
|
+
await beforeDispose;
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
this.closed = true;
|
|
612
|
+
this.abort.abort();
|
|
613
|
+
this.channel?.close();
|
|
614
|
+
this.watches.clear();
|
|
615
|
+
this.allListeners.clear();
|
|
616
|
+
this.accessListeners.clear();
|
|
617
|
+
this.storage.close();
|
|
618
|
+
}
|
|
240
619
|
}
|
|
241
620
|
}
|