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