@pmate/chat-sdk 0.12.0-next.23 → 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
@@ -228,3 +228,18 @@ Custom primitive integrations must initialize them with the same authenticated
228
228
  scope, dispose them on identity changes, and wire their transport to an authorized
229
229
  server. Standard browser integrations should use the facade instead. The Merry migration and production validation
230
230
  are tracked in the unified message layer proposal.
231
+
232
+ When the browser reports offline, scheduled message restoration and reading
233
+ refreshes wait for the normal reconnect/wake path. Cached reads remain available;
234
+ unknown receipt counts stay unknown and an uncached reader detail page rejects
235
+ without attempting transport. A network failure arriving after the browser went
236
+ offline does not turn background synchronization into an operation error.
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);
@@ -97,7 +102,8 @@ export class ChatSession {
97
102
  this.publishGlobal();
98
103
  if (state === "connected") {
99
104
  const since = Date.now();
100
- this.catchUp.schedule("scope", () => this.messages.restore(since, options.activeThreadId));
105
+ this.catchUp.schedule("scope", async () => { if (this.connectionState === "connected" && !this.closed && !this.withdrawn)
106
+ await this.messages.restore(since, options.activeThreadId); });
101
107
  if (this.readingStarted)
102
108
  this.reading.refresh();
103
109
  this.wakeCounts();
@@ -132,6 +138,7 @@ export class ChatSession {
132
138
  return;
133
139
  this.assertOpen();
134
140
  await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
141
+ this.setReadingAllowed(true);
135
142
  await this.reading.start();
136
143
  this.assertOpen();
137
144
  this.readingStarted = true;
@@ -139,16 +146,60 @@ export class ChatSession {
139
146
  }
140
147
  /** One-time migration only: never establishes trusted history coverage. */
141
148
  importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
142
- getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
149
+ getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
143
150
  subscribeReading(listener) { if (this.closed || this.withdrawn)
144
- return () => { }; return this.reading.subscribe(listener); }
145
- refreshReading() { this.assertOpen(); this.reading.refresh(); }
146
- 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); }
147
200
  /** Explicit text-history acknowledgement; unheard audio stays unread. */
148
- markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages).then(next => { if (this.withdrawn)
149
- throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
150
- updateReading(transform) { this.assertOpen(); return this.reading.update(transform).then(next => { if (this.withdrawn)
151
- 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)); }
152
203
  assertOpen() { if (this.withdrawn)
153
204
  throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
154
205
  throw new Error("CHAT_SESSION_CLOSED"); }
@@ -277,7 +328,7 @@ export class ChatSession {
277
328
  }
278
329
  if (!threadId)
279
330
  throw new Error("INVALID_READING_THREAD");
280
- 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));
281
332
  }
282
333
  /** Bridge transports can delegate social events without maintaining clients. */
283
334
  async handleReach(event) {
@@ -297,7 +348,7 @@ export class ChatSession {
297
348
  if (event.payload.profileId !== this.options.messages.scope.profileId)
298
349
  throw new Error("Reading reach identity mismatch");
299
350
  await this.initialize();
300
- await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
351
+ await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
301
352
  this.assertOpen();
302
353
  }
303
354
  return "handled";
@@ -378,7 +429,7 @@ export class ChatSession {
378
429
  let snapshot = this.snapshots.get(threadId);
379
430
  if (!snapshot) {
380
431
  const messages = this.messages.getSnapshot(threadId);
381
- const state = this.reading.getSnapshot();
432
+ const state = this.getReadingSnapshot();
382
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 };
383
434
  this.snapshots.set(threadId, snapshot);
384
435
  }
@@ -425,7 +476,7 @@ export class ChatSession {
425
476
  }
426
477
  buildGlobal() {
427
478
  const ids = new Set(this.snapshots.keys());
428
- for (const thread of this.reading.getSnapshot()?.threads ?? [])
479
+ for (const thread of this.getReadingSnapshot()?.threads ?? [])
429
480
  if (thread.teamId === this.options.messages.scope.teamId)
430
481
  ids.add(thread.conversationId ?? thread.id);
431
482
  for (const outgoing of this.sender.snapshot())
@@ -434,7 +485,7 @@ export class ChatSession {
434
485
  const snapshot = this.getSnapshot(threadId);
435
486
  return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
436
487
  });
437
- const state = this.reading.getSnapshot();
488
+ const state = this.getReadingSnapshot();
438
489
  const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
439
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 };
440
491
  }
