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

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;
@@ -100,12 +100,20 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
100
100
  private migrateLegacy;
101
101
  private database;
102
102
  private profiles;
103
+ private threadRows;
104
+ private messageRows;
105
+ private positionRows;
106
+ private normalizedStates;
103
107
  private versions;
104
108
  private closed;
105
109
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
106
110
  private transaction;
107
111
  private key;
112
+ private hydrate;
108
113
  private loadCurrent;
114
+ /** Message bodies are immutable for local read observations: keep them out of
115
+ * profile/cursor writes, and update only changed normalized rows on sync. */
116
+ private persist;
109
117
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
110
118
  loadVersioned(appId: string, profileId: string): Promise<{
111
119
  state: ReadIndexState | undefined;
@@ -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
  }
@@ -190,6 +190,10 @@ export class IndexedDbReadIndex {
190
190
  migrateLegacy;
191
191
  database;
192
192
  profiles;
193
+ threadRows;
194
+ messageRows;
195
+ positionRows;
196
+ normalizedStates = new WeakSet();
193
197
  versions;
194
198
  closed = false;
195
199
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
@@ -201,6 +205,10 @@ export class IndexedDbReadIndex {
201
205
  // The empty schema retains the legacy out-of-line primary key and records.
202
206
  this.database.version(1).stores({ profiles: "" });
203
207
  this.database.version(2).stores({ profiles: "", versions: "" });
208
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
209
+ this.positionRows = this.database.table("readPositions");
210
+ this.threadRows = this.database.table("readThreads");
211
+ this.messageRows = this.database.table("readMessages");
204
212
  this.profiles = this.database.table("profiles");
205
213
  this.versions = this.database.table("versions");
206
214
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
@@ -211,7 +219,7 @@ export class IndexedDbReadIndex {
211
219
  throw new Error("READ_STORAGE_CLOSED");
212
220
  let started = false;
213
221
  try {
214
- return await this.database.transaction(mode, this.profiles, this.versions, async () => {
222
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
215
223
  if (this.closed)
216
224
  throw new Error("READ_STORAGE_CLOSED");
217
225
  started = true;
@@ -230,25 +238,136 @@ export class IndexedDbReadIndex {
230
238
  throw new Error("READ_STORAGE_UNAVAILABLE");
231
239
  }
232
240
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
241
+ async hydrate(current, key) {
242
+ if (current.normalized) {
243
+ const owner = JSON.stringify(key);
244
+ const [threads, messages, positions] = await Promise.all([
245
+ this.threadRows.where("owner").equals(owner).toArray(),
246
+ this.messageRows.where("owner").equals(owner).toArray(),
247
+ this.positionRows.where("owner").equals(owner).toArray(),
248
+ ]);
249
+ const byThread = new Map();
250
+ for (const row of messages) {
251
+ if (!byThread.has(row.thread))
252
+ byThread.set(row.thread, new Map());
253
+ byThread.get(row.thread).set(row.id, row.value);
254
+ }
255
+ const byPosition = new Map(positions.map(row => [row.id, row]));
256
+ const byId = new Map(threads.map(row => [row.id, row]));
257
+ const { normalized: _normalized, threadOrder, ...state } = current;
258
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
259
+ const row = byId.get(id);
260
+ const position = byPosition.get(id);
261
+ if (!row || !position)
262
+ throw new Error("READ_STORAGE_INCOMPLETE");
263
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
264
+ const message = byThread.get(id)?.get(messageId);
265
+ if (!message)
266
+ throw new Error("READ_STORAGE_INCOMPLETE");
267
+ return message;
268
+ }) };
269
+ }) };
270
+ this.normalizedStates.add(result);
271
+ return result;
272
+ }
273
+ return current;
274
+ }
233
275
  async loadCurrent(appId, profileId) {
234
276
  const current = await this.profiles.get(this.key(appId, profileId));
235
- if (current || !this.migrateLegacy || this.storageScope === undefined)
277
+ if (current?.normalized)
278
+ return this.hydrate(current, this.key(appId, profileId));
279
+ if (current) {
280
+ if (this.migrateLegacy)
281
+ await this.persist(appId, profileId, undefined, current);
236
282
  return current;
283
+ }
284
+ if (!this.migrateLegacy || this.storageScope === undefined)
285
+ return undefined;
237
286
  const markerKey = [appId, profileId, "legacy-owner-v1"];
238
287
  const marker = await this.profiles.get(markerKey);
239
288
  if (marker && marker.legacyOwner !== this.storageScope)
240
289
  return undefined;
241
- const legacy = await this.profiles.get([appId, profileId]);
242
- if (!legacy)
290
+ const legacyRow = await this.profiles.get([appId, profileId]);
291
+ if (!legacyRow)
243
292
  return undefined;
293
+ const legacy = await this.hydrate(legacyRow, [appId, profileId]);
244
294
  if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
245
295
  throw new Error("READ_IDENTITY_MISMATCH");
246
296
  const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
247
297
  await this.profiles.put(ownership, markerKey);
248
- await this.profiles.put(legacy, this.key(appId, profileId));
298
+ await this.persist(appId, profileId, undefined, legacy);
249
299
  await this.profiles.delete([appId, profileId]);
300
+ const oldOwner = JSON.stringify([appId, profileId]);
301
+ await this.threadRows.where("owner").equals(oldOwner).delete();
302
+ await this.messageRows.where("owner").equals(oldOwner).delete();
303
+ await this.positionRows.where("owner").equals(oldOwner).delete();
250
304
  return legacy;
251
305
  }
