@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.40

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.
Files changed (44) hide show
  1. package/README.md +41 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +11 -2
  3. package/lib/runtime/batchedReadIndex.js +21 -1
  4. package/lib/runtime/chatClient.d.ts +1 -0
  5. package/lib/runtime/chatClient.js +1 -1
  6. package/lib/runtime/chatSession.d.ts +78 -8
  7. package/lib/runtime/chatSession.js +504 -39
  8. package/lib/runtime/connectivity.d.ts +4 -0
  9. package/lib/runtime/connectivity.js +8 -0
  10. package/lib/runtime/conversationRead.d.ts +13 -0
  11. package/lib/runtime/conversationRead.js +34 -0
  12. package/lib/runtime/conversationReading.d.ts +28 -0
  13. package/lib/runtime/conversationReading.js +47 -0
  14. package/lib/runtime/createChat.d.ts +3 -0
  15. package/lib/runtime/createChat.js +18 -5
  16. package/lib/runtime/durableSender.d.ts +28 -4
  17. package/lib/runtime/durableSender.js +155 -34
  18. package/lib/runtime/index.d.ts +3 -0
  19. package/lib/runtime/index.js +3 -0
  20. package/lib/runtime/messageClient.d.ts +33 -6
  21. package/lib/runtime/messageClient.js +443 -71
  22. package/lib/runtime/messageControls.d.ts +19 -0
  23. package/lib/runtime/messageControls.js +44 -0
  24. package/lib/runtime/messageReaders.d.ts +12 -1
  25. package/lib/runtime/messageReaders.js +67 -3
  26. package/lib/runtime/messageStore.d.ts +41 -2
  27. package/lib/runtime/messageStore.js +212 -10
  28. package/lib/runtime/messageTimeline.d.ts +50 -0
  29. package/lib/runtime/messageTimeline.js +120 -0
  30. package/lib/runtime/readCounts.d.ts +15 -1
  31. package/lib/runtime/readCounts.js +96 -8
  32. package/lib/runtime/readIndex.d.ts +44 -6
  33. package/lib/runtime/readIndex.js +238 -11
  34. package/lib/runtime/readSync.d.ts +1 -0
  35. package/lib/runtime/readSync.js +21 -8
  36. package/lib/runtime/readingClient.d.ts +19 -0
  37. package/lib/runtime/readingClient.js +121 -18
  38. package/lib/runtime/readingContent.d.ts +5 -0
  39. package/lib/runtime/readingContent.js +23 -0
  40. package/lib/runtime/recordCache.d.ts +9 -0
  41. package/lib/runtime/recordCache.js +62 -5
  42. package/lib/runtime/responseCache.d.ts +44 -0
  43. package/lib/runtime/responseCache.js +173 -0
  44. package/package.json +1 -1
@@ -1,5 +1,24 @@
1
1
  import { Dexie } from "dexie";
2
+ import { isReadingControl } from "./readingContent.js";
2
3
  import { isValidChatTeamId } from "./thread.js";
