@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.21

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.
@@ -5,6 +5,8 @@ export class MessageClient {
5
5
  options;
6
6
  storage;
7
7
  watches = new Map();
8
+ accessListeners = new Set();
9
+ allListeners = new Set();
8
10
  reads = new Map();
9
11
  syncing = new Map();
10
12
  paging = new Map();
@@ -18,46 +20,177 @@ export class MessageClient {
18
20
  closing = false;
19
21
  revoked = new Set();
20
22
  batchSize;
23
+ transactionBytes;
24
+ queueBytes;
25
+ pendingBytes = 0;
26
+ pendingRecords = 0;
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
+ }
21
37
  constructor(options) {
22
38
  this.options = options;
23
39
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
24
40
  this.batchSize = options.batchSize ?? 128;
41
+ this.transactionBytes = options.transactionBytes ?? 1_048_576;
42
+ this.queueBytes = options.queueBytes ?? 8_388_608;
43
+ if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
44
+ throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
25
45
  if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
26
46
  throw new Error("INVALID_MESSAGE_BATCH_SIZE");
27
- this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
47
+ this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
48
+ this.invalidate(thread);
49
+ else
50
+ options.onError?.(error, thread); });
28
51
  this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
29
52
  if (typeof BroadcastChannel !== "undefined") {
30
- this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
31
- this.channel.onmessage = event => { if (typeof event.data === "string" && this.watches.has(event.data))
32
- void this.reload(event.data).catch(error => options.onError?.(error, event.data)); };
53
+ this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
54
+ this.channel.onmessage = event => {
55
+ if (this.closed)
56
+ return;
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)) {
68
+ const denied = event.data;
69
+ void (async () => {
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;
73
+ if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
74
+ return;
75
+ this.denyView(denied.thread, denied.version);
76
+ options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
77
+ })();
78
+ }
79
+ else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
80
+ const thread = event.data.thread;
81
+ void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
82
+ options.onError?.(error, thread); });
83
+ }
84
+ else if (typeof event.data === "string" && this.watches.has(event.data))
85
+ void this.reload(event.data).catch(error => { if (!this.closed)
86
+ options.onError?.(error, event.data); });
87
+ };
33
88
  }
34
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
+ }
35
99
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
36
100
  subscribe(thread, listener) {
37
101
  let watch = this.watches.get(thread);
38
102
  if (!watch) {
39
- watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
103
+ watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
40
104
  this.watches.set(thread, watch);
41
105
  }
42
106
  watch.listeners.add(listener);
43
107
  return () => { watch.listeners.delete(listener); };
44
108
  }
45
- read(thread, limit = 100) {
46
- if (this.closed || this.closing || this.revoked.has(thread))
109
+ subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
110
+ /** Close the connection gap for saved windows with four bounded background lanes. */
111
+ async restore(since, threadId) {
112
+ const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
113
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
114
+ while (queue.length && !this.closed && !this.closing) {
115
+ const window = queue.shift();
116
+ try {
117
+ await this.synchronize(window.thread);
118
+ }
119
+ catch (error) {
120
+ if (!this.closed && !this.closing)
121
+ this.options.onError?.(error, window.thread);
122
+ }
123
+ }
124
+ }));
125
+ }
126
+ read(thread, limit = 100, refresh = false) {
127
+ if (this.closed || this.closing)
47
128
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
48
129
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
49
130
  return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
50
- const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
131
+ const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
51
132
  if (running)
52
133
  return running;
53
134
  const task = (async () => {
135
+ await this.ensureScope();
136
+ if (this.scopeDenied)
137
+ throw new Error("MESSAGE_ACCESS_REVOKED");
54
138
  const local = await this.storage.read(thread, limit);
55
- if (!local.coverage?.complete)
56
- await this.synchronize(thread);
139
+ if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
140
+ this.denyScope();
141
+ throw new Error("MESSAGE_SCOPE_CHANGED");
142
+ }
143
+ if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
144
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
145
+ if (!refresh && !local.coverage?.revoked) {
146
+ const fallback = await this.fallback(thread, limit);
147
+ if (fallback.messages.length)
148
+ return fallback;
149
+ }
150
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
151
+ }
152
+ this.revoked.delete(thread);
153
+ try {
154
+ await this.synchronize(thread);
155
+ }
156
+ catch (error) {
157
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
158
+ this.denyScope();
159
+ throw error;
160
+ }
161
+ if (refresh || [401, 403].includes(error?.status ?? 0))
162
+ throw error;
163
+ const fallback = await this.fallback(thread, limit);
164
+ if (!fallback.messages.length)
165
+ throw error;
166
+ this.options.onError?.(error, thread);
167
+ return fallback;
168
+ }
169
+ }
57
170
  if (this.closed)
