@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.51

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