4
+ /** Retain read semantics without a second durable copy of message bodies. */
5
+ export function readingMetadata(state) {
6
+ let changed = false;
7
+ const threads = state.threads.map(thread => {
8
+ let different = false;
9
+ const messages = thread.messages.map(message => {
10
+ if (message.payload === null && typeof message.readingControl === "boolean")
11
+ return message;
12
+ different = true;
13
+ return { ...message, payload: null, readingControl: Boolean(message.readingControl) || isReadingControl(message.payload, state.appId) };
14
+ });
15
+ if (!different)
16
+ return thread;
17
+ changed = true;
18
+ return { ...thread, messages };
19
+ });
20
+ return changed ? { ...state, threads } : state;
21
+ }
3
22
  export class ReadSyncGap extends Error {
4
23
  cursor;
5
24
  constructor(cursor) {
@@ -153,31 +172,66 @@ export function coalesceReadOperations(current) {
153
172
  return [...batches.values()];
154
173
  }
155
174
  /** Counts are over the indexed message coverage, not an estimate of missing history. */
156
- export function unreadInThread(state, threadId) {
175
+ export function unreadInThread(state, threadId, hidden) {
157
176
  const thread = state.threads.find(t => t.id === threadId);
158
177
  if (!thread)
159
178
  throw new Error("UNKNOWN_READ_THREAD");
160
179
  const explicit = new Set(thread.explicitReadMessageIds);
161
- const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
180
+ const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !(message.readingControl || isReadingControl(message.payload, state.appId)) && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
181
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
163
182
  const unreadMessageCount = messages.length;
164
183
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
165
184
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
185
  }
186
+ function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
187
+ export function assertReadIndexVersions(expected, current = {}, teamId) {
188
+ const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
189
+ if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
190
+ throw new Error("READ_SCOPE_CHANGED");
191
+ }
192
+ /** Remove only the withdrawn team's original sources, logical conversations and
193
+ * queued observations. Other teams' pending operations must survive. */
194
+ export function removeReadTeam(state, teamId, knownThreadIds = []) {
195
+ const removed = new Set(knownThreadIds);
196
+ for (const thread of state.threads)
197
+ if (thread.teamId === teamId) {
198
+ removed.add(thread.id);
199
+ if (thread.conversationId)
200
+ removed.add(thread.conversationId);
201
+ }
202
+ return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
203
+ pending: state.pending.filter(operation => !removed.has(operation.threadId)),
204
+ observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
205
+ }
167
206
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
207
  export class IndexedDbReadIndex {
169
208
  storageScope;
209
+ migrateLegacy;
210
+ metadataOnly;
170
211
  database;
171
212
  profiles;
213
+ threadRows;
214
+ messageRows;
215
+ positionRows;
216
+ normalizedStates = new WeakSet();
217
+ versions;
172
218
  closed = false;
173
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
219
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
174
220
  this.storageScope = storageScope;
221
+ this.migrateLegacy = migrateLegacy;
222
+ this.metadataOnly = metadataOnly;
175
223
  if (!factory)
176
224
  throw new Error("READ_STORAGE_UNAVAILABLE");
177
225
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
178
226
  // The empty schema retains the legacy out-of-line primary key and records.
179
227
  this.database.version(1).stores({ profiles: "" });
228
+ this.database.version(2).stores({ profiles: "", versions: "" });
229
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
230
+ this.positionRows = this.database.table("readPositions");
231
+ this.threadRows = this.database.table("readThreads");
232
+ this.messageRows = this.database.table("readMessages");
180
233
  this.profiles = this.database.table("profiles");
234
+ this.versions = this.database.table("versions");
181
235
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
182
236
  }
183
237
  async transaction(mode, execute) {
@@ -186,7 +240,7 @@ export class IndexedDbReadIndex {
186
240
  throw new Error("READ_STORAGE_CLOSED");
187
241
  let started = false;
188
242
  try {
189
- return await this.database.transaction(mode, this.profiles, async () => {
243
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
190
244
  if (this.closed)
191
245
  throw new Error("READ_STORAGE_CLOSED");
192
246
  started = true;
@@ -205,17 +259,190 @@ export class IndexedDbReadIndex {
205
259
  throw new Error("READ_STORAGE_UNAVAILABLE");
206
260
  }
207
261
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
262
+ async hydrate(current, key) {
263
+ if (current.normalized) {
264
+ const owner = JSON.stringify(key);
265
+ const [threads, messages, positions] = await Promise.all([
266
+ this.threadRows.where("owner").equals(owner).toArray(),
267
+ this.messageRows.where("owner").equals(owner).toArray(),
268
+ this.positionRows.where("owner").equals(owner).toArray(),
269
+ ]);
270
+ const byThread = new Map();
271
+ for (const row of messages) {
272
+ if (!byThread.has(row.thread))
273
+ byThread.set(row.thread, new Map());
274
+ byThread.get(row.thread).set(row.id, row.value);
275
+ }
276
+ const byPosition = new Map(positions.map(row => [row.id, row]));
277
+ const byId = new Map(threads.map(row => [row.id, row]));
278
+ const { normalized: _normalized, threadOrder, ...state } = current;
279
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
280
+ const row = byId.get(id);
281
+ const position = byPosition.get(id);
282
+ if (!row || !position)
283
+ throw new Error("READ_STORAGE_INCOMPLETE");
284
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
285
+ const message = byThread.get(id)?.get(messageId);
286
+ if (!message)
287
+ throw new Error("READ_STORAGE_INCOMPLETE");
288
+ return message;
289
+ }) };
290
+ }) };
291
+ this.normalizedStates.add(result);
292
+ return result;
293
+ }
294
+ return current;
295
+ }
296
+ async loadCurrent(appId, profileId) {
297
+ const current = await this.loadStored(appId, profileId);
298
+ if (!current || !this.metadataOnly)
299
+ return current;
300
+ const metadata = readingMetadata(current);
301
+ if (metadata !== current)
302
+ await this.persist(appId, profileId, current, metadata);
303
+ return metadata;
304
+ }
305
+ async loadStored(appId, profileId) {
306
+ const current = await this.profiles.get(this.key(appId, profileId));
307
+ if (current?.normalized)
308
+ return this.hydrate(current, this.key(appId, profileId));
309
+ if (current) {
310
+ const next = this.metadataOnly ? readingMetadata(current) : current;
311
+ if (this.migrateLegacy || this.metadataOnly)
312
+ await this.persist(appId, profileId, undefined, next, true);
313
+ return next;
314
+ }
315
+ if (!this.migrateLegacy || this.storageScope === undefined)
316
+ return undefined;
317
+ const markerKey = [appId, profileId, "legacy-owner-v1"];
318
+ const marker = await this.profiles.get(markerKey);
319
+ if (marker && marker.legacyOwner !== this.storageScope)
320
+ return undefined;
321
+ const legacyRow = await this.profiles.get([appId, profileId]);
322
+ if (!legacyRow)
323
+ return undefined;
324
+ const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
325
+ const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
326
+ if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
327
+ throw new Error("READ_IDENTITY_MISMATCH");
328
+ const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
329
+ await this.profiles.put(ownership, markerKey);
330
+ await this.persist(appId, profileId, undefined, legacy);
331
+ await this.profiles.delete([appId, profileId]);
332
+ const oldOwner = JSON.stringify([appId, profileId]);
333
+ await this.threadRows.where("owner").equals(oldOwner).delete();
334
+ await this.messageRows.where("owner").equals(oldOwner).delete();
335
+ await this.positionRows.where("owner").equals(oldOwner).delete();
336
+ return legacy;
337
+ }
338
+ /** Message bodies are immutable for local read observations: keep them out of
339
+ * profile/cursor writes, and update only changed normalized rows on sync. */
340
+ async persist(appId, profileId, previous, next, replaceLegacy = false) {
341
+ if (previous && !this.normalizedStates.has(previous)) {
342
+ previous = undefined;
343
+ replaceLegacy = true;
344
+ }
345
+ const owner = JSON.stringify(this.key(appId, profileId));
346
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
347
+ const nextIds = new Set(next.threads.map(thread => thread.id));
348
+ if (nextIds.size !== next.threads.length)
349
+ throw new Error("DUPLICATE_READ_THREAD");
350
+ const threadWrites = [], messageWrites = [], positionWrites = [];
351
+ const messageDeletes = [];
352
+ for (const thread of next.threads) {
353
+ const old = oldThreads.get(thread.id);
354
+ if (old === thread)
355
+ continue;
356
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
357
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
358
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
359
+ positionWrites.push(position);
360
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
361
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
362
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
363
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
364
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
365
+ threadWrites.push(metadata);
366
+ }
367
+ if (old?.messages === messages)
368
+ continue;
369
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
370
+ const ids = new Set();
371
+ for (const message of messages) {
372
+ if (ids.has(message.id))
373
+ throw new Error("INVALID_READ_MESSAGE");
374
+ ids.add(message.id);
375
+ const prior = byId.get(message.id);
376
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
377
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
378
+ }
379
+ for (const id of byId.keys())
380
+ if (!ids.has(id))
381
+ messageDeletes.push([owner, thread.id, id]);
382
+ }
383
+ for (const id of oldThreads.keys())
384
+ if (!nextIds.has(id)) {
385
+ await this.threadRows.delete([owner, id]);
386
+ await this.positionRows.delete([owner, id]);
387
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
388
+ }
389
+ // A legacy document can replace the full profile after normalization. Its
390
+ // replacement is authoritative for the row set; discard unreachable bodies
391
+ // in the same transaction before installing the rebuilt normalized records.
392
+ if (replaceLegacy) {
393
+ await this.messageRows.where("owner").equals(owner).delete();
394
+ await this.threadRows.where("owner").equals(owner).delete();
395
+ await this.positionRows.where("owner").equals(owner).delete();
396
+ }
397
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
398
+ // atomically; bounding total snapshot transaction bytes is separate work.
399
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
400
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
401
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
402
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
403
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
404
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
405
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
406
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
407
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
408
+ const nextHeader = header(next);
409
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
410
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
411
+ this.normalizedStates.add(next);
412
+ }
208
413
  async load(appId, profileId) {
209
- return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
414
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
415
+ }
416
+ async loadVersioned(appId, profileId) {
417
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
418
+ state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
419
+ }));
420
+ }
421
+ async loadVersions(appId, profileId) {
422
+ return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
423
+ }
424
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
425
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
426
+ throw new Error("INVALID_READ_SCOPE_VERSION");
427
+ return this.transaction("rw", async () => {
428
+ const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
429
+ const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
430
+ if (readTeamVersion(teams, teamId) >= version)
431
+ return state;
432
+ const next = removeReadTeam(state, teamId, knownThreadIds);
433
+ await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
434
+ await this.persist(appId, profileId, state, next);
435
+ return next;
436
+ });
210
437
  }
