@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.30

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