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

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.
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
15
15
  export type MessageSnapshot<T> = {
16
16
  messages: readonly T[];
17
17
  hasEarlier: boolean;
18
+ resetVersion: number;
19
+ complete: boolean;
18
20
  };
19
21
  export type MessageClientOptions<T extends StoredMessage> = {
20
22
  scope: MessageScope;
@@ -25,12 +27,16 @@ export type MessageClientOptions<T extends StoredMessage> = {
25
27
  batchSize?: number;
26
28
  writeWindowMs?: number;
27
29
  syncWindowMs?: number;
30
+ transactionBytes?: number;
31
+ queueBytes?: number;
32
+ freshnessMs?: number;
28
33
  };
29
34
  /** Local-first message facade; hosts never select a cache or network source. */
30
35
  export declare class MessageClient<T extends StoredMessage> {
31
36
  private options;
32
37
  private storage;
33
38
  private watches;
39
+ private allListeners;
34
40
  private reads;
35
41
  private syncing;
36
42
  private paging;
@@ -44,27 +50,38 @@ export declare class MessageClient<T extends StoredMessage> {
44
50
  private closing;
45
51
  private revoked;
46
52
  private batchSize;
53
+ private transactionBytes;
54
+ private queueBytes;
55
+ private pendingBytes;
56
+ private pendingRecords;
57
+ private legacyImports;
47
58
  constructor(options: MessageClientOptions<T>);
48
59
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
49
60
  subscribe(thread: string, listener: () => void): () => void;
50
- read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
61
+ subscribeAll(listener: (threadId: string) => void): () => void;
62
+ /** Close the connection gap for saved windows with four bounded background lanes. */
63
+ restore(since: number, threadId?: string): Promise<void>;
64
+ read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
51
65
  /** Application connection/wake events invalidate, rather than fetching directly. */
52
66
  invalidate(thread: string): void;
67
+ private fallback;
68
+ /** Migration batches remain separate from authoritative message coverage. */
69
+ importLegacy(thread: string, messages: T[]): Promise<void>;
53
70
  private reload;
54
71
  /** Events are visible only after their atomic batch commits. */
55
72
  receive(thread: string, messages: T[]): Promise<void>;
56
73
  private persist;
74
+ private messageBytes;
75
+ private release;
57
76
  private flushWrites;
58
77
  private runWrites;
59
78
  synchronize(thread: string): Promise<void>;
60
79
  earlier(thread: string, before: {
61
80
  timestamp: number;
62
81
  id: string;
63
- }, limit?: number): Promise<{
64
- messages: T[];
65
- hasEarlier: boolean;
66
- }>;
82
+ }, limit?: number): Promise<MessageSnapshot<T>>;
67
83
  private fillWindow;
84
+ private denyView;
68
85
  revoke(thread: string): Promise<void>;
69
86
  close(): Promise<void>;
70
87
  }
@@ -5,6 +5,7 @@ export class MessageClient {
5
5
  options;
6
6
  storage;
7
7
  watches = new Map();
8
+ allListeners = new Set();
8
9
  reads = new Map();
9
10
  syncing = new Map();
10
11
  paging = new Map();
@@ -18,46 +19,132 @@ export class MessageClient {
18
19
  closing = false;
19
20
  revoked = new Set();
20
21
  batchSize;
22
+ transactionBytes;
23
+ queueBytes;
24
+ pendingBytes = 0;
25
+ pendingRecords = 0;
26
+ legacyImports = 0;
21
27
  constructor(options) {
22
28
  this.options = options;
23
29
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
24
30
  this.batchSize = options.batchSize ?? 128;
31
+ this.transactionBytes = options.transactionBytes ?? 1_048_576;
32
+ this.queueBytes = options.queueBytes ?? 8_388_608;
33
+ if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
34
+ throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
25
35
  if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
26
36
  throw new Error("INVALID_MESSAGE_BATCH_SIZE");
27
- this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
37
+ this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
38
+ this.invalidate(thread);
39
+ else
40
+ options.onError?.(error, thread); });
28
41
  this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
29
42
  if (typeof BroadcastChannel !== "undefined") {
30
- this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
31
- this.channel.onmessage = event => { if (typeof event.data === "string" && this.watches.has(event.data))
32
- void this.reload(event.data).catch(error => options.onError?.(error, event.data)); };
43
+ this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
44
+ this.channel.onmessage = event => {
45
+ if (this.closed)
46
+ return;
47
+ if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
48
+ const denied = event.data;
49
+ void (async () => {
50
+ const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
51
+ if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
52
+ return;
53
+ this.denyView(denied.thread, denied.version);
54
+ options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
55
+ })();
56
+ }
57
+ else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
58
+ const thread = event.data.thread;
59
+ void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
60
+ options.onError?.(error, thread); });
61
+ }
62
+ else if (typeof event.data === "string" && this.watches.has(event.data))
63
+ void this.reload(event.data).catch(error => { if (!this.closed)
64
+ options.onError?.(error, event.data); });
65
+ };
33
66
  }