306
+ /** Message bodies are immutable for local read observations: keep them out of
307
+ * profile/cursor writes, and update only changed normalized rows on sync. */
308
+ async persist(appId, profileId, previous, next) {
309
+ if (previous && !this.normalizedStates.has(previous))
310
+ previous = undefined;
311
+ const owner = JSON.stringify(this.key(appId, profileId));
312
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
313
+ const nextIds = new Set(next.threads.map(thread => thread.id));
314
+ if (nextIds.size !== next.threads.length)
315
+ throw new Error("DUPLICATE_READ_THREAD");
316
+ const threadWrites = [], messageWrites = [], positionWrites = [];
317
+ const messageDeletes = [];
318
+ for (const thread of next.threads) {
319
+ const old = oldThreads.get(thread.id);
320
+ if (old === thread)
321
+ continue;
322
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
323
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
324
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
325
+ positionWrites.push(position);
326
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
327
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
328
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
329
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
330
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
331
+ threadWrites.push(metadata);
332
+ }
333
+ if (old?.messages === messages)
334
+ continue;
335
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
336
+ const ids = new Set();
337
+ for (const message of messages) {
338
+ if (ids.has(message.id))
339
+ throw new Error("INVALID_READ_MESSAGE");
340
+ ids.add(message.id);
341
+ const prior = byId.get(message.id);
342
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
343
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
344
+ }
345
+ for (const id of byId.keys())
346
+ if (!ids.has(id))
347
+ messageDeletes.push([owner, thread.id, id]);
348
+ }
349
+ for (const id of oldThreads.keys())
350
+ if (!nextIds.has(id)) {
351
+ await this.threadRows.delete([owner, id]);
352
+ await this.positionRows.delete([owner, id]);
353
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
354
+ }
355
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
356
+ // atomically; bounding total snapshot transaction bytes is separate work.
357
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
358
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
359
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
360
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
361
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
362
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
363
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
364
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
365
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
366
+ const nextHeader = header(next);
367
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
368
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
369
+ this.normalizedStates.add(next);
370
+ }
252
371
  async load(appId, profileId) {
253
372
  return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
254
373
  }
@@ -270,7 +389,7 @@ export class IndexedDbReadIndex {
270
389
  return state;
271
390
  const next = removeReadTeam(state, teamId, knownThreadIds);
272
391
  await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
273
- await this.profiles.put(next, this.key(appId, profileId));
392
+ await this.persist(appId, profileId, state, next);
274
393
  return next;
275
394
  });
276
395
  }
@@ -280,8 +399,7 @@ export class IndexedDbReadIndex {
280
399
  const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
281
400
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
282
401
  throw new Error("READ_IDENTITY_MISMATCH");
283
- if (JSON.stringify(next) !== JSON.stringify(previous))
284
- await this.profiles.put(next, this.key(appId, profileId));
402
+ await this.persist(appId, profileId, previous, next);
285
403
  return next;
286
404
  });
287
405
  }
@@ -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.26",
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",