211
438
  async update(appId, profileId, transform) {
212
439
  return this.transaction("rw", async () => {
213
- const previous = await this.profiles.get(this.key(appId, profileId));
214
- const next = transform(previous ?? createReadIndex(appId, profileId));
440
+ const previous = await this.loadCurrent(appId, profileId);
441
+ const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
442
+ const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
215
443
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
216
444
  throw new Error("READ_IDENTITY_MISMATCH");
217
- if (JSON.stringify(next) !== JSON.stringify(previous))
218
- await this.profiles.put(next, this.key(appId, profileId));
445
+ await this.persist(appId, profileId, previous, next);
219
446
  return next;
220
447
  });
221
448
  }
@@ -231,14 +458,14 @@ function readSource(state, conversationId, messageId) {
231
458
  return sources[0];
232
459
  }
233
460
  /** Aggregate logical history while each original source retains its own read cursor. */
234
- export function unreadInConversation(state, conversationId) {
461
+ export function unreadInConversation(state, conversationId, hidden) {
235
462
  const sources = sourcesInConversation(state, conversationId);
236
463
  if (!sources.length)
237
464
  throw new Error("UNKNOWN_READ_CONVERSATION");
238
465
  if (new Set(sources.map(source => source.teamId)).size > 1)
239
466
  throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
240
467
  return sources.reduce((total, source) => {
241
- const unread = unreadInThread(state, source.id);
468
+ const unread = unreadInThread(state, source.id, hidden);
242
469
  return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
243
470
  }, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
244
471
  }
@@ -4,6 +4,7 @@ export type ReadSyncOptions = {
4
4
  profileId: string;
5
5
  sessionToken: string;
6
6
  hubHttpUrl: string;
7
+ metadataOnly?: boolean;
7
8
  persistence: ReadIndexPersistence;
8
9
  fetch?: typeof globalThis.fetch;
9
10
  onChange?: (state: ReadIndexState) => void;
@@ -1,4 +1,4 @@
1
- import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
1
+ import { readingMetadata, assertReadIndexVersions, applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
2
2
  /** Keeps local operations until an authoritative projection covers them. */
3
3
  export class ReadSyncClient {
4
4
  options;
@@ -9,8 +9,11 @@ export class ReadSyncClient {
9
9
  this.options = options;
10
10
  }
11
11
  async loadLocal() {
12
- return await this.options.persistence.load(this.options.appId, this.options.profileId)
13
- ?? createReadIndex(this.options.appId, this.options.profileId);
12
+ const { appId, profileId, persistence, metadataOnly } = this.options;
13
+ const state = await persistence.load(appId, profileId) ?? createReadIndex(appId, profileId);
14
+ if (!metadataOnly || readingMetadata(state) === state)
15
+ return state;
16
+ return persistence.update(appId, profileId, readingMetadata);
14
17
  }
15
18
  stop() { this.controller.abort(); }
16
19
  synchronize() {
@@ -46,7 +49,9 @@ export class ReadSyncClient {
46
49
  }
47
50
  async run() {
48
51
  const { appId, profileId, persistence } = this.options;
49
- const current = await this.loadLocal();
52
+ const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
53
+ const current = loaded.state ?? createReadIndex(appId, profileId);
54
+ const versions = loaded.versions;
50
55
  // Ordinary reads are monotonic: only the furthest position per thread needs
51
56
  // sending. Keep every original operation in storage until the projection
52
57
  // confirms it, and never rewrite an operation ID's payload during retries.
@@ -60,6 +65,8 @@ export class ReadSyncClient {
60
65
  }
61
66
  const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
62
67
  for (const operation of outgoing) {
68
+ if (persistence.loadVersions)
69
+ assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
63
70
  const receipt = await this.request("/api/social/reading", {
64
71
  method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
65
72
  });
@@ -69,8 +76,10 @@ export class ReadSyncClient {
69
76
  this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
70
77
  }
71
78
  const query = new URLSearchParams({ appId, profileId });
79
+ if (this.options.metadataOnly)
80
+ query.set("metadataOnly", "true");
72
81
  if (!this.readAfter && current.syncCursor !== "0") {
73
- const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
82
+ const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) })}`);
74
83
  if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
75
84
  || typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
76
85
  throw new Error("INVALID_READ_DELTA");
@@ -78,8 +87,9 @@ export class ReadSyncClient {
78
87
  try {
79
88
  if (this.controller.signal.aborted)
80
89
  throw new Error("READ_SYNC_STOPPED");
81
- const next = await persistence.update(appId, profileId, state => {
82
- const applied = delta.changes.reduce(applyReadChange, state);
90
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
91
+ assertReadIndexVersions(versions, currentVersions);
92
+ const applied = delta.changes.reduce((current, change) => applyReadChange(current, this.options.metadataOnly ? { ...change, thread: readingMetadata({ appId, threads: [change.thread] }).threads[0] } : change), this.options.metadataOnly ? readingMetadata(state) : state);
83
93
  if (applied.syncCursor !== delta.syncCursor)
84
94
  throw new ReadSyncGap(applied.syncCursor);
85
95
  return applied;
@@ -100,7 +110,10 @@ export class ReadSyncClient {
100
110
  if (this.controller.signal.aborted)
101
111
  throw new Error("READ_SYNC_STOPPED");
102
112
  // Merge into the latest transaction, including reads made while HTTP was pending.
103
- const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
113
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
114
+ assertReadIndexVersions(versions, currentVersions);
115
+ return installReadSnapshot(state, this.options.metadataOnly ? readingMetadata(snapshot) : snapshot);
116
+ });
104
117
  if (!next.pending.length)
105
118
  this.readAfter = 0;
106
119
  if (!this.controller.signal.aborted)
@@ -1,11 +1,15 @@
1
+ import { type ConversationReadMessage } from "./conversationRead.js";
1
2
  import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
2
3
  export type ReadingClientOptions = {
3
4
  appId: string;
4
5
  profileId: string;
5
6
  sessionToken: string;
6
7
  hubHttpUrl: string;
8
+ teamId?: string;
9
+ metadataOnly?: boolean;
7
10
  persistence?: ReadIndexPersistence;
8
11
  storageScope?: string;
12
+ migrateLegacy?: boolean;
9
13
  fetch?: typeof fetch;
10
14
  onError?: (error: unknown) => void;
11
15
  onRecovered?: () => void;
@@ -15,6 +19,12 @@ export type ReadingClientOptions = {
15
19
  export declare class ReadingClient {
16
20
  private options;
17
21
  private storage;
22
+ private backing;
23
+ private closing?;
24
+ private revoking?;
25
+ private versions?;
26
+ private prepared;
27
+ private accepted;
18
28
  private persistence;
19
29
  private sync;
20
30
  private tasks;
@@ -34,8 +44,17 @@ export declare class ReadingClient {
34
44
  refresh: () => void;
35
45
  synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
36
46
  update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
47
+ private updateScoped;
48
+ prepareTeam(teamId: string, version: number): Promise<void>;
49
+ private assertThreadTeam;
50
+ markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
37
51
  recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
38
52
  recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
39
53
  private record;
54
+ withdraw(): void;
55
+ private stop;
56
+ /** This client is retired permanently. Drain accepted local observations
57
+ * before deleting one team's profile-wide projection; sibling teams survive. */
58
+ revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
40
59
  close(): Promise<void>;
41
60
  }
@@ -1,12 +1,21 @@
1
+ import { isValidChatTeamId } from "./thread.js";
2
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
3
+ import { recordConversationRead } from "./conversationRead.js";
1
4
  import { BatchedReadIndex } from "./batchedReadIndex.js";
2
5
  import { WindowedTasks } from "./coalescer.js";
3
- import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
6
+ import { IndexedDbReadIndex, readingMetadata, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
4
7
  import { ReadSyncClient } from "./readSync.js";
5
8
  /** Owns durable local reads, network coalescing, retries and state notifications.
6
9
  * Hosts report actual visibility; loading or subscribing never marks messages read. */
7
10
  export class ReadingClient {
8
11
  options;
9
12
  storage;
13
+ backing;
14
+ closing;
15
+ revoking;
16
+ versions;
17
+ prepared = new Map();
18
+ accepted = new Set();
10
19
  persistence;
11
20
  sync;
12
21
  tasks;
@@ -19,7 +28,10 @@ export class ReadingClient {
19
28
  generation = 0;
20
29
  constructor(options) {
21
30
  this.options = options;
22
- const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope));
31
+ if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
32
+ throw new Error("INVALID_READ_TEAM");
33
+ const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy, options.metadataOnly));
34
+ this.backing = storage;
23
35
  this.persistence = new BatchedReadIndex(storage);
24
36
  this.tasks = new WindowedTasks(1000, error => this.failed(error));
25
37
  this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
@@ -33,7 +45,7 @@ export class ReadingClient {
33
45
  this.listeners.forEach(listener => listener());
34
46
  }
35
47
  failed(error) {
36
- if (this.closed)
48
+ if (this.closed || isOfflineNetworkFailure(error))
37
49
  return;
38
50
  this.options.onError?.(error);
39
51
  if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
@@ -56,8 +68,9 @@ export class ReadingClient {
56
68
  this.refresh();
57
69
  }
58
70
  refresh = () => {
59
- if (!this.closed)
60
- 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(); });
61
74
  };
62
75
  async synchronize(targetCursor, profileId) {
63
76
  if (this.closed)
@@ -84,32 +97,122 @@ export class ReadingClient {
84
97
  }
85
98
  throw new Error("Reading projection has not reached the announced version");
86
99
  }
87
- async update(transform) {
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) {
88
106
  if (this.closed)
89
- throw new Error("READING_CLIENT_CLOSED");
90
- const next = await this.persistence.update(this.options.appId, this.options.profileId, transform);
91
- if (!this.closed) {
92
- this.publish(next);
93
- if (next.pending.length || next.observedReadIntents?.length)
94
- this.refresh();
95
- }
96
- return next;
107
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
108
+ // One shared metadata read per local authority generation, rather than one
109
+ // IndexedDB lookup for every viewport observation.
110
+ const versions = this.versions ??= this.persistence.loadVersions(this.options.appId, this.options.profileId);
111
+ const task = (async () => {
112
+ const expected = await versions;
113
+ const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
114
+ assertReadIndexVersions(expected, currentVersions, teamId);
115
+ const next = transform(state);
116
+ return this.options.metadataOnly ? readingMetadata(next) : next;
117
+ });
118
+ if (!this.closed) {
119
+ this.publish(next);
120
+ if (next.pending.length || next.observedReadIntents?.length)
121
+ this.refresh();
122
+ }
123
+ return next;
124
+ })().catch(error => {
125
+ if (String(error).includes("READ_SCOPE_CHANGED")) {
126
+ if (teamId !== undefined)
127
+ this.withdraw();
128
+ else
129
+ this.versions = undefined;
130
+ }
131
+ throw error;
132
+ });
133
+ this.accepted.add(task);
134
+ void task.finally(() => this.accepted.delete(task)).catch(() => { });
135
+ return task;
136
+ }
137
+ prepareTeam(teamId, version) {
138
+ if (this.closed)
139
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
140
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 0)
141
+ return Promise.reject(new Error("INVALID_READ_SCOPE_VERSION"));
142
+ const previous = this.prepared.get(teamId);
143
+ if (previous && previous.version >= version)
144
+ return previous.task;
145
+ const task = (async () => {
146
+ if (version > 0)
147
+ await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
148
+ this.versions = undefined;
149
+ })().catch(error => { if (this.prepared.get(teamId)?.task === task)
150
+ this.prepared.delete(teamId); throw error; });
151
+ this.prepared.set(teamId, { version, task });
152
+ return task;
153
+ }
154
+ assertThreadTeam(state, threadId) {
155
+ if (this.options.teamId !== undefined && state.threads.some(thread => (thread.id === threadId || thread.conversationId === threadId) && thread.teamId !== undefined && thread.teamId !== this.options.teamId))
156
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
157
+ }
158
+ markRead(threadId, messages = []) {
159
+ return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
97
160
  }
98
161
  recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
99
162
  recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
100
163
  record(threadId, ids, readPolicy) {
101
164
  const messageIds = [...new Set(ids)];
102
- return this.update(state => messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state));
165
+ 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);
103
166
  }
104
- async close() {
167
+ withdraw() { this.stop(); }
168
+ stop() {
169
+ if (this.closed)
170
+ return;
105
171
  this.closed = true;
106
172
  this.generation++;
107
173
  this.offWake?.();
108
174
  this.tasks.close();
109
175
  clearTimeout(this.retry);
110
176
  this.sync.stop();
177
+ this.state = undefined;
178
+ this.listeners.forEach(listener => listener());
111
179
  this.listeners.clear();
112
- await this.persistence.close();
113
- await this.storage?.close();
180
+ }
181
+ /** This client is retired permanently. Drain accepted local observations
182
+ * before deleting one team's profile-wide projection; sibling teams survive. */
183
+ revokeTeam(teamId, version, knownThreadIds = []) {
184
+ if (this.revoking)
185
+ return this.revoking;
186
+ if (this.closing)
187
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
188
+ this.stop();
189
+ return this.revoking = (async () => {
190
+ try {
191
+ await Promise.allSettled([...this.accepted]);
192
+ await this.persistence.close();
193
+ if (!this.backing.invalidateTeam)
194
+ throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
195
+ await this.backing.invalidateTeam(this.options.appId, this.options.profileId, teamId, version, knownThreadIds);
196
+ }
197
+ finally {
198
+ await this.storage?.close();
199
+ }
200
+ })();
201
+ }
202
+ close() {
203
+ if (this.revoking)
204
+ return this.revoking;
205
+ if (this.closing)
206
+ return this.closing;
207
+ this.stop();
208
+ return this.closing = (async () => {
209
+ try {
210
+ await Promise.allSettled([...this.accepted]);
211
+ await this.persistence.close();
212
+ }
213
+ finally {
214
+ await this.storage?.close();
215
+ }
216
+ })();
114
217
  }
115
218
  }
@@ -0,0 +1,5 @@
1
+ /** Social control envelopes do not contribute to message unread counts. */
2
+ export declare function isReadingControl(payload: unknown, appId: string): boolean;
3
+ /** Mirrors the server's voice policy for ordinary and quoted wire envelopes. */
4
+ export declare function messageRequiresPlayback(body: unknown): boolean;
5
+ export declare function messageMentionsProfile(body: unknown, profileId: string): boolean;