@pmate/chat-sdk 0.12.0-next.24 → 0.12.0-next.25

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
@@ -235,3 +235,11 @@ unknown receipt counts stay unknown and an uncached reader detail page rejects
235
235
  without attempting transport. A network failure arriving after the browser went
236
236
  offline does not turn background synchronization into an operation error.
237
237
  Authorization failures still revoke the corresponding cached data.
238
+
239
+ Reading entry points check the message scope even before `initialize()` finishes.
240
+ Concurrent observations share the permission lookup and batch their local writes.
241
+ Visibility/playback operations reject another known team's conversation; a
242
+ permission change in another team does not discard this team's observations.
243
+ Ordinary shutdown waits for accepted local reading writes, while access withdrawal
244
+ rejects late results. Retired asynchronous reading entries reject through promises;
245
+ refresh/subscription teardown remains safe.
@@ -63,6 +63,10 @@ export declare class ChatSession<T extends StoredMessage> {
63
63
  private closedSummary;
64
64
  private connectionState;
65
65
  private catchUp;
66
+ private readingAllowed;
67
+ private readingListeners;
68
+ private acceptedReading;
69
+ private readingAccess?;
66
70
  private readingStarted;
67
71
  private readingStarting?;
68
72
  private withdrawn;
@@ -81,6 +85,10 @@ export declare class ChatSession<T extends StoredMessage> {
81
85
  importLegacyMessages(threadId: string, messages: T[]): Promise<void>;
82
86
  getReadingSnapshot(): import("./readIndex.js").ReadIndexState | undefined;
83
87
  subscribeReading(listener: () => void): () => void;
88
+ private setReadingAllowed;
89
+ private readingAuthority;
90
+ private authorizeReading;
91
+ private readingOperation;
84
92
  refreshReading(): void;
85
93
  synchronizeReading(cursor?: string, profileId?: string): Promise<import("./readIndex.js").ReadIndexState>;
86
94
  /** Explicit text-history acknowledgement; unheard audio stays unread. */
@@ -31,6 +31,10 @@ export class ChatSession {
31
31
  closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
32
32
  connectionState = "closed";
33
33
  catchUp;
34
+ readingAllowed = false;
35
+ readingListeners = new Set();
36
+ acceptedReading = new Set();
37
+ readingAccess;
34
38
  readingStarted = false;
35
39
  readingStarting;
36
40
  withdrawn = false;
@@ -58,7 +62,7 @@ export class ChatSession {
58
62
  return options.connection.onReach(event);
59
63
  throw new Error("REACH_HANDLER_UNAVAILABLE");
60
64
  } });
61
- this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
65
+ this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
62
66
  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
67
  this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
64
68
  this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
@@ -74,7 +78,8 @@ export class ChatSession {
74
78
  this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
75
79
  void this.revokeAccess().catch(error => options.onError(error)); }));
76
80
  this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