58
171
  throw new Error("MESSAGE_CLIENT_CLOSED");
59
- await this.fillWindow(thread, limit);
60
- return this.reload(thread, limit);
172
+ let result;
173
+ try {
174
+ result = await this.fillWindow(thread, limit);
175
+ }
176
+ catch (error) {
177
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
178
+ this.denyScope();
179
+ throw error;
180
+ }
181
+ if ([401, 403].includes(error?.status ?? 0))
182
+ throw error;
183
+ const cached = await this.fallback(thread, limit);
184
+ if (!cached.messages.length)
185
+ throw error;
186
+ this.options.onError?.(error, thread);
187
+ return cached;
188
+ }
189
+ const snapshot = await this.reload(thread);
190
+ if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
191
+ && (typeof navigator === "undefined" || navigator.onLine !== false))
192
+ this.invalidate(thread);
193
+ return limit === 100 ? snapshot : result;
61
194
  })().finally(() => { if (this.reads.get(key) === task)
62
195
  this.reads.delete(key); });
63
196
  this.reads.set(key, task);
@@ -68,10 +201,61 @@ export class MessageClient {
68
201
  if (!this.closed && !this.closing && !this.revoked.has(thread))
69
202
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
70
203
  }
71
- async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
72
- const page = await this.storage.read(thread, limit);
204
+ async fallback(thread, limit, before) {
205
+ await this.ensureScope();
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
+ }
211
+ if (!before && page.messages.length)
212
+ await this.reload(thread, true);
213
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
214
+ }
215
+ /** Migration batches remain separate from authoritative message coverage. */
216
+ importLegacy(thread, messages) {
217
+ if (this.closed || this.closing)
218
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
219
+ if (messages.length > 128)
220
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
221
+ if (this.legacyImports >= 4)
222
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
223
+ let records;
224
+ try {
225
+ let bytes = 0;
226
+ for (const message of messages) {
227
+ bytes += this.messageBytes(message);
228
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
229
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
230
+ }
231
+ records = structuredClone(messages);
232
+ }
233
+ catch (error) {
234
+ return Promise.reject(error);
235
+ }
236
+ this.legacyImports++;
237
+ const task = this.storage.importLegacy(thread, records).then(async () => {
238
+ const page = await this.storage.read(thread, 1);
239
+ await this.reload(thread, !page.coverage?.complete);
240
+ this.channel?.postMessage({ type: "legacy", thread });
241
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
242
+ this.activeWrites.add(task);
243
+ return task;
244
+ }
245
+ async reload(thread, fallback = false) {
246
+ await this.ensureScope();
247
+ const limit = 100;
248
+ const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
73
249
  if (this.closed)
74
250
  throw new Error("MESSAGE_CLIENT_CLOSED");
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)) {
256
+ page.messages = [];
257
+ page.hasEarlier = false;
258
+ }
75
259
  let watch = this.watches.get(thread);
76
260
  const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
77
261
  const messages = page.messages.map(message => {
@@ -79,49 +263,108 @@ export class MessageClient {
79
263
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
80
264
  });
81
265
  const old = watch?.snapshot;
82
- if (old && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
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);
267
+ const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
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]))
83
269
  return old;
