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