@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.40

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