84
- const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
270
+ const snapshot = { messages, resetVersion, complete, hasEarlier };
85
271
  if (!watch) {
86
- watch = { limit, snapshot, listeners: new Set() };
272
+ watch = { snapshot, listeners: new Set() };
87
273
  this.watches.set(thread, watch);
88
274
  }
89
- watch.limit = limit;
90
275
  watch.snapshot = snapshot;
91
276
  watch.listeners.forEach(listener => listener());
277
+ this.allListeners.forEach(listener => listener(thread));
92
278
  return snapshot;
93
279
  }
94
280
  /** Events are visible only after their atomic batch commits. */
95
281
  receive(thread, messages) {
96
- if (this.closed || this.closing || this.revoked.has(thread))
282
+ if (this.closed || this.closing)
97
283
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
98
- const queue = this.writes.get(thread) ?? [];
99
- if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
284
+ if (this.revoked.has(thread))
285
+ return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
286
+ if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
100
287
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
101
- const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
102
- this.writes.set(thread, queue);
103
- this.writeTasks.schedule(thread, () => this.flushWrites(thread));
104
- return promise;
288
+ let bytes = 0;
289
+ try {
290
+ for (const message of messages) {
291
+ bytes += this.messageBytes(message);
292
+ if (this.pendingBytes + bytes > this.queueBytes)
293
+ throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
294
+ }
295
+ messages = structuredClone(messages);
296
+ }
297
+ catch (error) {
298
+ return Promise.reject(error);
299
+ }
300
+ this.pendingBytes += bytes;
301
+ this.pendingRecords += messages.length;
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;
105
316
  }