@@ -482,6 +533,7 @@ export class ChatSession {
482
533
  this.globalSnapshot = undefined;
483
534
  this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
484
535
  this.globalListeners.forEach(listener => listener());
536
+ this.readingListeners.forEach(listener => listener());
485
537
  if (!this.options.sharedConnection)
486
538
  this.connection.close();
487
539
  return this.revoking = Promise.resolve().then(async () => {
@@ -528,8 +580,10 @@ export class ChatSession {
528
580
  if (!this.options.sharedConnection)
529
581
  this.connection.close();
530
582
  const senderClosing = this.sender.close();
583
+ const readingBarrier = Promise.allSettled([...this.acceptedReading]);
584
+ const readingClosing = readingBarrier.then(() => this.reading.close());
531
585
  try {
532
- 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]))]);
533
587
  const failed = results.find((result) => result.status === "rejected");
534
588
  if (failed)
535
589
  throw failed.reason;
@@ -537,6 +591,7 @@ export class ChatSession {
537
591
  finally {
538
592
  this.listeners.clear();
539
593
  this.globalListeners.clear();
594
+ this.readingListeners.clear();
540
595
  this.snapshots.clear();
541
596
  this.countSnapshots.clear();
542
597
  this.historyEdges.clear();
@@ -0,0 +1,4 @@
1
+ /** Browser connectivity is advisory: only suppress network errors while the
2
+ * browser explicitly reports offline. Permission and data errors stay visible. */
3
+ export declare function isOffline(): boolean;
4
+ export declare function isOfflineNetworkFailure(error: unknown): boolean;
@@ -0,0 +1,8 @@
1
+ /** Browser connectivity is advisory: only suppress network errors while the
2
+ * browser explicitly reports offline. Permission and data errors stay visible. */
3
+ export function isOffline() {
4
+ return typeof navigator !== "undefined" && navigator.onLine === false;
5
+ }
6
+ export function isOfflineNetworkFailure(error) {
7
+ return isOffline() && (error instanceof TypeError || error?.name === "AbortError");
8
+ }
@@ -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] : [])]);
@@ -1,3 +1,4 @@
1
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
1
2
  import { WindowedTasks } from "./coalescer.js";
2
3
  import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
3
4
  /** Local-first message facade; hosts never select a cache or network source. */
@@ -48,7 +49,8 @@ export class MessageClient {
48
49
  this.invalidate(thread);
49
50
  else
50
51
  options.onError?.(error, thread); });
