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