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