106
- async persist(thread, messages, checkpoint, complete, nextBefore) {
317
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
318
+ const initial = (await this.storage.read(thread, 1)).coverage;
319
+ const version = expectedVersion ?? initial?.resetVersion ?? 0;
320
+ const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
107
321
  let changed = 0;
322
+ let navigationChanged = false;
108
323
  // Coverage advances only after all bounded batches have durably committed.
109
- for (let offset = 0; offset < messages.length; offset += this.batchSize) {
110
- if (this.closed || this.revoked.has(thread))
324
+ let batch = [], bytes = 0;
325
+ const commit = async () => {
326
+ if (!batch.length)
327
+ return;
328
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
111
329
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
112
- changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
330
+ changed += await this.storage.commit(thread, batch, undefined, guard);
331
+ batch = [];
332
+ bytes = 0;
333
+ };
334
+ for (const message of messages) {
335
+ const size = this.messageBytes(message);
336
+ if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
337
+ await commit();
338
+ batch.push(message);
339
+ bytes += size;
113
340
  }
341
+ await commit();
114
342
  if (complete !== undefined || checkpoint !== undefined) {
115
343
  const old = (await this.storage.read(thread, 1)).coverage;
116
- if (this.closed || this.revoked.has(thread))
344
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
117
345
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
118
- await this.storage.commit(thread, [], { complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
346
+ navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
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);
119
348
  }
120
- if (changed) {
349
+ if (complete === true)
350
+ this.scopeAllowed = true;
351
+ if (changed || navigationChanged) {
121
352
  await this.reload(thread);
122
353
  this.channel?.postMessage(thread);
123
354
  }
124
355
  }
356
+ messageBytes(message) {
357
+ const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
358
+ if (bytes > this.transactionBytes)
359
+ throw new Error("MESSAGE_RECORD_TOO_LARGE");
360
+ return bytes;
361
+ }
362
+ release(queue) {
363
+ for (const write of queue) {
364
+ this.pendingBytes -= write.bytes;
365
+ this.pendingRecords -= write.messages.length;
366
+ }
367
+ }
125
368
  flushWrites(thread) {
126
369
  const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
127
370
  this.activeWrites.add(task);
@@ -133,39 +376,63 @@ export class MessageClient {
133
376
  return;
134
377
  this.writes.delete(thread);
135
378
  try {
136
- await this.persist(thread, queue.flatMap(write => write.messages));
137
- queue.forEach(write => write.resolve());
379
+ const groups = new Map();
380
+ for (const write of queue) {
381
+ const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
382
+ group.push(write);
383
+ groups.set(key, group);
384
+ }
385
+ let failed = false, failure;
386
+ for (const writes of groups.values()) {
387
+ try {
388
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
389
+ writes.forEach(write => write.resolve());
390
+ }
391
+ catch (error) {
392
+ failed = true;
393
+ failure = error;
394
+ writes.forEach(write => write.reject(error));
395
+ }
396
+ }
397
+ if (failed)
398
+ throw failure;
138
399
  }
139
- catch (error) {
140
- queue.forEach(write => write.reject(error));
141
- throw error;
400
+ finally {
401
+ this.release(queue);
142
402
  }
143
403
  }
144
404
  synchronize(thread) {
145
- if (this.closed || this.closing || this.revoked.has(thread))
405
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
146
406
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
147
407
  const running = this.syncing.get(thread);
148
408
  if (running)
149
409
  return running;
150
410
  const task = (async () => {
411
+ await this.ensureScope();
151
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
+ }
418
+ let version = coverage?.resetVersion ?? 0;
152
419
  let after = coverage?.complete ? coverage.checkpoint : undefined;
153
420
  let page = after && this.options.transport.changes
154
421
  ? await this.options.transport.changes(thread, after, this.abort.signal)
155
422
  : await this.options.transport.load(thread, this.abort.signal);
156
423
  if (page.resetRequired) {
157
- if (this.closed || this.revoked.has(thread))
424
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
158
425
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
159
- await this.storage.removeThread(thread);
426
+ version = await this.storage.resetThread(thread, false, scopeVersion);
160
427
  after = undefined;
161
428
  page = await this.options.transport.load(thread, this.abort.signal);
162
429
  }
163
430
  for (;;) {
164
- if (this.closed || this.revoked.has(thread))
431
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
165
432
  throw new Error("MESSAGE_ACCESS_REVOKED");
166
433
  if (page.resetRequired)
167
434
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
168
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
435
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
169
436
  if (!page.hasMoreChanges)
170
437
  break;
171
438
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -173,22 +440,56 @@ export class MessageClient {
173
440
  after = page.checkpoint;
174
441
  page = await this.options.transport.changes(thread, after, this.abort.signal);
175
442
  }
176
- })().finally(() => { if (this.syncing.get(thread) === task)
443
+ })().catch(async (error) => {
444
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
445
+ this.denyScope();
446
+ if (!this.closed && !this.closing && error?.status === 403) {
447
+ try {
448
+ await this.revoke(thread);
449
+ }
450
+ catch (cacheError) {
451
+ Object.assign(error, { cacheError });
452
+ }
453
+ }
454
+ throw error;
455
+ }).finally(() => { if (this.syncing.get(thread) === task)
177
456
  this.syncing.delete(thread); });
178
457
  this.syncing.set(thread, task);
179
458
  return task;
180
459
  }
181
460
  async earlier(thread, before, limit = 100) {
461
+ await this.ensureScope();
462
+ if (this.scopeDenied)
463
+ throw new Error("MESSAGE_ACCESS_REVOKED");
182
464
  if (this.closed)
183
465
  throw new Error("MESSAGE_CLIENT_CLOSED");
184
466
  if (this.revoked.has(thread))
185
467
  throw new Error("MESSAGE_ACCESS_REVOKED");
186
- if (!(await this.storage.read(thread, 1)).coverage?.complete)
187
- await this.synchronize(thread);
188
- return this.fillWindow(thread, limit, before);
468
+ try {
469
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
470
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
471
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
472
+ await this.synchronize(thread);
473
+ }
474
+ return await this.fillWindow(thread, limit, before);
475
+ }
476
+ catch (error) {
477
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
478
+ this.denyScope();
479
+ throw error;
480
+ }
481
+ if ([401, 403].includes(error?.status ?? 0))
482
+ throw error;
483
+ const fallback = await this.fallback(thread, limit, before);
484
+ if (!fallback.messages.length)
485
+ throw error;
486
+ this.options.onError?.(error, thread);
487
+ return fallback;
488
+ }
189
489
  }
190
490
  async fillWindow(thread, limit, before) {
191
491
  let page = await this.storage.read(thread, limit, before);
492
+ let resets = 0;
192
493
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
193
494
  const pending = this.paging.get(thread);
194
495
  if (pending) {
@@ -196,46 +497,124 @@ export class MessageClient {
196
497
  page = await this.storage.read(thread, limit, before);
197
498
  continue;
198
499
  }
199
- const token = page.coverage.nextBefore;
500
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
200
501
  const task = (async () => {
201
502
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
202
- if (this.closed || this.revoked.has(thread))
503
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
203
504
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
204
505
  if (remote.resetRequired) {
205
- await this.storage.removeThread(thread);
206
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
506
+ if (++resets > 2)
507
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
508
+ await this.storage.resetThread(thread, false, scopeVersion);
509
+ await this.synchronize(thread);
510
+ return;
207
511
  }
208
512
  if (remote.nextBefore === token)
209
513
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
210
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
211
- })().finally(() => { if (this.paging.get(thread) === task)
514
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
515
+ })().catch(async (error) => {
516
+ if (!this.closed && !this.closing && error?.status === 403) {
517
+ try {
518
+ await this.revoke(thread);
519
+ }
520
+ catch (cacheError) {
521
+ Object.assign(error, { cacheError });
522
+ }
523
+ }
524
+ throw error;
525
+ }).finally(() => { if (this.paging.get(thread) === task)
212
526
  this.paging.delete(thread); });
213
527
  this.paging.set(thread, task);
214
528
  await task;
215
529
  page = await this.storage.read(thread, limit, before);
216
530
  }
217
- return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
531
+ return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
218
532
  }
219
- async revoke(thread) {
533
+ denyView(thread, version) {
220
534
  this.revoked.add(thread);
535
+ const watch = this.watches.get(thread);
536
+ const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
537
+ if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
538
+ return;
539
+ if (watch) {
540
+ watch.snapshot = snapshot;
541
+ watch.listeners.forEach(listener => listener());
542
+ }
543
+ else
544
+ this.watches.set(thread, { snapshot, listeners: new Set() });
545
+ this.allListeners.forEach(listener => listener(thread));
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
+ }
579
+ async revoke(thread) {
580
+ this.denyView(thread);
221
581
  const queue = this.writes.get(thread);
222
582
  this.writes.delete(thread);
223
- queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
224
- await this.storage.removeThread(thread);
225
- await this.reload(thread);
226
- this.channel?.postMessage(thread);
583
+ if (queue) {
584
+ queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
585
+ this.release(queue);
586
+ }
587
+ try {
588
+ await this.storage.resetThread(thread, true, this.scopeVersion);
589
+ await this.reload(thread);
590
+ }
591
+ finally {
592
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
593
+ }
227
594
  }
228
- async close() {
229
- // Flush accepted event batches before closing; new requests are rejected afterwards.
595
+ async close(beforeDispose) {
596
+ if (this.closed)
597
+ return;
230
598
  this.closing = true;
231
- this.syncTasks.close();
232
- this.writeTasks.close();
233
- await Promise.all([...this.writes.keys()].map(thread => this.flushWrites(thread)));
234
- await Promise.all([...this.activeWrites]);
235
- this.closed = true;
236
- this.abort.abort();
237
- this.channel?.close();
238
- this.watches.clear();
239
- this.storage.close();
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
+ }
240
619
  }
241
620
  }