77
- this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
81
+ this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => { this.publishAll(); if (this.readingAllowed)
82
+ this.readingListeners.forEach(listener => listener()); }));
78
83
  this.off.push(this.connection.watchMessages(message => {
79
84
  try {
80
85
  const decoded = options.decode(message);
@@ -133,6 +138,7 @@ export class ChatSession {
133
138
  return;
134
139
  this.assertOpen();
135
140
  await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
141
+ this.setReadingAllowed(true);
136
142
  await this.reading.start();
137
143
  this.assertOpen();
138
144
  this.readingStarted = true;
@@ -140,16 +146,60 @@ export class ChatSession {
140
146
  }
141
147
  /** One-time migration only: never establishes trusted history coverage. */
142
148
  importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
143
- getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
149
+ getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
144
150
  subscribeReading(listener) { if (this.closed || this.withdrawn)
145
- return () => { }; return this.reading.subscribe(listener); }
146
- refreshReading() { this.assertOpen(); this.reading.refresh(); }
147
- synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
151
+ return () => { }; this.readingListeners.add(listener); return () => { this.readingListeners.delete(listener); }; }
152
+ setReadingAllowed(allowed) {
153
+ if (this.readingAllowed === allowed)
154
+ return;
155
+ const previous = this.getReadingSnapshot();
156
+ this.readingAllowed = allowed;
157
+ if (previous !== this.getReadingSnapshot())
158
+ this.readingListeners.forEach(listener => listener());
159
+ this.publishAll();
160
+ }
161
+ readingAuthority() {
162
+ return this.readingAccess ??= this.messages.scopeAccess().then(access => { if (!access.allowed)
163
+ this.setReadingAllowed(false); return access; }).finally(() => { this.readingAccess = undefined; });
164
+ }
165
+ async authorizeReading() {
166
+ const access = await this.readingAuthority();
167
+ if (this.withdrawn || !access.allowed)
168
+ throw new Error("CHAT_ACCESS_REVOKED");
169
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
170
+ this.setReadingAllowed(true);
171
+ }
172
+ readingOperation(run, durable = true) {
173
+ if (this.closed || this.withdrawn)
174
+ return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
175
+ const task = (async () => {
176
+ await this.authorizeReading();
177
+ if (this.withdrawn)
178
+ throw new Error("CHAT_ACCESS_REVOKED");
179
+ if (!durable)
180
+ this.assertOpen();
181
+ const next = await run();
182
+ if (this.withdrawn || !(await this.readingAuthority()).allowed)
183
+ throw new Error("CHAT_ACCESS_REVOKED");
184
+ return next;
185
+ })();
186
+ if (durable) {
187
+ this.acceptedReading.add(task);
188
+ void task.finally(() => this.acceptedReading.delete(task)).catch(() => { });
189
+ }
190
+ return task;
191
+ }
192
+ refreshReading() {
193
+ if (this.closed || this.withdrawn)
194
+ return;
195
+ void this.authorizeReading().then(() => { if (!this.closed && !this.withdrawn)
196
+ this.reading.refresh(); }).catch(error => { if (!this.closed && !this.withdrawn)
197
+ this.options.onError(error); });
198
+ }
199
+ synchronizeReading(cursor, profileId) { return this.readingOperation(() => this.reading.synchronize(cursor, profileId), false); }
148
200
  /** Explicit text-history acknowledgement; unheard audio stays unread. */
149
- markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages).then(next => { if (this.withdrawn)
150
- throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
151
- updateReading(transform) { this.assertOpen(); return this.reading.update(transform).then(next => { if (this.withdrawn)
152
- throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
201
+ markRead(threadId, messages = []) { return this.readingOperation(() => this.reading.markRead(threadId, messages)); }
202
+ updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
153
203
  assertOpen() { if (this.withdrawn)
154
204
  throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
155
205
  throw new Error("CHAT_SESSION_CLOSED"); }
@@ -278,7 +328,7 @@ export class ChatSession {
278
328
  }
279
329
  if (!threadId)
280
330
  throw new Error("INVALID_READING_THREAD");
281
- return (event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids)).then(next => { this.assertOpen(); return next; });
331
+ return this.readingOperation(() => event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids));
282
332
  }
283
333
  /** Bridge transports can delegate social events without maintaining clients. */
284
334
  async handleReach(event) {
@@ -298,7 +348,7 @@ export class ChatSession {
298
348
  if (event.payload.profileId !== this.options.messages.scope.profileId)
299
349
  throw new Error("Reading reach identity mismatch");
300
350
  await this.initialize();
301
- await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
351
+ await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
302
352
  this.assertOpen();
303
353
  }
304
354
  return "handled";
@@ -379,7 +429,7 @@ export class ChatSession {
379
429
  let snapshot = this.snapshots.get(threadId);
380
430
  if (!snapshot) {
381
431
  const messages = this.messages.getSnapshot(threadId);
382
- const state = this.reading.getSnapshot();
432
+ const state = this.getReadingSnapshot();
383
433
  snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
384
434
  this.snapshots.set(threadId, snapshot);
385
435
  }
@@ -426,7 +476,7 @@ export class ChatSession {
426
476
  }
427
477
  buildGlobal() {
428
478
  const ids = new Set(this.snapshots.keys());
429
- for (const thread of this.reading.getSnapshot()?.threads ?? [])
479
+ for (const thread of this.getReadingSnapshot()?.threads ?? [])
430
480
  if (thread.teamId === this.options.messages.scope.teamId)
431
481
  ids.add(thread.conversationId ?? thread.id);
432
482
  for (const outgoing of this.sender.snapshot())
@@ -435,7 +485,7 @@ export class ChatSession {
435
485
  const snapshot = this.getSnapshot(threadId);
436
486
  return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
437
487
  });
438
- const state = this.reading.getSnapshot();
488
+ const state = this.getReadingSnapshot();
439
489
  const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
440
490
  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 };
441
491
  }
@@ -483,6 +533,7 @@ export class ChatSession {
483
533
  this.globalSnapshot = undefined;
484
534
  this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
485
535
  this.globalListeners.forEach(listener => listener());
536
+ this.readingListeners.forEach(listener => listener());
486
537
  if (!this.options.sharedConnection)
487
538
  this.connection.close();
488
539
  return this.revoking = Promise.resolve().then(async () => {
@@ -529,8 +580,10 @@ export class ChatSession {
529
580
  if (!this.options.sharedConnection)
530
581
  this.connection.close();
531
582
  const senderClosing = this.sender.close();
583
+ const readingBarrier = Promise.allSettled([...this.acceptedReading]);
584
+ const readingClosing = readingBarrier.then(() => this.reading.close());
532
585
  try {
533
- const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, this.reading.close(), this.messages.close(senderClosing)]);
586
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, readingBarrier]))]);
534
587
  const failed = results.find((result) => result.status === "rejected");
535
588
  if (failed)
536
589
  throw failed.reason;
@@ -538,6 +591,7 @@ export class ChatSession {
538
591
  finally {
539
592
  this.listeners.clear();
540
593
  this.globalListeners.clear();
594
+ this.readingListeners.clear();
541
595
  this.snapshots.clear();
542
596
  this.countSnapshots.clear();
543
597
  this.historyEdges.clear();
@@ -293,6 +293,7 @@ export class DurableChatSender {
293
293
  this.offConnection();
294
294
  this.listeners.clear();
295
295
  this.channel?.close();
296
+ this.channel = undefined;
296
297
  try {
297
298
  await this.ready;
298
299
  await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
@@ -79,7 +79,7 @@ export declare function unreadInThread(state: ReadIndexState, threadId: string):
79
79
  };
80
80
  export type ReadIndexVersions = Readonly<Record<string, number>>;
81
81
  export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
82
- export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions): void;
82
+ export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions, teamId?: string): void;
83
83
  /** Remove only the withdrawn team's original sources, logical conversations and
84
84
  * queued observations. Other teams' pending operations must survive. */
85
85
  export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
@@ -165,8 +165,8 @@ export function unreadInThread(state, threadId) {
165
165
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
166
  }
167
167
  function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
168
- export function assertReadIndexVersions(expected, current = {}) {
169
- const teams = new Set([...Object.keys(expected), ...Object.keys(current)]);
168
+ export function assertReadIndexVersions(expected, current = {}, teamId) {
169
+ const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
170
170
  if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
171
171
  throw new Error("READ_SCOPE_CHANGED");
172
172
  }
@@ -5,6 +5,7 @@ export type ReadingClientOptions = {
5
5
  profileId: string;
6
6
  sessionToken: string;
7
7
  hubHttpUrl: string;
8
+ teamId?: string;
8
9
  persistence?: ReadIndexPersistence;
9
10
  storageScope?: string;
10
11
  migrateLegacy?: boolean;
@@ -21,6 +22,7 @@ export declare class ReadingClient {
21
22
  private closing?;
22
23
  private revoking?;
23
24
  private versions?;
25
+ private prepared;
24
26
  private accepted;
25
27
  private persistence;
26
28
  private sync;
@@ -41,7 +43,9 @@ export declare class ReadingClient {
41
43
  refresh: () => void;
42
44
  synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
43
45
  update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
46
+ private updateScoped;
44
47
  prepareTeam(teamId: string, version: number): Promise<void>;
48
+ private assertThreadTeam;
45
49
  markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
46
50
  recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
47
51
  recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
@@ -1,3 +1,4 @@
1
+ import { isValidChatTeamId } from "./thread.js";
1
2
  import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
2
3
  import { recordConversationRead } from "./conversationRead.js";
3
4
  import { BatchedReadIndex } from "./batchedReadIndex.js";
@@ -13,6 +14,7 @@ export class ReadingClient {
13
14
  closing;
14
15
  revoking;
15
16
  versions;
17
+ prepared = new Map();
16
18
  accepted = new Set();
17
19
  persistence;
18
20
  sync;
@@ -26,6 +28,8 @@ export class ReadingClient {
26
28
  generation = 0;
27
29
  constructor(options) {
28
30
  this.options = options;
31
+ if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
32
+ throw new Error("INVALID_READ_TEAM");
29
33
  const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
30
34
  this.backing = storage;
31
35
  this.persistence = new BatchedReadIndex(storage);
@@ -94,6 +98,11 @@ export class ReadingClient {
94
98
  throw new Error("Reading projection has not reached the announced version");
95
99
  }
96
100
  update(transform) {
101
+ // Advanced transforms may replace profile-wide state. Typed observation
102
+ // methods below only derive changes for their configured team.
103
+ return this.updateScoped(transform);
104
+ }
105
+ updateScoped(transform, teamId) {
97
106
  if (this.closed)
98
107
  return Promise.reject(new Error("READING_CLIENT_CLOSED"));
99
108
  // One shared metadata read per local authority generation, rather than one
@@ -102,7 +111,7 @@ export class ReadingClient {
102
111
  const task = (async () => {
103
112
  const expected = await versions;
104
113
  const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
105
- assertReadIndexVersions(expected, currentVersions);
114
+ assertReadIndexVersions(expected, currentVersions, teamId);
106
115
  return transform(state);
107
116
  });
108
117
  if (!this.closed) {
@@ -111,28 +120,48 @@ export class ReadingClient {
111
120
  this.refresh();
112
121
  }
113
122
  return next;
114
- })().catch(error => { if (String(error).includes("READ_SCOPE_CHANGED"))
115
- this.versions = undefined; throw error; });
123
+ })().catch(error => {
124
+ if (String(error).includes("READ_SCOPE_CHANGED")) {
125
+ if (teamId !== undefined)
126
+ this.withdraw();
127
+ else
128
+ this.versions = undefined;
129
+ }
130
+ throw error;
131
+ });
116
132
  this.accepted.add(task);
117
133
  void task.finally(() => this.accepted.delete(task)).catch(() => { });
118
134
  return task;
119
135
  }
120
- async prepareTeam(teamId, version) {
136
+ prepareTeam(teamId, version) {
121
137
  if (this.closed)
122
- throw new Error("READING_CLIENT_CLOSED");
123
- if (version > 0) {
124
- await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
138
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
139
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 0)
140
+ return Promise.reject(new Error("INVALID_READ_SCOPE_VERSION"));
141
+ const previous = this.prepared.get(teamId);
142
+ if (previous && previous.version >= version)
143
+ return previous.task;
144
+ const task = (async () => {
145
+ if (version > 0)
146
+ await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
125
147
  this.versions = undefined;
126
- }
148
+ })().catch(error => { if (this.prepared.get(teamId)?.task === task)
149
+ this.prepared.delete(teamId); throw error; });
150
+ this.prepared.set(teamId, { version, task });
151
+ return task;
152
+ }
153
+ assertThreadTeam(state, threadId) {
154
+ if (this.options.teamId !== undefined && state.threads.some(thread => (thread.id === threadId || thread.conversationId === threadId) && thread.teamId !== undefined && thread.teamId !== this.options.teamId))
155
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
127
156
  }
128
157
  markRead(threadId, messages = []) {
129
- return this.update(state => recordConversationRead(state, threadId, messages));
158
+ return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
130
159
  }
131
160
  recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
132
161
  recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
133
162
  record(threadId, ids, readPolicy) {
134
163
  const messageIds = [...new Set(ids)];
135
- return this.update(state => messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state));
164
+ return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state); }, this.options.teamId);
136
165
  }
137
166
  withdraw() { this.stop(); }
138
167
  stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.24",
3
+ "version": "0.12.0-next.25",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",