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

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.
package/README.md CHANGED
@@ -158,9 +158,17 @@ await chat.observeMessages(threadId, [voiceId], "played")
158
158
  await chat.sendMessage(threadId, { to, body, recipients })
159
159
  const readers = await chat.getReaders(threadId, messageId)
160
160
  off(); offSummary()
161
+ // Ordinary navigation/shutdown preserves offline data.
161
162
  await chat.close()
162
163
  ```
163
164
 
165
+ ```ts
166
+ // When team access is withdrawn, use this instead of ordinary close(). It hides
167
+ // snapshots immediately, retires durable caches/outbox and closes this session.
168
+ // Other teams' reading operations remain intact. Create a new session to recover.
169
+ await chat.revokeAccess()
170
+ ```
171
+
164
172
  The facade owns all clients below. Message reads transparently restore IndexedDB
165
173
  windows and fetch missing ranges through the authenticated Hub. Its normalized
166
174
  messages retain the protocol body; product rendering remains with the host.
@@ -1,6 +1,6 @@
1
- import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
1
+ import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform } from "./readIndex.js";
2
2
  type Change = {
3
- transform: (state: ReadIndexState) => ReadIndexState;
3
+ transform: ReadIndexTransform;
4
4
  resolve: (state: ReadIndexState) => void;
5
5
  reject: (error: unknown) => void;
6
6
  };
@@ -15,6 +15,15 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
15
15
  private closed;
16
16
  constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
17
17
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
18
+ loadVersioned(appId: string, profileId: string): Promise<{
19
+ state: ReadIndexState | undefined;
20
+ versions: import("./readIndex.js").ReadIndexVersions;
21
+ } | {
22
+ state: ReadIndexState | undefined;
23
+ versions: {};
24
+ }>;
25
+ loadVersions(appId: string, profileId: string): Promise<{}>;
26
+ invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
18
27
  update(appId: string, profileId: string, transform: Change["transform"]): Promise<ReadIndexState>;
19
28
  private drain;
20
29
  flush(appId: string, profileId: string): Promise<void>;
@@ -22,6 +22,26 @@ export class BatchedReadIndex {
22
22
  throw new Error("READ_STORAGE_CLOSED");
23
23
  return this.storage.load(appId, profileId);
24
24
  }
25
+ async loadVersioned(appId, profileId) {
26
+ await this.flush(appId, profileId);
27
+ if (this.closed)
28
+ throw new Error("READ_STORAGE_CLOSED");
29
+ return this.storage.loadVersioned ? this.storage.loadVersioned(appId, profileId) : { state: await this.storage.load(appId, profileId), versions: {} };
30
+ }
31
+ async loadVersions(appId, profileId) {
32
+ if (this.closed)
33
+ throw new Error("READ_STORAGE_CLOSED");
34
+ return this.storage.loadVersions ? this.storage.loadVersions(appId, profileId) : {};
35
+ }
36
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds) {
37
+ if (this.closed)
38
+ throw new Error("READ_STORAGE_CLOSED");
39
+ if (!this.storage.invalidateTeam)
40
+ throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
41
+ // Do not flush a blocked old HTTP transform first. The durable epoch is the
42
+ // barrier; any queued transform sees it inside its eventual transaction.
43
+ return this.storage.invalidateTeam(appId, profileId, teamId, version, knownThreadIds);
44
+ }
25
45
  update(appId, profileId, transform) {
26
46
  if (this.closed)
27
47
  return Promise.reject(new Error("READ_STORAGE_CLOSED"));
@@ -48,7 +68,7 @@ export class BatchedReadIndex {
48
68
  return;
49
69
  const run = (async () => {
50
70
  try {
51
- const next = await this.storage.update(queue.appId, queue.profileId, state => batch.reduce((current, change) => change.transform(current), state));
71
+ const next = await this.storage.update(queue.appId, queue.profileId, (state, versions) => batch.reduce((current, change) => change.transform(current, versions), state));
52
72
  batch.forEach(change => change.resolve(next));
53
73
  }
54
74
  catch (error) {
@@ -59,9 +59,14 @@ export declare class ChatSession<T extends StoredMessage> {
59
59
  private off;
60
60
  private globalListeners;
61
61
  private globalSnapshot?;
62
+ private closedThread;
63
+ private closedSummary;
62
64
  private connectionState;
63
65
  private catchUp;
64
66
  private readingStarted;
67
+ private readingStarting?;
68
+ private withdrawn;
69
+ private revoking?;
65
70
  private closed;
66
71
  private starting?;
67
72
  private initializing?;
@@ -71,6 +76,7 @@ export declare class ChatSession<T extends StoredMessage> {
71
76
  /** Initializes local state and background reading sync without opening a
72
77
  * second transport when the host already owns calls/presence. */
73
78
  initialize(): Promise<void>;
79
+ private startReading;
74
80
  /** One-time migration only: never establishes trusted history coverage. */
75
81
  importLegacyMessages(threadId: string, messages: T[]): Promise<void>;
76
82
  getReadingSnapshot(): import("./readIndex.js").ReadIndexState | undefined;
@@ -117,5 +123,9 @@ export declare class ChatSession<T extends StoredMessage> {
117
123
  private buildGlobal;
118
124
  private publishGlobal;
119
125
  private publish;
126
+ /** Permanently retire this identity/team session. UI withdrawal is immediate;
127
+ * completion means all durable stores have crossed the same authority epoch. */
128
+ revokeAccess(): Promise<void>;
120
129
  close(): Promise<void>;
130
+ private dispose;
121
131
  }
@@ -27,9 +27,14 @@ export class ChatSession {
27
27
  off = [];
28
28
  globalListeners = new Set();
29
29
  globalSnapshot;
30
+ closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
31
+ closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
30
32
  connectionState = "closed";
31
33
  catchUp;
32
34
  readingStarted = false;
35
+ readingStarting;
36
+ withdrawn = false;
37
+ revoking;
33
38
  closed = false;
34
39
  starting;
35
40
  initializing;
@@ -54,8 +59,8 @@ export class ChatSession {
54
59
  throw new Error("REACH_HANDLER_UNAVAILABLE");
55
60
  } });
56
61
  this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
57
- this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
58
- this.sender = new DurableChatSender(this.connection, scope, options.onError);
62
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
63
+ this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
59
64
  this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
60
65
  options.onError(error); });
61
66
  this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
@@ -66,6 +71,8 @@ export class ChatSession {
66
71
  window.addEventListener("focus", wake);
67
72
  this.off.push(() => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); });
68
73
  }
74
+ this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
75
+ void this.revokeAccess().catch(error => options.onError(error)); }));
69
76
  this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
70
77
  this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
71
78
  this.off.push(this.connection.watchMessages(message => {
@@ -112,21 +119,37 @@ export class ChatSession {
112
119
  return this.initializing ??= (async () => {
113
120
  await this.sender.initialize();
114
121
  this.assertOpen();
115
- this.readingStarted = true;
116
- await this.reading.start();
122
+ await this.startReading();
117
123
  this.assertOpen();
118
124
  })().catch(error => { this.initializing = undefined; throw error; });
119
125
  }
126
+ startReading() {
127
+ if (this.readingStarted)
128
+ return Promise.resolve();
129
+ return this.readingStarting ??= (async () => {
130
+ const access = await this.messages.scopeAccess();
131
+ if (!access.allowed)
132
+ return;
133
+ this.assertOpen();
134
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
135
+ await this.reading.start();
136
+ this.assertOpen();
137
+ this.readingStarted = true;
138
+ })().finally(() => { this.readingStarting = undefined; });
139
+ }
120
140
  /** One-time migration only: never establishes trusted history coverage. */
121
141
  importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
122
- getReadingSnapshot() { return this.reading.getSnapshot(); }
142
+ getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
123
143
  subscribeReading(listener) { this.assertOpen(); return this.reading.subscribe(listener); }
124
144
  refreshReading() { this.assertOpen(); this.reading.refresh(); }
125
145
  synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
126
146
  /** Explicit text-history acknowledgement; unheard audio stays unread. */
127
- markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages); }
128
- updateReading(transform) { this.assertOpen(); return this.reading.update(transform); }
129
- assertOpen() { if (this.closed)
147
+ markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages).then(next => { if (this.withdrawn)
148
+ throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
149
+ updateReading(transform) { this.assertOpen(); return this.reading.update(transform).then(next => { if (this.withdrawn)
150
+ throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
151
+ assertOpen() { if (this.withdrawn)
152
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
130
153
  throw new Error("CHAT_SESSION_CLOSED"); }
131
154
  async getMessages(threadId, options = {}) {
132
155
  this.assertOpen();
@@ -145,7 +168,11 @@ export class ChatSession {
145
168
  return this.readAll(threadId);
146
169
  }
147
170
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
171
+ this.assertOpen();
148
172
  await this.sender.confirm(page.messages.map(message => message.id));
173
+ if (this.initializing && !this.readingStarted && page.complete)
174
+ await this.startReading();
175
+ this.assertOpen();
149
176
  const edge = this.historyEdges.get(threadId);
150
177
  if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
151
178
  this.rememberHistoryEdge(threadId, page);
@@ -206,6 +233,7 @@ export class ChatSession {
206
233
  return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
207
234
  }
208
235
  catch (error) {
236
+ this.assertOpen();
209
237
  if ([401, 403].includes(error?.status ?? 0))
210
238
  throw error;
211
239
  if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
@@ -219,7 +247,8 @@ export class ChatSession {
219
247
  }
220
248
  sendMessage(threadId, input) {
221
249
  this.assertOpen();
222
- return this.sender.enqueue(threadId, input);
250
+ return this.sender.enqueue(threadId, input).then(next => { if (this.withdrawn)
251
+ throw new Error("CHAT_ACCESS_REVOKED"); return next; });
223
252
  }
224
253
  async getReaders(threadId, messageId, page = {}, signal) {
225
254
  this.assertOpen();
@@ -245,7 +274,7 @@ export class ChatSession {
245
274
  }
246
275
  if (!threadId)
247
276
  throw new Error("INVALID_READING_THREAD");
248
- return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
277
+ return (event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids)).then(next => { this.assertOpen(); return next; });
249
278
  }
250
279
  /** Bridge transports can delegate social events without maintaining clients. */
251
280
  async handleReach(event) {
@@ -285,6 +314,8 @@ export class ChatSession {
285
314
  this.listeners.get(thread)?.forEach(listener => listener());
286
315
  }
287
316
  publishCounts(value) {
317
+ if (this.closed || this.withdrawn)
318
+ return;
288
319
  const previous = this.countSnapshots.get(value.threadId);
289
320
  if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
290
321
  return;
@@ -311,13 +342,13 @@ export class ChatSession {
311
342
  this.countClosings.add(closing);
312
343
  }
313
344
  const scope = this.options.messages.scope;
314
- const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
345
+ const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, access: () => this.messages.scopeAccess(), token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
315
346
  onChange: value => { if (!this.closed && this.counts === client)
316
347
  this.publishCounts(value); },
317
348
  onError: error => {
318
349
  if (this.closed || this.counts !== client)
319
350
  return;
320
- if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
351
+ if (/READ_COUNTS_HTTP_(401|403)|RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error))) {
321
352
  const threads = [...this.countSnapshots.keys()];
322
353
  this.countSnapshots.clear();
323
354
  threads.forEach(thread => this.publishCountSnapshot(thread));
@@ -331,6 +362,11 @@ export class ChatSession {
331
362
  this.counts.watch(threadId, ids);
332
363
  }
333
364
  getSnapshot(threadId) {
365
+ if (this.closed || this.withdrawn) {
366
+ if (threadId === undefined)
367
+ return this.closedSummary;
368
+ return this.closedThread;
369
+ }
334
370
  if (threadId === undefined) {
335
371
  if (!this.globalSnapshot)
336
372
  this.globalSnapshot = this.buildGlobal();
@@ -377,6 +413,8 @@ export class ChatSession {
377
413
  };
378
414
  }
379
415
  publishAll() {
416
+ if (this.closed || this.withdrawn)
417
+ return;
380
418
  for (const threadId of [...this.snapshots.keys()])
381
419
  this.publish(threadId, false);
382
420
  this.publishGlobal();
@@ -397,6 +435,8 @@ export class ChatSession {
397
435
  return { threads, unreadMessageCount: known ? threads.reduce((sum, t) => sum + (t.unread?.unreadMessageCount ?? 0), 0) : undefined, badgeCount: known ? threads.reduce((sum, t) => sum + (t.unread?.badgeCount ?? 0), 0) : undefined, connection: this.connectionState };
398
436
  }
399
437
  publishGlobal() {
438
+ if (this.closed || this.withdrawn)
439
+ return;
400
440
  const next = this.buildGlobal();
401
441
  if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
402
442
  return;
@@ -415,7 +455,67 @@ export class ChatSession {
415
455
  if (notifyGlobal)
416
456
  this.publishGlobal();
417
457
  }
418
- close() {
458
+ /** Permanently retire this identity/team session. UI withdrawal is immediate;
459
+ * completion means all durable stores have crossed the same authority epoch. */
460
+ revokeAccess() {
461
+ if (this.revoking)
462
+ return this.revoking;
463
+ if (this.closed)
464
+ return Promise.reject(new Error("CHAT_SESSION_CLOSED"));
465
+ this.withdrawn = true;
466
+ const knownThreads = [...new Set([...this.snapshots.keys(), ...this.historyEdges.keys()])];
467
+ this.catchUp.close();
468
+ this.countTasks.close();
469
+ this.off.splice(0).forEach(off => off());
470
+ this.sender.withdraw();
471
+ this.reading.withdraw();
472
+ this.readers.withdraw();
473
+ this.counts?.watch(undefined, []);
474
+ this.countSnapshots.clear();
475
+ this.snapshots.clear();
476
+ this.historyEdges.clear();
477
+ this.earlierReads.clear();
478
+ this.globalSnapshot = undefined;
479
+ this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
480
+ this.globalListeners.forEach(listener => listener());
481
+ if (!this.options.sharedConnection)
482
+ this.connection.close();
483
+ return this.revoking = Promise.resolve().then(async () => {
484
+ const failures = [];
485
+ let version;
486
+ try {
487
+ version = await this.messages.revokeScope();
488
+ }
489
+ catch (error) {
490
+ failures.push(error);
491
+ version = 1;
492
+ try {
493
+ version = Math.max(1, (await this.messages.scopeAccess()).version);
494
+ }
495
+ catch (failure) {
496
+ failures.push(failure);
497
+ }
498
+ }
499
+ const results = await Promise.allSettled([
500
+ this.sender.revoke(version),
501
+ this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
502
+ this.readers.retireScope(version), this.counts?.retireScope(version),
503
+ ]);
504
+ for (const result of results)
505
+ if (result.status === "rejected")
506
+ failures.push(result.reason);
507
+ try {
508
+ await this.dispose();
509
+ }
510
+ catch (error) {
511
+ failures.push(error);
512
+ }
513
+ if (failures.length)
514
+ throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
515
+ });
516
+ }
517
+ close() { return this.revoking ?? this.dispose(); }
518
+ dispose() {
419
519
  return this.closing ??= (async () => {
420
520
  this.closed = true;
421
521
  this.catchUp.close();
@@ -423,14 +523,22 @@ export class ChatSession {
423
523
  this.off.splice(0).forEach(off => off());
424
524
  if (!this.options.sharedConnection)
425
525
  this.connection.close();
426
- await Promise.all([this.counts?.close(), ...this.countClosings, this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
427
- this.listeners.clear();
428
- this.globalListeners.clear();
429
- this.snapshots.clear();
430
- this.countSnapshots.clear();
431
- this.historyEdges.clear();
432
- this.earlierReads.clear();
433
- this.globalSnapshot = undefined;
526
+ const senderClosing = this.sender.close();
527
+ try {
528
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, this.reading.close(), this.messages.close(senderClosing)]);
529
+ const failed = results.find((result) => result.status === "rejected");
530
+ if (failed)
531
+ throw failed.reason;
532
+ }
533
+ finally {
534
+ this.listeners.clear();
535
+ this.globalListeners.clear();
536
+ this.snapshots.clear();
537
+ this.countSnapshots.clear();
538
+ this.historyEdges.clear();
539
+ this.earlierReads.clear();
540
+ this.globalSnapshot = undefined;
541
+ }
434
542
  })();
435
543
  }
436
544
  }
@@ -8,13 +8,30 @@ export type OutgoingMessage = {
8
8
  message: ChatRuntimeMessage;
9
9
  status: "sending" | "failed" | "sent";
10
10
  confirmed?: boolean;
11
+ accessVersion?: number;
12
+ };
13
+ export type SenderScopeAccess = {
14
+ version: number;
15
+ allowed: boolean;
16
+ };
17
+ export type DurableSenderOptions = {
18
+ indexedDB?: IDBFactory;
19
+ name?: string;
20
+ access?: () => Promise<SenderScopeAccess>;
11
21
  };
12
22
  /** Persists the exact wire message before sending; retries preserve its hash. */
13
23
  export declare class DurableChatSender {
14
24
  private client;
15
25
  private onError;
26
+ private options;
16
27
  private db;
17
28
  private outbox;
29
+ private epochs;
30
+ private version;
31
+ private withdrawn;
32
+ private revoking?;
33
+ private channel?;
34
+ private accepted;
18
35
  private scope;
19
36
  private identity;
20
37
  private values;
@@ -22,12 +39,19 @@ export declare class DurableChatSender {
22
39
  private listeners;
23
40
  private running;
24
41
  private closed;
42
+ private closing;
43
+ private empty;
25
44
  private offConnection;
26
45
  private ready;
27
- constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: {
28
- indexedDB?: IDBFactory;
29
- name?: string;
30
- });
46
+ constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: DurableSenderOptions);
47
+ private readAccess;
48
+ private restore;
49
+ private checkAccess;
50
+ private withEpoch;
51
+ private deleteRows;
52
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
53
+ withdraw(): void;
54
+ revoke(targetVersion?: number): Promise<void>;
31
55
  initialize(): Promise<void>;
32
56
  subscribe(listener: () => void): () => void;
33
57
  snapshot(threadId?: string): readonly OutgoingMessage[];