@pmate/chat-sdk 0.12.0-next.20 → 0.12.0-next.22
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 +8 -0
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatSession.d.ts +10 -0
- package/lib/runtime/chatSession.js +129 -21
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +154 -34
- package/lib/runtime/messageClient.d.ts +17 -1
- package/lib/runtime/messageClient.js +164 -39
- package/lib/runtime/messageReaders.d.ts +4 -0
- package/lib/runtime/messageReaders.js +12 -4
- package/lib/runtime/messageStore.d.ts +16 -3
- package/lib/runtime/messageStore.js +74 -14
- package/lib/runtime/readCounts.d.ts +3 -0
- package/lib/runtime/readCounts.js +23 -5
- package/lib/runtime/readIndex.d.ts +21 -2
- package/lib/runtime/readIndex.js +47 -2
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +11 -0
- package/lib/runtime/readingClient.js +78 -11
- package/lib/runtime/responseCache.d.ts +15 -0
- package/lib/runtime/responseCache.js +64 -2
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ export class MessageClient {
|
|
|
5
5
|
options;
|
|
6
6
|
storage;
|
|
7
7
|
watches = new Map();
|
|
8
|
+
accessListeners = new Set();
|
|
8
9
|
allListeners = new Set();
|
|
9
10
|
reads = new Map();
|
|
10
11
|
syncing = new Map();
|
|
@@ -24,6 +25,15 @@ export class MessageClient {
|
|
|
24
25
|
pendingBytes = 0;
|
|
25
26
|
pendingRecords = 0;
|
|
26
27
|
legacyImports = 0;
|
|
28
|
+
revokingScope;
|
|
29
|
+
scopeReady;
|
|
30
|
+
scopeVersion = 0;
|
|
31
|
+
scopeAllowed = true;
|
|
32
|
+
scopeDenied = false;
|
|
33
|
+
enqueuing = new Set();
|
|
34
|
+
ensureScope() {
|
|
35
|
+
return this.scopeReady ??= this.storage.scopeAccess().then(access => { this.scopeVersion = access.version; this.scopeAllowed = !access.revoked && !this.scopeDenied; });
|
|
36
|
+
}
|
|
27
37
|
constructor(options) {
|
|
28
38
|
this.options = options;
|
|
29
39
|
this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
|
|
@@ -44,10 +54,22 @@ export class MessageClient {
|
|
|
44
54
|
this.channel.onmessage = event => {
|
|
45
55
|
if (this.closed)
|
|
46
56
|
return;
|
|
47
|
-
if (event.data?.type === "revoked" &&
|
|
57
|
+
if (event.data?.type === "scope-revoked" && Number.isSafeInteger(event.data.version)) {
|
|
58
|
+
const version = event.data.version;
|
|
59
|
+
void this.ensureScope().then(async () => {
|
|
60
|
+
const current = await this.storage.scopeAccess();
|
|
61
|
+
if (!this.closed && (this.scopeVersion < version || current.revoked)) {
|
|
62
|
+
this.denyScope();
|
|
63
|
+
options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), "");
|
|
64
|
+
}
|
|
65
|
+
}).catch(error => options.onError?.(error, ""));
|
|
66
|
+
}
|
|
67
|
+
else if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
|
|
48
68
|
const denied = event.data;
|
|
49
69
|
void (async () => {
|
|
50
70
|
const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
|
|
71
|
+
if ((current?.scopeVersion ?? 0) !== (denied.scopeVersion ?? 0))
|
|
72
|
+
return;
|
|
51
73
|
if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
|
|
52
74
|
return;
|
|
53
75
|
this.denyView(denied.thread, denied.version);
|
|
@@ -65,6 +87,15 @@ export class MessageClient {
|
|
|
65
87
|
};
|
|
66
88
|
}
|
|
67
89
|
}
|
|
90
|
+
/** Shared guard for sibling caches; it never authorizes a denied old instance. */
|
|
91
|
+
subscribeAccessRevoked(listener) { this.accessListeners.add(listener); return () => { this.accessListeners.delete(listener); }; }
|
|
92
|
+
async scopeAccess() {
|
|
93
|
+
await this.ensureScope();
|
|
94
|
+
if (this.closed)
|
|
95
|
+
return { version: this.scopeVersion, allowed: false };
|
|
96
|
+
const current = await this.storage.scopeAccess();
|
|
97
|
+
return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
|
|
98
|
+
}
|
|
68
99
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
69
100
|
subscribe(thread, listener) {
|
|
70
101
|
let watch = this.watches.get(thread);
|
|
@@ -101,7 +132,14 @@ export class MessageClient {
|
|
|
101
132
|
if (running)
|
|
102
133
|
return running;
|
|
103
134
|
const task = (async () => {
|
|
135
|
+
await this.ensureScope();
|
|
136
|
+
if (this.scopeDenied)
|
|
137
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
104
138
|
const local = await this.storage.read(thread, limit);
|
|
139
|
+
if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
140
|
+
this.denyScope();
|
|
141
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
142
|
+
}
|
|
105
143
|
if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
|
|
106
144
|
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
|
107
145
|
if (!refresh && !local.coverage?.revoked) {
|
|
@@ -116,6 +154,10 @@ export class MessageClient {
|
|
|
116
154
|
await this.synchronize(thread);
|
|
117
155
|
}
|
|
118
156
|
catch (error) {
|
|
157
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
158
|
+
this.denyScope();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
119
161
|
if (refresh || [401, 403].includes(error?.status ?? 0))
|
|
120
162
|
throw error;
|
|
121
163
|
const fallback = await this.fallback(thread, limit);
|
|
@@ -132,6 +174,10 @@ export class MessageClient {
|
|
|
132
174
|
result = await this.fillWindow(thread, limit);
|
|
133
175
|
}
|
|
134
176
|
catch (error) {
|
|
177
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
178
|
+
this.denyScope();
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
135
181
|
if ([401, 403].includes(error?.status ?? 0))
|
|
136
182
|
throw error;
|
|
137
183
|
const cached = await this.fallback(thread, limit);
|
|
@@ -156,7 +202,12 @@ export class MessageClient {
|
|
|
156
202
|
this.syncTasks.schedule(thread, () => this.synchronize(thread));
|
|
157
203
|
}
|
|
158
204
|
async fallback(thread, limit, before) {
|
|
205
|
+
await this.ensureScope();
|
|
159
206
|
const page = await this.storage.readFallback(thread, limit, before);
|
|
207
|
+
if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
208
|
+
this.denyScope();
|
|
209
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
210
|
+
}
|
|
160
211
|
if (!before && page.messages.length)
|
|
161
212
|
await this.reload(thread, true);
|
|
162
213
|
return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
|
|
@@ -192,11 +243,16 @@ export class MessageClient {
|
|
|
192
243
|
return task;
|
|
193
244
|
}
|
|
194
245
|
async reload(thread, fallback = false) {
|
|
246
|
+
await this.ensureScope();
|
|
195
247
|
const limit = 100;
|
|
196
248
|
const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
|
|
197
249
|
if (this.closed)
|
|
198
250
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
199
|
-
if (this.
|
|
251
|
+
if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
|
|
252
|
+
this.denyScope();
|
|
253
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
254
|
+
}
|
|
255
|
+
if (this.scopeDenied || this.revoked.has(thread)) {
|
|
200
256
|
page.messages = [];
|
|
201
257
|
page.hasEarlier = false;
|
|
202
258
|
}
|
|
@@ -207,7 +263,7 @@ export class MessageClient {
|
|
|
207
263
|
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
208
264
|
});
|
|
209
265
|
const old = watch?.snapshot;
|
|
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);
|
|
266
|
+
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);
|
|
211
267
|
const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
|
|
212
268
|
if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
213
269
|
return old;
|
|
@@ -227,7 +283,6 @@ export class MessageClient {
|
|
|
227
283
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
228
284
|
if (this.revoked.has(thread))
|
|
229
285
|
return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
|
|
230
|
-
const queue = this.writes.get(thread) ?? [];
|
|
231
286
|
if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
|
|
232
287
|
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
233
288
|
let bytes = 0;
|
|
@@ -244,15 +299,25 @@ export class MessageClient {
|
|
|
244
299
|
}
|
|
245
300
|
this.pendingBytes += bytes;
|
|
246
301
|
this.pendingRecords += messages.length;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.
|
|
250
|
-
|
|
302
|
+
let resolve, reject;
|
|
303
|
+
const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
304
|
+
const enqueue = this.ensureScope().then(() => {
|
|
305
|
+
if (this.closed || this.scopeDenied)
|
|
306
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
307
|
+
if (!this.scopeAllowed)
|
|
308
|
+
throw new Error("MESSAGE_SCOPE_NOT_READY");
|
|
309
|
+
const queue = this.writes.get(thread) ?? [];
|
|
310
|
+
queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, scopeVersion: this.scopeVersion, resolve, reject });
|
|
311
|
+
this.writes.set(thread, queue);
|
|
312
|
+
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
313
|
+
}).catch(error => { this.pendingBytes -= bytes; this.pendingRecords -= messages.length; reject(error); }).finally(() => this.enqueuing.delete(enqueue));
|
|
314
|
+
this.enqueuing.add(enqueue);
|
|
315
|
+
return committed;
|
|
251
316
|
}
|
|
252
|
-
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
|
|
317
|
+
async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
|
|
253
318
|
const initial = (await this.storage.read(thread, 1)).coverage;
|
|
254
319
|
const version = expectedVersion ?? initial?.resetVersion ?? 0;
|
|
255
|
-
const guard = { version, authoritative: complete === true };
|
|
320
|
+
const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
|
|
256
321
|
let changed = 0;
|
|
257
322
|
let navigationChanged = false;
|
|
258
323
|
// Coverage advances only after all bounded batches have durably committed.
|
|
@@ -260,7 +325,7 @@ export class MessageClient {
|
|
|
260
325
|
const commit = async () => {
|
|
261
326
|
if (!batch.length)
|
|
262
327
|
return;
|
|
263
|
-
if (this.closed || this.revoked.has(thread))
|
|
328
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
264
329
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
265
330
|
changed += await this.storage.commit(thread, batch, undefined, guard);
|
|
266
331
|
batch = [];
|
|
@@ -276,11 +341,13 @@ export class MessageClient {
|
|
|
276
341
|
await commit();
|
|
277
342
|
if (complete !== undefined || checkpoint !== undefined) {
|
|
278
343
|
const old = (await this.storage.read(thread, 1)).coverage;
|
|
279
|
-
if (this.closed || this.revoked.has(thread))
|
|
344
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
280
345
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
281
346
|
navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
|
|
282
347
|
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);
|
|
283
348
|
}
|
|
349
|
+
if (complete === true)
|
|
350
|
+
this.scopeAllowed = true;
|
|
284
351
|
if (changed || navigationChanged) {
|
|
285
352
|
await this.reload(thread);
|
|
286
353
|
this.channel?.postMessage(thread);
|
|
@@ -311,14 +378,14 @@ export class MessageClient {
|
|
|
311
378
|
try {
|
|
312
379
|
const groups = new Map();
|
|
313
380
|
for (const write of queue) {
|
|
314
|
-
const group = groups.get(
|
|
381
|
+
const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
|
|
315
382
|
group.push(write);
|
|
316
|
-
groups.set(
|
|
383
|
+
groups.set(key, group);
|
|
317
384
|
}
|
|
318
385
|
let failed = false, failure;
|
|
319
|
-
for (const
|
|
386
|
+
for (const writes of groups.values()) {
|
|
320
387
|
try {
|
|
321
|
-
await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
|
|
388
|
+
await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
|
|
322
389
|
writes.forEach(write => write.resolve());
|
|
323
390
|
}
|
|
324
391
|
catch (error) {
|
|
@@ -335,31 +402,37 @@ export class MessageClient {
|
|
|
335
402
|
}
|
|
336
403
|
}
|
|
337
404
|
synchronize(thread) {
|
|
338
|
-
if (this.closed || this.closing || this.revoked.has(thread))
|
|
405
|
+
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
339
406
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
340
407
|
const running = this.syncing.get(thread);
|
|
341
408
|
if (running)
|
|
342
409
|
return running;
|
|
343
410
|
const task = (async () => {
|
|
411
|
+
await this.ensureScope();
|
|
344
412
|
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
413
|
+
const scopeVersion = coverage?.scopeVersion ?? 0;
|
|
414
|
+
if (scopeVersion !== this.scopeVersion) {
|
|
415
|
+
this.denyScope();
|
|
416
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
417
|
+
}
|
|
345
418
|
let version = coverage?.resetVersion ?? 0;
|
|
346
419
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
347
420
|
let page = after && this.options.transport.changes
|
|
348
421
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
349
422
|
: await this.options.transport.load(thread, this.abort.signal);
|
|
350
423
|
if (page.resetRequired) {
|
|
351
|
-
if (this.closed || this.revoked.has(thread))
|
|
424
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
352
425
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
353
|
-
version = await this.storage.resetThread(thread);
|
|
426
|
+
version = await this.storage.resetThread(thread, false, scopeVersion);
|
|
354
427
|
after = undefined;
|
|
355
428
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
356
429
|
}
|
|
357
430
|
for (;;) {
|
|
358
|
-
if (this.closed || this.revoked.has(thread))
|
|
431
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
359
432
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
360
433
|
if (page.resetRequired)
|
|
361
434
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
362
|
-
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
|
|
435
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
|
|
363
436
|
if (!page.hasMoreChanges)
|
|
364
437
|
break;
|
|
365
438
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
@@ -368,6 +441,8 @@ export class MessageClient {
|
|
|
368
441
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
369
442
|
}
|
|
370
443
|
})().catch(async (error) => {
|
|
444
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
|
|
445
|
+
this.denyScope();
|
|
371
446
|
if (!this.closed && !this.closing && error?.status === 403) {
|
|
372
447
|
try {
|
|
373
448
|
await this.revoke(thread);
|
|
@@ -383,6 +458,9 @@ export class MessageClient {
|
|
|
383
458
|
return task;
|
|
384
459
|
}
|
|
385
460
|
async earlier(thread, before, limit = 100) {
|
|
461
|
+
await this.ensureScope();
|
|
462
|
+
if (this.scopeDenied)
|
|
463
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
386
464
|
if (this.closed)
|
|
387
465
|
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
388
466
|
if (this.revoked.has(thread))
|
|
@@ -396,6 +474,10 @@ export class MessageClient {
|
|
|
396
474
|
return await this.fillWindow(thread, limit, before);
|
|
397
475
|
}
|
|
398
476
|
catch (error) {
|
|
477
|
+
if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
|
|
478
|
+
this.denyScope();
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
399
481
|
if ([401, 403].includes(error?.status ?? 0))
|
|
400
482
|
throw error;
|
|
401
483
|
const fallback = await this.fallback(thread, limit, before);
|
|
@@ -415,21 +497,21 @@ export class MessageClient {
|
|
|
415
497
|
page = await this.storage.read(thread, limit, before);
|
|
416
498
|
continue;
|
|
417
499
|
}
|
|
418
|
-
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
|
|
500
|
+
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
|
|
419
501
|
const task = (async () => {
|
|
420
502
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
421
|
-
if (this.closed || this.revoked.has(thread))
|
|
503
|
+
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
422
504
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
423
505
|
if (remote.resetRequired) {
|
|
424
506
|
if (++resets > 2)
|
|
425
507
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
426
|
-
await this.storage.resetThread(thread);
|
|
508
|
+
await this.storage.resetThread(thread, false, scopeVersion);
|
|
427
509
|
await this.synchronize(thread);
|
|
428
510
|
return;
|
|
429
511
|
}
|
|
430
512
|
if (remote.nextBefore === token)
|
|
431
513
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
432
|
-
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
|
|
514
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
|
|
433
515
|
})().catch(async (error) => {
|
|
434
516
|
if (!this.closed && !this.closing && error?.status === 403) {
|
|
435
517
|
try {
|
|
@@ -462,6 +544,38 @@ export class MessageClient {
|
|
|
462
544
|
this.watches.set(thread, { snapshot, listeners: new Set() });
|
|
463
545
|
this.allListeners.forEach(listener => listener(thread));
|
|
464
546
|
}
|
|
547
|
+
denyScope() {
|
|
548
|
+
const notify = !this.scopeDenied;
|
|
549
|
+
this.scopeDenied = true;
|
|
550
|
+
this.scopeAllowed = false;
|
|
551
|
+
this.abort.abort();
|
|
552
|
+
this.syncTasks.close();
|
|
553
|
+
this.writeTasks.close();
|
|
554
|
+
for (const thread of this.watches.keys())
|
|
555
|
+
this.denyView(thread);
|
|
556
|
+
for (const queue of this.writes.values()) {
|
|
557
|
+
queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
558
|
+
this.release(queue);
|
|
559
|
+
}
|
|
560
|
+
this.writes.clear();
|
|
561
|
+
if (notify)
|
|
562
|
+
this.accessListeners.forEach(listener => listener());
|
|
563
|
+
}
|
|
564
|
+
revokeScope() {
|
|
565
|
+
if (this.closed || this.closing)
|
|
566
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
567
|
+
return this.revokingScope ??= (async () => {
|
|
568
|
+
this.denyScope();
|
|
569
|
+
try {
|
|
570
|
+
await this.ensureScope();
|
|
571
|
+
return await this.storage.revokeScope(this.scopeVersion);
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
const access = await this.storage.scopeAccess();
|
|
575
|
+
this.channel?.postMessage({ type: "scope-revoked", version: access.version });
|
|
576
|
+
}
|
|
577
|
+
})();
|
|
578
|
+
}
|
|
465
579
|
async revoke(thread) {
|
|
466
580
|
this.denyView(thread);
|
|
467
581
|
const queue = this.writes.get(thread);
|
|
@@ -471,25 +585,36 @@ export class MessageClient {
|
|
|
471
585
|
this.release(queue);
|
|
472
586
|
}
|
|
473
587
|
try {
|
|
474
|
-
await this.storage.resetThread(thread, true);
|
|
588
|
+
await this.storage.resetThread(thread, true, this.scopeVersion);
|
|
475
589
|
await this.reload(thread);
|
|
476
590
|
}
|
|
477
591
|
finally {
|
|
478
|
-
this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
|
|
592
|
+
this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
|
|
479
593
|
}
|
|
480
594
|
}
|
|
481
|
-
async close() {
|
|
482
|
-
|
|
595
|
+
async close(beforeDispose) {
|
|
596
|
+
if (this.closed)
|
|
597
|
+
return;
|
|
483
598
|
this.closing = true;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
599
|
+
try {
|
|
600
|
+
await Promise.all([...this.enqueuing]);
|
|
601
|
+
this.syncTasks.close();
|
|
602
|
+
this.writeTasks.close();
|
|
603
|
+
const flushes = [...this.writes.keys()].map(thread => this.flushWrites(thread));
|
|
604
|
+
const results = await Promise.allSettled([...flushes, ...this.activeWrites, ...(this.revokingScope ? [this.revokingScope] : [])]);
|
|
605
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
606
|
+
if (failed)
|
|
607
|
+
throw failed.reason;
|
|
608
|
+
await beforeDispose;
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
this.closed = true;
|
|
612
|
+
this.abort.abort();
|
|
613
|
+
this.channel?.close();
|
|
614
|
+
this.watches.clear();
|
|
615
|
+
this.allListeners.clear();
|
|
616
|
+
this.accessListeners.clear();
|
|
617
|
+
this.storage.close();
|
|
618
|
+
}
|
|
494
619
|
}
|
|
495
620
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
1
2
|
import { type MessageScope } from "./messageStore.js";
|
|
2
3
|
export type MessageReadersPage = {
|
|
3
4
|
appId: string;
|
|
@@ -23,6 +24,7 @@ export type MessageReadersOptions = {
|
|
|
23
24
|
hubHttpUrl: string;
|
|
24
25
|
fetch?: typeof fetch;
|
|
25
26
|
scope?: MessageScope;
|
|
27
|
+
access?: ResponseCacheAccess;
|
|
26
28
|
onError?: (error: unknown) => void;
|
|
27
29
|
};
|
|
28
30
|
export declare class MessageReadersError extends Error {
|
|
@@ -42,5 +44,7 @@ export declare class MessageReadersClient {
|
|
|
42
44
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
43
45
|
private cachedRequest;
|
|
44
46
|
private request;
|
|
47
|
+
withdraw(): void;
|
|
48
|
+
retireScope(version: number): Promise<void>;
|
|
45
49
|
close(): Promise<void>;
|
|
46
50
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
1
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
2
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
3
|
export class MessageReadersError extends Error {
|
|
4
4
|
status;
|
|
@@ -22,7 +22,7 @@ export class MessageReadersClient {
|
|
|
22
22
|
if (options.scope) {
|
|
23
23
|
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
24
24
|
throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
|
|
25
|
-
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
|
|
25
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
getReaders(query, signal) {
|
|
@@ -56,7 +56,8 @@ export class MessageReadersClient {
|
|
|
56
56
|
const cache = input.limit === 50 ? this.cache : undefined;
|
|
57
57
|
if (!cache)
|
|
58
58
|
return this.request(input);
|
|
59
|
-
const stored = await cache.read(key).catch(error => {
|
|
59
|
+
const stored = await cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
60
|
+
throw error; this.options.onError?.(error); return undefined; });
|
|
60
61
|
if (this.lifetime.signal.aborted)
|
|
61
62
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
62
63
|
const generation = this.generation;
|
|
@@ -84,13 +85,16 @@ export class MessageReadersClient {
|
|
|
84
85
|
if (value.resetRequired)
|
|
85
86
|
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
86
87
|
else if (stored)
|
|
87
|
-
await cache?.write(key, value, stored.epoch).catch(error => { if (
|
|
88
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
88
89
|
throw error; this.options.onError?.(error); });
|
|
89
90
|
if (this.lifetime.signal.aborted)
|
|
90
91
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
91
92
|
return value;
|
|
92
93
|
}
|
|
93
94
|
async request(input) {
|
|
95
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
96
|
+
if (this.lifetime.signal.aborted)
|
|
97
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
94
98
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
95
99
|
const controller = new AbortController();
|
|
96
100
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -113,6 +117,8 @@ export class MessageReadersClient {
|
|
|
113
117
|
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
114
118
|
if (this.lifetime.signal.aborted)
|
|
115
119
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
120
|
+
if (this.options.access)
|
|
121
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
116
122
|
return data;
|
|
117
123
|
}
|
|
118
124
|
finally {
|
|
@@ -120,5 +126,7 @@ export class MessageReadersClient {
|
|
|
120
126
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
121
127
|
}
|
|
122
128
|
}
|
|
129
|
+
withdraw() { this.lifetime.abort(); this.pending.clear(); }
|
|
130
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
123
131
|
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
124
132
|
}
|
|
@@ -18,6 +18,12 @@ export type MessageCoverage = {
|
|
|
18
18
|
syncedAt?: number;
|
|
19
19
|
resetVersion?: number;
|
|
20
20
|
revoked?: boolean;
|
|
21
|
+
scopeVersion?: number;
|
|
22
|
+
};
|
|
23
|
+
export type MessageScopeAccess = {
|
|
24
|
+
scope: string;
|
|
25
|
+
version: number;
|
|
26
|
+
revoked: boolean;
|
|
21
27
|
};
|
|
22
28
|
type CoverageRow = MessageCoverage & {
|
|
23
29
|
scope: string;
|
|
@@ -32,6 +38,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
32
38
|
private rows;
|
|
33
39
|
private legacy;
|
|
34
40
|
private coverage;
|
|
41
|
+
private accessRows;
|
|
35
42
|
private key;
|
|
36
43
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
37
44
|
indexedDB?: IDBFactory;
|
|
@@ -44,7 +51,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
44
51
|
}): Promise<{
|
|
45
52
|
messages: T[];
|
|
46
53
|
hasEarlier: boolean;
|
|
47
|
-
coverage:
|
|
54
|
+
coverage: MessageCoverage | undefined;
|
|
48
55
|
}>;
|
|
49
56
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
50
57
|
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
@@ -55,15 +62,21 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
55
62
|
}): Promise<{
|
|
56
63
|
messages: T[];
|
|
57
64
|
hasEarlier: boolean;
|
|
58
|
-
coverage:
|
|
65
|
+
coverage: MessageCoverage | undefined;
|
|
59
66
|
}>;
|
|
60
67
|
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
61
68
|
version: number;
|
|
62
69
|
authoritative?: boolean;
|
|
70
|
+
scopeVersion?: number;
|
|
63
71
|
}): Promise<number>;
|
|
64
72
|
windows(): Promise<CoverageRow[]>;
|
|
73
|
+
scopeAccess(): Promise<MessageScopeAccess>;
|
|
74
|
+
private scopedCoverage;
|
|
75
|
+
private clearScopeRows;
|
|
76
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
77
|
+
revokeScope(expectedVersion?: number): Promise<number>;
|
|
65
78
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
66
|
-
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
79
|
+
resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
|
|
67
80
|
removeThread(thread: string): Promise<void>;
|
|
68
81
|
clear(): Promise<void>;
|
|
69
82
|
close(): void;
|