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