@pmate/chat-sdk 0.12.0-next.0 → 0.12.0-next.10

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