51
- this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
52
+ this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => { if (!isOfflineNetworkFailure(error))
53
+ options.onError?.(error, thread); });
52
54
  if (typeof BroadcastChannel !== "undefined") {
53
55
  this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
54
56
  this.channel.onmessage = event => {
@@ -109,15 +111,17 @@ export class MessageClient {
109
111
  subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
110
112
  /** Close the connection gap for saved windows with four bounded background lanes. */
111
113
  async restore(since, threadId) {
114
+ if (this.closed || this.closing || this.scopeDenied || isOffline())
115
+ return;
112
116
  const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
113
117
  await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
114
- while (queue.length && !this.closed && !this.closing) {
118
+ while (queue.length && !this.closed && !this.closing && !this.scopeDenied && !isOffline()) {
115
119
  const window = queue.shift();
116
120
  try {
117
121
  await this.synchronize(window.thread);
118
122
  }
119
123
  catch (error) {
120
- if (!this.closed && !this.closing)
124
+ if (!this.closed && !this.closing && !isOfflineNetworkFailure(error))
121
125
  this.options.onError?.(error, window.thread);
122
126
  }
123
127
  }
@@ -198,8 +202,11 @@ export class MessageClient {
198
202
  }
199
203
  /** Application connection/wake events invalidate, rather than fetching directly. */
200
204
  invalidate(thread) {
201
- if (!this.closed && !this.closing && !this.revoked.has(thread))
202
- this.syncTasks.schedule(thread, () => this.synchronize(thread));
205
+ if (!this.closed && !this.closing && !this.scopeDenied && !isOffline() && !this.revoked.has(thread))
206
+ this.syncTasks.schedule(thread, async () => {
207
+ if (!isOffline())
208
+ await this.synchronize(thread);
209
+ });
203
210
  }
204
211
  async fallback(thread, limit, before) {
205
212
  await this.ensureScope();
@@ -1,3 +1,4 @@
1
+ import { isOffline } from "./connectivity.js";
1
2
  import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
2
3
  import { messageScopeKey } from "./messageStore.js";
3
4
  export class MessageReadersError extends Error {
@@ -95,6 +96,8 @@ export class MessageReadersClient {
95
96
  const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
96
97
  if (this.lifetime.signal.aborted)
97
98
  throw new Error("MESSAGE_READERS_CLOSED");
99
+ if (isOffline())
100
+ throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
98
101
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
99
102
  const controller = new AbortController();
100
103
  const cancel = () => controller.abort(this.lifetime.signal.reason);
@@ -1,3 +1,4 @@
1
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
1
2
  import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
2
3
  import { messageScopeKey } from "./messageStore.js";
3
4
  let subscriptionSequence = 0;
@@ -59,6 +60,8 @@ export class ReadCountsClient {
59
60
  return envelope.data;
60
61
  }
61
62
  subscription(threadId, release) {
63
+ if (release && isOffline())
64
+ return Promise.resolve();
62
65
  return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
63
66
  }
64
67
  renew() {
@@ -98,7 +101,8 @@ export class ReadCountsClient {
98
101
  clearInterval(this.heartbeat);
99
102
  this.heartbeat = undefined;
100
103
  }
101
- this.options.onError?.(error);
104
+ if (!isOfflineNetworkFailure(error))
105
+ this.options.onError?.(error);
102
106
  }).finally(() => { if (this.subscriptionPending === pending)
103
107
  this.subscriptionPending = undefined; });
104
108
  this.subscriptionPending = pending;
@@ -197,6 +201,8 @@ export class ReadCountsClient {
197
201
  if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
198
202
  return;
199
203
  }
204
+ if (isOffline())
205
+ return;
200
206
  const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
201
207
  if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
202
208
  return;
@@ -217,6 +223,10 @@ export class ReadCountsClient {
217
223
  this.renew();
218
224
  }
219
225
  catch (error) {
226
+ if (isOfflineNetworkFailure(error)) {
227
+ this.dirty = false;
228
+ return;
229
+ }
220
230
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
221
231
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
222
232
  this.denyCache();
@@ -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,5 @@
1
+ import { isValidChatTeamId } from "./thread.js";
2
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
1
3
  import { recordConversationRead } from "./conversationRead.js";
2
4
  import { BatchedReadIndex } from "./batchedReadIndex.js";
3
5
  import { WindowedTasks } from "./coalescer.js";
@@ -12,6 +14,7 @@ export class ReadingClient {
12
14
  closing;
13
15
  revoking;
14
16
  versions;
17
+ prepared = new Map();
15
18
  accepted = new Set();
16
19
  persistence;
17
20
  sync;
@@ -25,6 +28,8 @@ export class ReadingClient {
25
28
  generation = 0;
26
29
  constructor(options) {
27
30
  this.options = options;
31
+ if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
32
+ throw new Error("INVALID_READ_TEAM");
28
33
  const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
29
34
  this.backing = storage;
30
35
  this.persistence = new BatchedReadIndex(storage);
@@ -40,7 +45,7 @@ export class ReadingClient {
40
45
  this.listeners.forEach(listener => listener());
41
46
  }
42
47
  failed(error) {
43
- if (this.closed)
48
+ if (this.closed || isOfflineNetworkFailure(error))
44
49
  return;
45
50
  this.options.onError?.(error);
46
51
  if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
@@ -63,8 +68,9 @@ export class ReadingClient {
63
68
  this.refresh();
64
69
  }
65
70
  refresh = () => {
66
- if (!this.closed)
67
- this.tasks.schedule("reading", async () => { await this.synchronize(); });
71
+ if (!this.closed && !isOffline())
72
+ this.tasks.schedule("reading", async () => { if (!isOffline())
73
+ await this.synchronize(); });
68
74
  };
69
75
  async synchronize(targetCursor, profileId) {
70
76
  if (this.closed)
@@ -92,6 +98,11 @@ export class ReadingClient {
92
98
  throw new Error("Reading projection has not reached the announced version");
93
99
  }
94
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) {
95
106
  if (this.closed)
96
107
  return Promise.reject(new Error("READING_CLIENT_CLOSED"));
97
108
  // One shared metadata read per local authority generation, rather than one
@@ -100,7 +111,7 @@ export class ReadingClient {
100
111
  const task = (async () => {
101
112
  const expected = await versions;
102
113
  const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
103
- assertReadIndexVersions(expected, currentVersions);
114
+ assertReadIndexVersions(expected, currentVersions, teamId);
104
115
  return transform(state);
105
116
  });
106
117
  if (!this.closed) {
@@ -109,28 +120,48 @@ export class ReadingClient {
109
120
  this.refresh();
110
121
  }
111
122
  return next;
112
- })().catch(error => { if (String(error).includes("READ_SCOPE_CHANGED"))
113
- 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
+ });
114
132
  this.accepted.add(task);
115
133
  void task.finally(() => this.accepted.delete(task)).catch(() => { });
116
134
  return task;
117
135
  }
118
- async prepareTeam(teamId, version) {
136
+ prepareTeam(teamId, version) {
119
137
  if (this.closed)
120
- throw new Error("READING_CLIENT_CLOSED");
121
- if (version > 0) {
122
- 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);
123
147
  this.versions = undefined;
124
- }
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");
125
156
  }
126
157
  markRead(threadId, messages = []) {
127
- 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);
128
159
  }
129
160
  recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
130
161
  recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
131
162
  record(threadId, ids, readPolicy) {
132
163
  const messageIds = [...new Set(ids)];
133
- 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);
134
165
  }
135
166
  withdraw() { this.stop(); }
136
167
  stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.23",
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",