34
67
  }
35
68
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
36
69
  subscribe(thread, listener) {
37
70
  let watch = this.watches.get(thread);
38
71
  if (!watch) {
39
- watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
72
+ watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
40
73
  this.watches.set(thread, watch);
41
74
  }
42
75
  watch.listeners.add(listener);
43
76
  return () => { watch.listeners.delete(listener); };
44
77
  }
45
- read(thread, limit = 100) {
46
- if (this.closed || this.closing || this.revoked.has(thread))
78
+ subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
79
+ /** Close the connection gap for saved windows with four bounded background lanes. */
80
+ async restore(since, threadId) {
81
+ const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
82
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
83
+ while (queue.length && !this.closed && !this.closing) {
84
+ const window = queue.shift();
85
+ try {
86
+ await this.synchronize(window.thread);
87
+ }
88
+ catch (error) {
89
+ if (!this.closed && !this.closing)
90
+ this.options.onError?.(error, window.thread);
91
+ }
92
+ }
93
+ }));
94
+ }
95
+ read(thread, limit = 100, refresh = false) {
96
+ if (this.closed || this.closing)
47
97
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
48
98
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
49
99
  return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
50
- const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
100
+ const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
51
101
  if (running)
52
102
  return running;
53
103
  const task = (async () => {
54
104
  const local = await this.storage.read(thread, limit);
55
- if (!local.coverage?.complete)
56
- await this.synchronize(thread);
105
+ if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
106
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
107
+ if (!refresh && !local.coverage?.revoked) {
108
+ const fallback = await this.fallback(thread, limit);
109
+ if (fallback.messages.length)
110
+ return fallback;
111
+ }
112
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
113
+ }
114
+ this.revoked.delete(thread);
115
+ try {
116
+ await this.synchronize(thread);
117
+ }
118
+ catch (error) {
119
+ if (refresh || [401, 403].includes(error?.status ?? 0))
120
+ throw error;
121
+ const fallback = await this.fallback(thread, limit);
122
+ if (!fallback.messages.length)
123
+ throw error;
124
+ this.options.onError?.(error, thread);
125
+ return fallback;
126
+ }
127
+ }
57
128
  if (this.closed)
58
129
  throw new Error("MESSAGE_CLIENT_CLOSED");
59
- await this.fillWindow(thread, limit);
60
- return this.reload(thread, limit);
130
+ let result;
131
+ try {
132
+ result = await this.fillWindow(thread, limit);
133
+ }
134
+ catch (error) {
135
+ if ([401, 403].includes(error?.status ?? 0))
136
+ throw error;
137
+ const cached = await this.fallback(thread, limit);
138
+ if (!cached.messages.length)
139
+ throw error;
140
+ this.options.onError?.(error, thread);
141
+ return cached;
142
+ }
143
+ const snapshot = await this.reload(thread);
144
+ if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
145
+ && (typeof navigator === "undefined" || navigator.onLine !== false))
146
+ this.invalidate(thread);
147
+ return limit === 100 ? snapshot : result;
61
148
  })().finally(() => { if (this.reads.get(key) === task)
62
149
  this.reads.delete(key); });
63
150
  this.reads.set(key, task);
@@ -68,10 +155,51 @@ export class MessageClient {
68
155
  if (!this.closed && !this.closing && !this.revoked.has(thread))
69
156
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
70
157
  }
