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

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,35 @@ 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
147
  markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages); }
128
148
  updateReading(transform) { this.assertOpen(); return this.reading.update(transform); }
129
- assertOpen() { if (this.closed)
149
+ assertOpen() { if (this.withdrawn)
150
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
130
151
  throw new Error("CHAT_SESSION_CLOSED"); }
131
152
  async getMessages(threadId, options = {}) {
132
153
  this.assertOpen();
@@ -145,7 +166,10 @@ export class ChatSession {
145
166
  return this.readAll(threadId);
146
167
  }
147
168
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
169
+ this.assertOpen();
148
170
  await this.sender.confirm(page.messages.map(message => message.id));
171
+ if (this.initializing && !this.readingStarted && page.complete)
172
+ await this.startReading();
149
173
  const edge = this.historyEdges.get(threadId);
150
174
  if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
151
175
  this.rememberHistoryEdge(threadId, page);
@@ -285,6 +309,8 @@ export class ChatSession {
285
309
  this.listeners.get(thread)?.forEach(listener => listener());
286
310
  }
287
311
  publishCounts(value) {
312
+ if (this.closed || this.withdrawn)
313
+ return;
288
314
  const previous = this.countSnapshots.get(value.threadId);
289
315
  if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
290
316
  return;
@@ -311,13 +337,13 @@ export class ChatSession {
311
337
  this.countClosings.add(closing);
312
338
  }
313
339
  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,
340
+ 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
341
  onChange: value => { if (!this.closed && this.counts === client)
316
342
  this.publishCounts(value); },
317
343
  onError: error => {
318
344
  if (this.closed || this.counts !== client)
319
345
  return;
320
- if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
346
+ if (/READ_COUNTS_HTTP_(401|403)|RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error))) {
321
347
  const threads = [...this.countSnapshots.keys()];
322
348
  this.countSnapshots.clear();
323
349
  threads.forEach(thread => this.publishCountSnapshot(thread));
@@ -331,6 +357,11 @@ export class ChatSession {
331
357
  this.counts.watch(threadId, ids);
332
358
  }
333
359
  getSnapshot(threadId) {
360
+ if (this.closed || this.withdrawn) {
361
+ if (threadId === undefined)
362
+ return this.closedSummary;
363
+ return this.closedThread;
364
+ }
334
365
  if (threadId === undefined) {
335
366
  if (!this.globalSnapshot)
336
367
  this.globalSnapshot = this.buildGlobal();
@@ -377,6 +408,8 @@ export class ChatSession {
377
408
  };
378
409
  }
379
410
  publishAll() {
411
+ if (this.closed || this.withdrawn)
412
+ return;
380
413
  for (const threadId of [...this.snapshots.keys()])
381
414
  this.publish(threadId, false);
382
415
  this.publishGlobal();
@@ -397,6 +430,8 @@ export class ChatSession {
397
430
  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
431
  }
399
432
  publishGlobal() {
433
+ if (this.closed || this.withdrawn)
434
+ return;
400
435
  const next = this.buildGlobal();
401
436
  if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
402
437
  return;
@@ -415,7 +450,67 @@ export class ChatSession {
415
450
  if (notifyGlobal)
416
451
  this.publishGlobal();
417
452
  }
418
- close() {
453
+ /** Permanently retire this identity/team session. UI withdrawal is immediate;
454
+ * completion means all durable stores have crossed the same authority epoch. */
455
+ revokeAccess() {
456
+ if (this.revoking)
457
+ return this.revoking;
458
+ if (this.closed)
459
+ return Promise.reject(new Error("CHAT_SESSION_CLOSED"));
460
+ this.withdrawn = true;
461
+ const knownThreads = [...new Set([...this.snapshots.keys(), ...this.historyEdges.keys()])];
462
+ this.catchUp.close();
463
+ this.countTasks.close();
464
+ this.off.splice(0).forEach(off => off());
465
+ this.sender.withdraw();
466
+ this.reading.withdraw();
467
+ this.readers.withdraw();
468
+ this.counts?.watch(undefined, []);
469
+ this.countSnapshots.clear();
470
+ this.snapshots.clear();
471
+ this.historyEdges.clear();
472
+ this.earlierReads.clear();
473
+ this.globalSnapshot = undefined;
474
+ this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
475
+ this.globalListeners.forEach(listener => listener());
476
+ if (!this.options.sharedConnection)
477
+ this.connection.close();
478
+ return this.revoking = Promise.resolve().then(async () => {
479
+ const failures = [];
480
+ let version;
481
+ try {
482
+ version = await this.messages.revokeScope();
483
+ }
484
+ catch (error) {
485
+ failures.push(error);
486
+ version = 1;
487
+ try {
488
+ version = Math.max(1, (await this.messages.scopeAccess()).version);
489
+ }
490
+ catch (failure) {
491
+ failures.push(failure);
492
+ }
493
+ }
494
+ const results = await Promise.allSettled([
495
+ this.sender.revoke(version),
496
+ this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
497
+ this.readers.retireScope(version), this.counts?.retireScope(version),
498
+ ]);
499
+ for (const result of results)
500
+ if (result.status === "rejected")
501
+ failures.push(result.reason);
502
+ try {
503
+ await this.dispose();
504
+ }
505
+ catch (error) {
506
+ failures.push(error);
507
+ }
508
+ if (failures.length)
509
+ throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
510
+ });
511
+ }
512
+ close() { return this.revoking ?? this.dispose(); }
513
+ dispose() {
419
514
  return this.closing ??= (async () => {
420
515
  this.closed = true;
421
516
  this.catchUp.close();
@@ -423,14 +518,22 @@ export class ChatSession {
423
518
  this.off.splice(0).forEach(off => off());
424
519
  if (!this.options.sharedConnection)
425
520
  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;
521
+ const senderClosing = this.sender.close();
522
+ try {
523
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, this.reading.close(), this.messages.close(senderClosing)]);
524
+ const failed = results.find((result) => result.status === "rejected");
525
+ if (failed)
526
+ throw failed.reason;
527
+ }
528
+ finally {
529
+ this.listeners.clear();
530
+ this.globalListeners.clear();
531
+ this.snapshots.clear();
532
+ this.countSnapshots.clear();
533
+ this.historyEdges.clear();
534
+ this.earlierReads.clear();
535
+ this.globalSnapshot = undefined;
536
+ }
434
537
  })();
435
538
  }
436
539
  }
@@ -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[];