@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.20
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 +54 -3
- package/lib/runtime/chatClient.d.ts +8 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +69 -7
- package/lib/runtime/chatSession.js +331 -25
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/createChat.d.ts +26 -0
- package/lib/runtime/createChat.js +42 -0
- package/lib/runtime/durableSender.js +4 -1
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageClient.d.ts +22 -5
- package/lib/runtime/messageClient.js +302 -48
- package/lib/runtime/messageReaders.d.ts +8 -1
- package/lib/runtime/messageReaders.js +56 -3
- package/lib/runtime/messageStore.d.ts +23 -1
- package/lib/runtime/messageStore.js +88 -8
- package/lib/runtime/readCounts.d.ts +12 -1
- package/lib/runtime/readCounts.js +66 -6
- package/lib/runtime/readIndex.d.ts +5 -1
- package/lib/runtime/readIndex.js +28 -4
- package/lib/runtime/readingClient.d.ts +4 -0
- package/lib/runtime/readingClient.js +5 -1
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +29 -0
- package/lib/runtime/responseCache.js +111 -0
- package/package.json +1 -1
|
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
|
|
|
15
15
|
export type MessageSnapshot<T> = {
|
|
16
16
|
messages: readonly T[];
|
|
17
17
|
hasEarlier: boolean;
|
|
18
|
+
resetVersion: number;
|
|
19
|
+
complete: boolean;
|
|
18
20
|
};
|
|
19
21
|
export type MessageClientOptions<T extends StoredMessage> = {
|
|
20
22
|
scope: MessageScope;
|
|
@@ -25,12 +27,16 @@ export type MessageClientOptions<T extends StoredMessage> = {
|
|
|
25
27
|
batchSize?: number;
|
|
26
28
|
writeWindowMs?: number;
|
|
27
29
|
syncWindowMs?: number;
|
|
30
|
+
transactionBytes?: number;
|
|
31
|
+
queueBytes?: number;
|
|
32
|
+
freshnessMs?: number;
|
|
28
33
|
};
|
|
29
34
|
/** Local-first message facade; hosts never select a cache or network source. */
|
|
30
35
|
export declare class MessageClient<T extends StoredMessage> {
|
|
31
36
|
private options;
|
|
32
37
|
private storage;
|
|
33
38
|
private watches;
|
|
39
|
+
private allListeners;
|
|
34
40
|
private reads;
|
|
35
41
|
private syncing;
|
|
36
42
|
private paging;
|
|
@@ -44,27 +50,38 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
44
50
|
private closing;
|
|
45
51
|
private revoked;
|
|
46
52
|
private batchSize;
|
|
53
|
+
private transactionBytes;
|
|
54
|
+
private queueBytes;
|
|
55
|
+
private pendingBytes;
|
|
56
|
+
private pendingRecords;
|
|
57
|
+
private legacyImports;
|
|
47
58
|
constructor(options: MessageClientOptions<T>);
|
|
48
59
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
49
60
|
subscribe(thread: string, listener: () => void): () => void;
|
|
50
|
-
|
|
61
|
+
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
62
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
63
|
+
restore(since: number, threadId?: string): Promise<void>;
|
|
64
|
+
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
51
65
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
52
66
|
invalidate(thread: string): void;
|
|
67
|
+
private fallback;
|
|
68
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
69
|
+
importLegacy(thread: string, messages: T[]): Promise<void>;
|
|
53
70
|
private reload;
|
|
54
71
|
/** Events are visible only after their atomic batch commits. */
|
|
55
72
|
receive(thread: string, messages: T[]): Promise<void>;
|
|
56
73
|
private persist;
|
|
74
|
+
private messageBytes;
|
|
75
|
+
private release;
|
|
57
76
|
private flushWrites;
|
|
58
77
|
private runWrites;
|
|
59
78
|
synchronize(thread: string): Promise<void>;
|
|
60
79
|
earlier(thread: string, before: {
|
|
61
80
|
timestamp: number;
|
|
62
81
|
id: string;
|
|
63
|
-
}, limit?: number): Promise<
|
|
64
|
-
messages: T[];
|
|
65
|
-
hasEarlier: boolean;
|
|
66
|
-
}>;
|
|
82
|
+
}, limit?: number): Promise<MessageSnapshot<T>>;
|
|
67
83
|
private fillWindow;
|
|
84
|
+
private denyView;
|
|
68
85
|
revoke(thread: string): Promise<void>;
|
|
69
86
|
close(): Promise<void>;
|
|
70
87
|
}
|
|
@@ -5,6 +5,7 @@ export class MessageClient {
|
|
|
5
5
|
options;
|
|
6
6
|
storage;
|
|
7
7
|
watches = new Map();
|
|
8
|
+
allListeners = new Set();
|
|
8
9
|
reads = new Map();
|
|
9
10
|
syncing = new Map();
|
|
10
11
|
paging = new Map();
|
|
@@ -18,46 +19,132 @@ export class MessageClient {
|
|
|
18
19
|
closing = false;
|
|
19
20
|
revoked = new Set();
|
|
20
21
|
batchSize;
|
|
22
|
+
transactionBytes;
|
|
23
|
+
queueBytes;
|
|
24
|
+
pendingBytes = 0;
|
|
25
|
+
pendingRecords = 0;
|
|
26
|
+
legacyImports = 0;
|
|
21
27
|
constructor(options) {
|
|
22
28
|
this.options = options;
|
|
23
29
|
this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
|
|
24
30
|
this.batchSize = options.batchSize ?? 128;
|
|
31
|
+
this.transactionBytes = options.transactionBytes ?? 1_048_576;
|
|
32
|
+
this.queueBytes = options.queueBytes ?? 8_388_608;
|
|
33
|
+
if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
|
|
34
|
+
throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
|
|
25
35
|
if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
|
|
26
36
|
throw new Error("INVALID_MESSAGE_BATCH_SIZE");
|
|
27
|
-
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) =>
|
|
37
|
+
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
38
|
+
this.invalidate(thread);
|
|
39
|
+
else
|
|
40
|
+
options.onError?.(error, thread); });
|
|
28
41
|
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
|
|
29
42
|
if (typeof BroadcastChannel !== "undefined") {
|
|
30
|
-
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
|
|
31
|
-
this.channel.onmessage = event => {
|
|
32
|
-
|
|
43
|
+
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
|
|
44
|
+
this.channel.onmessage = event => {
|
|
45
|
+
if (this.closed)
|
|
46
|
+
return;
|
|
47
|
+
if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
|
|
48
|
+
const denied = event.data;
|
|
49
|
+
void (async () => {
|
|
50
|
+
const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
|
|
51
|
+
if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
|
|
52
|
+
return;
|
|
53
|
+
this.denyView(denied.thread, denied.version);
|
|
54
|
+
options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
|
|
58
|
+
const thread = event.data.thread;
|
|
59
|
+
void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
|
|
60
|
+
options.onError?.(error, thread); });
|
|
61
|
+
}
|
|
62
|
+
else if (typeof event.data === "string" && this.watches.has(event.data))
|
|
63
|
+
void this.reload(event.data).catch(error => { if (!this.closed)
|
|
64
|
+
options.onError?.(error, event.data); });
|
|
65
|
+
};
|
|
33
66
|
}
|
|
34
67
|
}
|
|
35
68
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
36
69
|
subscribe(thread, listener) {
|
|
37
70
|
let watch = this.watches.get(thread);
|
|
38
71
|
if (!watch) {
|
|
39
|
-
watch = {
|
|
72
|
+
watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
|
|
40
73
|
this.watches.set(thread, watch);
|
|
41
74
|
}
|
|
42
75
|
watch.listeners.add(listener);
|
|
43
76
|
return () => { watch.listeners.delete(listener); };
|
|
44
77
|
}
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
79
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
80
|
+
async restore(since, threadId) {
|
|
81
|
+
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
|
|
82
|
+
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
83
|
+
while (queue.length && !this.closed && !this.closing) {
|
|
84
|
+
const window = queue.shift();
|
|
85
|
+
try {
|
|
86
|
+
await this.synchronize(window.thread);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!this.closed && !this.closing)
|
|
90
|
+
this.options.onError?.(error, window.thread);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
read(thread, limit = 100, refresh = false) {
|
|
96
|
+
if (this.closed || this.closing)
|
|
47
97
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
48
98
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
49
99
|
return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
|
|
50
|
-
const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
|
|
100
|
+
const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
|
|
51
101
|
if (running)
|
|
52
102
|
return running;
|
|
53
103
|
const task = (async () => {
|
|
54
104
|
const local = await this.storage.read(thread, limit);
|
|
55
|
-
if (!local.coverage?.complete)
|
|
56
|
-
|
|
105
|
+
if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
106
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
107
|
+
if (!refresh && !local.coverage?.revoked) {
|
|
108
|
+
const fallback = await this.fallback(thread, limit);
|
|
109
|
+
if (fallback.messages.length)
|
|
110
|
+
return fallback;
|
|
111
|
+
}
|
|
112
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
113
|
+
}
|
|
114
|
+
this.revoked.delete(thread);
|
|
115
|
+
try {
|
|
116
|
+
await this.synchronize(thread);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (refresh || [401, 403].includes(error?.status ?? 0))
|
|
120
|
+
throw error;
|
|
121
|
+
const fallback = await this.fallback(thread, limit);
|
|
122
|
+
if (!fallback.messages.length)
|
|
123
|
+
throw error;
|
|
124
|
+
this.options.onError?.(error, thread);
|
|
125
|
+
return fallback;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
57
128
|
if (this.closed)
|
|
58
129
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
59
|
-
|
|
60
|
-
|
|
130
|
+
let result;
|
|
131
|
+
try {
|
|
132
|
+
result = await this.fillWindow(thread, limit);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
136
|
+
throw error;
|
|
137
|
+
const cached = await this.fallback(thread, limit);
|
|
138
|
+
if (!cached.messages.length)
|
|
139
|
+
throw error;
|
|
140
|
+
this.options.onError?.(error, thread);
|
|
141
|
+
return cached;
|
|
142
|
+
}
|
|
143
|
+
const snapshot = await this.reload(thread);
|
|
144
|
+
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
145
|
+
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
146
|
+
this.invalidate(thread);
|
|
147
|
+
return limit === 100 ? snapshot : result;
|
|
61
148
|
})().finally(() => { if (this.reads.get(key) === task)
|
|
62
149
|
this.reads.delete(key); });
|
|
63
150
|
this.reads.set(key, task);
|
|
@@ -68,10 +155,51 @@ export class MessageClient {
|
|
|
68
155
|
if (!this.closed && !this.closing && !this.revoked.has(thread))
|
|
69
156
|
this.syncTasks.schedule(thread, () => this.synchronize(thread));
|
|
70
157
|
}
|
|
71
|
-
async
|
|
72
|
-
const page = await this.storage.
|
|
158
|
+
async fallback(thread, limit, before) {
|
|
159
|
+
const page = await this.storage.readFallback(thread, limit, before);
|
|
160
|
+
if (!before && page.messages.length)
|
|
161
|
+
await this.reload(thread, true);
|
|
162
|
+
return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
|
|
163
|
+
}
|
|
164
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
165
|
+
importLegacy(thread, messages) {
|
|
166
|
+
if (this.closed || this.closing)
|
|
167
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
168
|
+
if (messages.length > 128)
|
|
169
|
+
return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
|
|
170
|
+
if (this.legacyImports >= 4)
|
|
171
|
+
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
172
|
+
let records;
|
|
173
|
+
try {
|
|
174
|
+
let bytes = 0;
|
|
175
|
+
for (const message of messages) {
|
|
176
|
+
bytes += this.messageBytes(message);
|
|
177
|
+
if (bytes > Math.min(this.transactionBytes, 1_048_576))
|
|
178
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
179
|
+
}
|
|
180
|
+
records = structuredClone(messages);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
return Promise.reject(error);
|
|
184
|
+
}
|
|
185
|
+
this.legacyImports++;
|
|
186
|
+
const task = this.storage.importLegacy(thread, records).then(async () => {
|
|
187
|
+
const page = await this.storage.read(thread, 1);
|
|
188
|
+
await this.reload(thread, !page.coverage?.complete);
|
|
189
|
+
this.channel?.postMessage({ type: "legacy", thread });
|
|
190
|
+
}).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
|
|
191
|
+
this.activeWrites.add(task);
|
|
192
|
+
return task;
|
|
193
|
+
}
|
|
194
|
+
async reload(thread, fallback = false) {
|
|
195
|
+
const limit = 100;
|
|
196
|
+
const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
|
|
73
197
|
if (this.closed)
|
|
74
198
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
199
|
+
if (this.revoked.has(thread)) {
|
|
200
|
+
page.messages = [];
|
|
201
|
+
page.hasEarlier = false;
|
|
202
|
+
}
|
|
75
203
|
let watch = this.watches.get(thread);
|
|
76
204
|
const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
|
|
77
205
|
const messages = page.messages.map(message => {
|
|
@@ -79,49 +207,97 @@ export class MessageClient {
|
|
|
79
207
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
80
208
|
});
|
|
81
209
|
const old = watch?.snapshot;
|
|
82
|
-
|
|
210
|
+
const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
|
|
211
|
+
const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
|
|
212
|
+
if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
83
213
|
return old;
|
|
84
|
-
const snapshot = { messages,
|
|
214
|
+
const snapshot = { messages, resetVersion, complete, hasEarlier };
|
|
85
215
|
if (!watch) {
|
|
86
|
-
watch = {
|
|
216
|
+
watch = { snapshot, listeners: new Set() };
|
|
87
217
|
this.watches.set(thread, watch);
|
|
88
218
|
}
|
|
89
|
-
watch.limit = limit;
|
|
90
219
|
watch.snapshot = snapshot;
|
|
91
220
|
watch.listeners.forEach(listener => listener());
|
|
221
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
92
222
|
return snapshot;
|
|
93
223
|
}
|
|
94
224
|
/** Events are visible only after their atomic batch commits. */
|
|
95
225
|
receive(thread, messages) {
|
|
96
|
-
if (this.closed || this.closing
|
|
226
|
+
if (this.closed || this.closing)
|
|
97
227
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
228
|
+
if (this.revoked.has(thread))
|
|
229
|
+
return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
|
|
98
230
|
const queue = this.writes.get(thread) ?? [];
|
|
99
|
-
if (messages.length > 1000 ||
|
|
231
|
+
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
100
232
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
101
|
-
|
|
233
|
+
let bytes = 0;
|
|
234
|
+
try {
|
|
235
|
+
for (const message of messages) {
|
|
236
|
+
bytes += this.messageBytes(message);
|
|
237
|
+
if (this.pendingBytes + bytes > this.queueBytes)
|
|
238
|
+
throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
|
|
239
|
+
}
|
|
240
|
+
messages = structuredClone(messages);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return Promise.reject(error);
|
|
244
|
+
}
|
|
245
|
+
this.pendingBytes += bytes;
|
|
246
|
+
this.pendingRecords += messages.length;
|
|
247
|
+
const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
|
|
102
248
|
this.writes.set(thread, queue);
|
|
103
249
|
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
104
250
|
return promise;
|
|
105
251
|
}
|
|
106
|
-
async persist(thread, messages, checkpoint, complete, nextBefore) {
|
|
252
|
+
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
|
|
253
|
+
const initial = (await this.storage.read(thread, 1)).coverage;
|
|
254
|
+
const version = expectedVersion ?? initial?.resetVersion ?? 0;
|
|
255
|
+
const guard = { version, authoritative: complete === true };
|
|
107
256
|
let changed = 0;
|
|
257
|
+
let navigationChanged = false;
|
|
108
258
|
// Coverage advances only after all bounded batches have durably committed.
|
|
109
|
-
|
|
259
|
+
let batch = [], bytes = 0;
|
|
260
|
+
const commit = async () => {
|
|
261
|
+
if (!batch.length)
|
|
262
|
+
return;
|
|
110
263
|
if (this.closed || this.revoked.has(thread))
|
|
111
264
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
112
|
-
changed += await this.storage.commit(thread,
|
|
265
|
+
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
266
|
+
batch = [];
|
|
267
|
+
bytes = 0;
|
|
268
|
+
};
|
|
269
|
+
for (const message of messages) {
|
|
270
|
+
const size = this.messageBytes(message);
|
|
271
|
+
if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
|
|
272
|
+
await commit();
|
|
273
|
+
batch.push(message);
|
|
274
|
+
bytes += size;
|
|
113
275
|
}
|
|
276
|
+
await commit();
|
|
114
277
|
if (complete !== undefined || checkpoint !== undefined) {
|
|
115
278
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
116
279
|
if (this.closed || this.revoked.has(thread))
|
|
117
280
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
118
|
-
|
|
281
|
+
navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
|
|
282
|
+
await this.storage.commit(thread, [], { resetVersion: version, revoked: complete === true ? false : old?.revoked, syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore }, guard);
|
|
119
283
|
}
|
|
120
|
-
if (changed) {
|
|
284
|
+
if (changed || navigationChanged) {
|
|
121
285
|
await this.reload(thread);
|
|
122
286
|
this.channel?.postMessage(thread);
|
|
123
287
|
}
|
|
124
288
|
}
|
|
289
|
+
messageBytes(message) {
|
|
290
|
+
const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
291
|
+
if (bytes > this.transactionBytes)
|
|
292
|
+
throw new Error("MESSAGE_RECORD_TOO_LARGE");
|
|
293
|
+
return bytes;
|
|
294
|
+
}
|
|
295
|
+
release(queue) {
|
|
296
|
+
for (const write of queue) {
|
|
297
|
+
this.pendingBytes -= write.bytes;
|
|
298
|
+
this.pendingRecords -= write.messages.length;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
125
301
|
flushWrites(thread) {
|
|
126
302
|
const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
|
|
127
303
|
this.activeWrites.add(task);
|
|
@@ -133,12 +309,29 @@ export class MessageClient {
|
|
|
133
309
|
return;
|
|
134
310
|
this.writes.delete(thread);
|
|
135
311
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
312
|
+
const groups = new Map();
|
|
313
|
+
for (const write of queue) {
|
|
314
|
+
const group = groups.get(write.version) ?? [];
|
|
315
|
+
group.push(write);
|
|
316
|
+
groups.set(write.version, group);
|
|
317
|
+
}
|
|
318
|
+
let failed = false, failure;
|
|
319
|
+
for (const [version, writes] of groups) {
|
|
320
|
+
try {
|
|
321
|
+
await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
|
|
322
|
+
writes.forEach(write => write.resolve());
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
failed = true;
|
|
326
|
+
failure = error;
|
|
327
|
+
writes.forEach(write => write.reject(error));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (failed)
|
|
331
|
+
throw failure;
|
|
138
332
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
throw error;
|
|
333
|
+
finally {
|
|
334
|
+
this.release(queue);
|
|
142
335
|
}
|
|
143
336
|
}
|
|
144
337
|
synchronize(thread) {
|
|
@@ -149,6 +342,7 @@ export class MessageClient {
|
|
|
149
342
|
return running;
|
|
150
343
|
const task = (async () => {
|
|
151
344
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
345
|
+
let version = coverage?.resetVersion ?? 0;
|
|
152
346
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
153
347
|
let page = after && this.options.transport.changes
|
|
154
348
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
@@ -156,7 +350,7 @@ export class MessageClient {
|
|
|
156
350
|
if (page.resetRequired) {
|
|
157
351
|
if (this.closed || this.revoked.has(thread))
|
|
158
352
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
159
|
-
await this.storage.
|
|
353
|
+
version = await this.storage.resetThread(thread);
|
|
160
354
|
after = undefined;
|
|
161
355
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
162
356
|
}
|
|
@@ -165,7 +359,7 @@ export class MessageClient {
|
|
|
165
359
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
166
360
|
if (page.resetRequired)
|
|
167
361
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
168
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
|
|
362
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
|
|
169
363
|
if (!page.hasMoreChanges)
|
|
170
364
|
break;
|
|
171
365
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -173,7 +367,17 @@ export class MessageClient {
|
|
|
173
367
|
after = page.checkpoint;
|
|
174
368
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
175
369
|
}
|
|
176
|
-
})().
|
|
370
|
+
})().catch(async (error) => {
|
|
371
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
372
|
+
try {
|
|
373
|
+
await this.revoke(thread);
|
|
374
|
+
}
|
|
375
|
+
catch (cacheError) {
|
|
376
|
+
Object.assign(error, { cacheError });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
}).finally(() => { if (this.syncing.get(thread) === task)
|
|
177
381
|
this.syncing.delete(thread); });
|
|
178
382
|
this.syncing.set(thread, task);
|
|
179
383
|
return task;
|
|
@@ -183,12 +387,27 @@ export class MessageClient {
|
|
|
183
387
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
184
388
|
if (this.revoked.has(thread))
|
|
185
389
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
186
|
-
|
|
187
|
-
await this.
|
|
188
|
-
|
|
390
|
+
try {
|
|
391
|
+
if (!(await this.storage.read(thread, 1)).coverage?.complete) {
|
|
392
|
+
if (typeof navigator !== "undefined" && navigator.onLine === false)
|
|
393
|
+
throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
394
|
+
await this.synchronize(thread);
|
|
395
|
+
}
|
|
396
|
+
return await this.fillWindow(thread, limit, before);
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
400
|
+
throw error;
|
|
401
|
+
const fallback = await this.fallback(thread, limit, before);
|
|
402
|
+
if (!fallback.messages.length)
|
|
403
|
+
throw error;
|
|
404
|
+
this.options.onError?.(error, thread);
|
|
405
|
+
return fallback;
|
|
406
|
+
}
|
|
189
407
|
}
|
|
190
408
|
async fillWindow(thread, limit, before) {
|
|
191
409
|
let page = await this.storage.read(thread, limit, before);
|
|
410
|
+
let resets = 0;
|
|
192
411
|
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
193
412
|
const pending = this.paging.get(thread);
|
|
194
413
|
if (pending) {
|
|
@@ -196,34 +415,68 @@ export class MessageClient {
|
|
|
196
415
|
page = await this.storage.read(thread, limit, before);
|
|
197
416
|
continue;
|
|
198
417
|
}
|
|
199
|
-
const token = page.coverage.nextBefore;
|
|
418
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
|
|
200
419
|
const task = (async () => {
|
|
201
420
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
202
421
|
if (this.closed || this.revoked.has(thread))
|
|
203
422
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
204
423
|
if (remote.resetRequired) {
|
|
205
|
-
|
|
206
|
-
|
|
424
|
+
if (++resets > 2)
|
|
425
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
426
|
+
await this.storage.resetThread(thread);
|
|
427
|
+
await this.synchronize(thread);
|
|
428
|
+
return;
|
|
207
429
|
}
|
|
208
430
|
if (remote.nextBefore === token)
|
|
209
431
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
210
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
211
|
-
})().
|
|
432
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
|
|
433
|
+
})().catch(async (error) => {
|
|
434
|
+
if (!this.closed && !this.closing && error?.status === 403) {
|
|
435
|
+
try {
|
|
436
|
+
await this.revoke(thread);
|
|
437
|
+
}
|
|
438
|
+
catch (cacheError) {
|
|
439
|
+
Object.assign(error, { cacheError });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
throw error;
|
|
443
|
+
}).finally(() => { if (this.paging.get(thread) === task)
|
|
212
444
|
this.paging.delete(thread); });
|
|
213
445
|
this.paging.set(thread, task);
|
|
214
446
|
await task;
|
|
215
447
|
page = await this.storage.read(thread, limit, before);
|
|
216
448
|
}
|
|
217
|
-
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
449
|
+
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
|
|
218
450
|
}
|
|
219
|
-
|
|
451
|
+
denyView(thread, version) {
|
|
220
452
|
this.revoked.add(thread);
|
|
453
|
+
const watch = this.watches.get(thread);
|
|
454
|
+
const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
|
|
455
|
+
if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
|
|
456
|
+
return;
|
|
457
|
+
if (watch) {
|
|
458
|
+
watch.snapshot = snapshot;
|
|
459
|
+
watch.listeners.forEach(listener => listener());
|
|
460
|
+
}
|
|
461
|
+
else
|
|
462
|
+
this.watches.set(thread, { snapshot, listeners: new Set() });
|
|
463
|
+
this.allListeners.forEach(listener => listener(thread));
|
|
464
|
+
}
|
|
465
|
+
async revoke(thread) {
|
|
466
|
+
this.denyView(thread);
|
|
221
467
|
const queue = this.writes.get(thread);
|
|
222
468
|
this.writes.delete(thread);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
469
|
+
if (queue) {
|
|
470
|
+
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
471
|
+
this.release(queue);
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
await this.storage.resetThread(thread, true);
|
|
475
|
+
await this.reload(thread);
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
|
|
479
|
+
}
|
|
227
480
|
}
|
|
228
481
|
async close() {
|
|
229
482
|
// Flush accepted event batches before closing; new requests are rejected afterwards.
|
|
@@ -236,6 +489,7 @@ export class MessageClient {
|
|
|
236
489
|
this.abort.abort();
|
|
237
490
|
this.channel?.close();
|
|
238
491
|
this.watches.clear();
|
|
492
|
+
this.allListeners.clear();
|
|
239
493
|
this.storage.close();
|
|
240
494
|
}
|
|
241
495
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
2
|
export type MessageReadersPage = {
|
|
2
3
|
appId: string;
|
|
3
4
|
threadId: string;
|
|
@@ -21,6 +22,8 @@ export type MessageReadersOptions = {
|
|
|
21
22
|
token: string;
|
|
22
23
|
hubHttpUrl: string;
|
|
23
24
|
fetch?: typeof fetch;
|
|
25
|
+
scope?: MessageScope;
|
|
26
|
+
onError?: (error: unknown) => void;
|
|
24
27
|
};
|
|
25
28
|
export declare class MessageReadersError extends Error {
|
|
26
29
|
readonly status: number;
|
|
@@ -32,8 +35,12 @@ export declare class MessageReadersClient {
|
|
|
32
35
|
private options;
|
|
33
36
|
private pending;
|
|
34
37
|
private lifetime;
|
|
38
|
+
private cache?;
|
|
39
|
+
private revoked;
|
|
40
|
+
private generation;
|
|
35
41
|
constructor(options: MessageReadersOptions);
|
|
36
42
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
43
|
+
private cachedRequest;
|
|
37
44
|
private request;
|
|
38
|
-
close(): void
|
|
45
|
+
close(): Promise<void>;
|
|
39
46
|
}
|