71
- async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
72
- const page = await this.storage.read(thread, limit);
158
+ async fallback(thread, limit, before) {
159
+ const page = await this.storage.readFallback(thread, limit, before);
160
+ if (!before && page.messages.length)
161
+ await this.reload(thread, true);
162
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
163
+ }
164
+ /** Migration batches remain separate from authoritative message coverage. */
165
+ importLegacy(thread, messages) {
166
+ if (this.closed || this.closing)
167
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
168
+ if (messages.length > 128)
169
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
170
+ if (this.legacyImports >= 4)
171
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
172
+ let records;
173
+ try {
174
+ let bytes = 0;
175
+ for (const message of messages) {
176
+ bytes += this.messageBytes(message);
177
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
178
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
179
+ }
180
+ records = structuredClone(messages);
181
+ }
182
+ catch (error) {
183
+ return Promise.reject(error);
184
+ }
185
+ this.legacyImports++;
186
+ const task = this.storage.importLegacy(thread, records).then(async () => {
187
+ const page = await this.storage.read(thread, 1);
188
+ await this.reload(thread, !page.coverage?.complete);
189
+ this.channel?.postMessage({ type: "legacy", thread });
190
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
191
+ this.activeWrites.add(task);
192
+ return task;
193
+ }
194
+ async reload(thread, fallback = false) {
195
+ const limit = 100;
196
+ const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
73
197
  if (this.closed)
74
198
  throw new Error("MESSAGE_CLIENT_CLOSED");
199
+ if (this.revoked.has(thread)) {
200
+ page.messages = [];
201
+ page.hasEarlier = false;
202
+ }
75
203
  let watch = this.watches.get(thread);
76
204
  const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
77
205
  const messages = page.messages.map(message => {
@@ -79,49 +207,97 @@ export class MessageClient {
79
207
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
80
208
  });
81
209
  const old = watch?.snapshot;
82
- if (old && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
210
+ const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
211
+ const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
212
+ if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
83
213
  return old;
84
- const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
214
+ const snapshot = { messages, resetVersion, complete, hasEarlier };
85
215
  if (!watch) {
86
- watch = { limit, snapshot, listeners: new Set() };
216
+ watch = { snapshot, listeners: new Set() };
87
217
  this.watches.set(thread, watch);
88
218
  }
89
- watch.limit = limit;
90
219
  watch.snapshot = snapshot;
91
220
  watch.listeners.forEach(listener => listener());
221
+ this.allListeners.forEach(listener => listener(thread));
92
222
  return snapshot;
93
223
  }
94
224
  /** Events are visible only after their atomic batch commits. */
95
225
  receive(thread, messages) {
96
- if (this.closed || this.closing || this.revoked.has(thread))
226
+ if (this.closed || this.closing)
97
227
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
228
+ if (this.revoked.has(thread))
229
+ return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
98
230
  const queue = this.writes.get(thread) ?? [];
99
- if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
231
+ if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
100
232
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
101
- const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
233
+ let bytes = 0;
234
+ try {
235
+ for (const message of messages) {
236
+ bytes += this.messageBytes(message);
237
+ if (this.pendingBytes + bytes > this.queueBytes)
238
+ throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
239
+ }
240
+ messages = structuredClone(messages);
241
+ }
242
+ catch (error) {
243
+ return Promise.reject(error);
244
+ }
245
+ this.pendingBytes += bytes;
246
+ this.pendingRecords += messages.length;
247
+ const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
102
248
  this.writes.set(thread, queue);
103
249
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
104
250
  return promise;
105
251
  }
106
- async persist(thread, messages, checkpoint, complete, nextBefore) {
252
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
253
+ const initial = (await this.storage.read(thread, 1)).coverage;
254
+ const version = expectedVersion ?? initial?.resetVersion ?? 0;
255
+ const guard = { version, authoritative: complete === true };
107
256
  let changed = 0;
257
+ let navigationChanged = false;
108
258
  // Coverage advances only after all bounded batches have durably committed.
109
- for (let offset = 0; offset < messages.length; offset += this.batchSize) {
259
+ let batch = [], bytes = 0;
260
+ const commit = async () => {
261
+ if (!batch.length)
262
+ return;
110
263
  if (this.closed || this.revoked.has(thread))
111
264
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
112
- changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
265
+ changed += await this.storage.commit(thread, batch, undefined, guard);
266
+ batch = [];
267
+ bytes = 0;
268
+ };
269
+ for (const message of messages) {
270
+ const size = this.messageBytes(message);
271
+ if (batch.length >= this.batchSize || bytes + size > this.transactionBytes)
272
+ await commit();
273
+ batch.push(message);
274
+ bytes += size;
113
275
  }
276
+ await commit();
114
277
  if (complete !== undefined || checkpoint !== undefined) {
115
278
  const old = (await this.storage.read(thread, 1)).coverage;
116
279
  if (this.closed || this.revoked.has(thread))
117
280
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
118
- await this.storage.commit(thread, [], { complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
281
+ navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
282
+ await this.storage.commit(thread, [], { resetVersion: version, revoked: complete === true ? false : old?.revoked, syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore }, guard);
119
283
  }
120
- if (changed) {
284
+ if (changed || navigationChanged) {
121
285
  await this.reload(thread);
122
286
  this.channel?.postMessage(thread);
123
287
  }
124
288
  }
289
+ messageBytes(message) {
290
+ const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength;
291
+ if (bytes > this.transactionBytes)
292
+ throw new Error("MESSAGE_RECORD_TOO_LARGE");
293
+ return bytes;
294
+ }
295
+ release(queue) {
296
+ for (const write of queue) {
297
+ this.pendingBytes -= write.bytes;
298
+ this.pendingRecords -= write.messages.length;
299
+ }
300
+ }
125
301
  flushWrites(thread) {
126
302
  const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
127
303
  this.activeWrites.add(task);
@@ -133,12 +309,29 @@ export class MessageClient {
133
309
  return;
134
310
  this.writes.delete(thread);
135
311
  try {
136
- await this.persist(thread, queue.flatMap(write => write.messages));
137
- queue.forEach(write => write.resolve());
312
+ const groups = new Map();
313
+ for (const write of queue) {
314
+ const group = groups.get(write.version) ?? [];
315
+ group.push(write);
316
+ groups.set(write.version, group);
317
+ }
318
+ let failed = false, failure;
319
+ for (const [version, writes] of groups) {
320
+ try {
321
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
322
+ writes.forEach(write => write.resolve());
323
+ }
324
+ catch (error) {
325
+ failed = true;
326
+ failure = error;
327
+ writes.forEach(write => write.reject(error));
328
+ }
329
+ }
330
+ if (failed)
331
+ throw failure;
138
332
  }
139
- catch (error) {
140
- queue.forEach(write => write.reject(error));
141
- throw error;
333
+ finally {
334
+ this.release(queue);
142
335
  }
143
336
  }
144
337
  synchronize(thread) {
@@ -149,6 +342,7 @@ export class MessageClient {
149
342
  return running;
150
343
  const task = (async () => {
151
344
  const coverage = (await this.storage.read(thread, 1)).coverage;
345
+ let version = coverage?.resetVersion ?? 0;
152
346
  let after = coverage?.complete ? coverage.checkpoint : undefined;
153
347
  let page = after && this.options.transport.changes
154
348
  ? await this.options.transport.changes(thread, after, this.abort.signal)
@@ -156,7 +350,7 @@ export class MessageClient {
156
350
  if (page.resetRequired) {
157
351
  if (this.closed || this.revoked.has(thread))
158
352
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
159
- await this.storage.removeThread(thread);
353
+ version = await this.storage.resetThread(thread);
160
354
  after = undefined;
161
355
  page = await this.options.transport.load(thread, this.abort.signal);
162
356
  }
@@ -165,7 +359,7 @@ export class MessageClient {
165
359
  throw new Error("MESSAGE_ACCESS_REVOKED");
166
360
  if (page.resetRequired)
167
361
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
168
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
362
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
169
363
  if (!page.hasMoreChanges)
170
364
  break;
171
365
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -173,7 +367,17 @@ export class MessageClient {
173
367
  after = page.checkpoint;
174
368
  page = await this.options.transport.changes(thread, after, this.abort.signal);
175
369
  }
176
- })().finally(() => { if (this.syncing.get(thread) === task)
370
+ })().catch(async (error) => {
371
+ if (!this.closed && !this.closing && error?.status === 403) {
372
+ try {
373
+ await this.revoke(thread);
374
+ }
375
+ catch (cacheError) {
376
+ Object.assign(error, { cacheError });
377
+ }
378
+ }
379
+ throw error;
380
+ }).finally(() => { if (this.syncing.get(thread) === task)
177
381
  this.syncing.delete(thread); });
178
382
  this.syncing.set(thread, task);
179
383
  return task;
@@ -183,12 +387,27 @@ export class MessageClient {
183
387
  throw new Error("MESSAGE_CLIENT_CLOSED");
184
388
  if (this.revoked.has(thread))
185
389
  throw new Error("MESSAGE_ACCESS_REVOKED");
186
- if (!(await this.storage.read(thread, 1)).coverage?.complete)
187
- await this.synchronize(thread);
188
- return this.fillWindow(thread, limit, before);
390
+ try {
391
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
392
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
393
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
394
+ await this.synchronize(thread);
395
+ }
396
+ return await this.fillWindow(thread, limit, before);
397
+ }
398
+ catch (error) {
399
+ if ([401, 403].includes(error?.status ?? 0))
400
+ throw error;
401
+ const fallback = await this.fallback(thread, limit, before);
402
+ if (!fallback.messages.length)
403
+ throw error;
404
+ this.options.onError?.(error, thread);
405
+ return fallback;
406
+ }
189
407
  }
190
408
  async fillWindow(thread, limit, before) {
191
409
  let page = await this.storage.read(thread, limit, before);
410
+ let resets = 0;
192
411
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
193
412
  const pending = this.paging.get(thread);
194
413
  if (pending) {
@@ -196,34 +415,68 @@ export class MessageClient {
196
415
  page = await this.storage.read(thread, limit, before);
197
416
  continue;
198
417
  }
199
- const token = page.coverage.nextBefore;
418
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
200
419
  const task = (async () => {
201
420
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
202
421
  if (this.closed || this.revoked.has(thread))
203
422
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
204
423
  if (remote.resetRequired) {
205
- await this.storage.removeThread(thread);
206
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
424
+ if (++resets > 2)
425
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
426
+ await this.storage.resetThread(thread);
427
+ await this.synchronize(thread);
428
+ return;
207
429
  }
208
430
  if (remote.nextBefore === token)
209
431
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
210
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
211
- })().finally(() => { if (this.paging.get(thread) === task)
432
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
433
+ })().catch(async (error) => {
434
+ if (!this.closed && !this.closing && error?.status === 403) {
435
+ try {
436
+ await this.revoke(thread);
437
+ }
438
+ catch (cacheError) {
439
+ Object.assign(error, { cacheError });
440
+ }
441
+ }
442
+ throw error;
443
+ }).finally(() => { if (this.paging.get(thread) === task)
212
444
  this.paging.delete(thread); });
213
445
  this.paging.set(thread, task);
214
446
  await task;
215
447
  page = await this.storage.read(thread, limit, before);
216
448
  }
217
- return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
449
+ return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
218
450
  }
219
- async revoke(thread) {
451
+ denyView(thread, version) {
220
452
  this.revoked.add(thread);
453
+ const watch = this.watches.get(thread);
454
+ const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
455
+ if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
456
+ return;
457
+ if (watch) {
458
+ watch.snapshot = snapshot;
459
+ watch.listeners.forEach(listener => listener());
460
+ }
461
+ else
462
+ this.watches.set(thread, { snapshot, listeners: new Set() });
463
+ this.allListeners.forEach(listener => listener(thread));
464
+ }
465
+ async revoke(thread) {
466
+ this.denyView(thread);
221
467
  const queue = this.writes.get(thread);
222
468
  this.writes.delete(thread);
223
- queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
224
- await this.storage.removeThread(thread);
225
- await this.reload(thread);
226
- this.channel?.postMessage(thread);
469
+ if (queue) {
470
+ queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
471
+ this.release(queue);
472
+ }
473
+ try {
474
+ await this.storage.resetThread(thread, true);
475
+ await this.reload(thread);
476
+ }
477
+ finally {
478
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
479
+ }
227
480
  }
228
481
  async close() {
229
482
  // Flush accepted event batches before closing; new requests are rejected afterwards.
@@ -236,6 +489,7 @@ export class MessageClient {
236
489
  this.abort.abort();
237
490
  this.channel?.close();
238
491
  this.watches.clear();
492
+ this.allListeners.clear();
239
493
  this.storage.close();
240
494
  }
241
495
  }
@@ -1,3 +1,4 @@
1
+ import { type MessageScope } from "./messageStore.js";
1
2
  export type MessageReadersPage = {
2
3
  appId: string;
3
4
  threadId: string;
@@ -21,6 +22,8 @@ export type MessageReadersOptions = {
21
22
  token: string;
22
23
  hubHttpUrl: string;
23
24
  fetch?: typeof fetch;
25
+ scope?: MessageScope;
26
+ onError?: (error: unknown) => void;
24
27
  };
25
28
  export declare class MessageReadersError extends Error {
26
29
  readonly status: number;
@@ -32,8 +35,12 @@ export declare class MessageReadersClient {
32
35
  private options;
33
36
  private pending;
34
37
  private lifetime;
38
+ private cache?;
39
+ private revoked;
40
+ private generation;
35
41
  constructor(options: MessageReadersOptions);
36
42
  getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
43
+ private cachedRequest;
37
44
  private request;
38
- close(): void;
45
+ close(): Promise<void>;
39
46
  }