@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.51

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 (52) hide show
  1. package/README.md +34 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  3. package/lib/runtime/batchedReadIndex.js +50 -4
  4. package/lib/runtime/cachedMessage.d.ts +12 -0
  5. package/lib/runtime/cachedMessage.js +18 -0
  6. package/lib/runtime/chatClient.d.ts +1 -0
  7. package/lib/runtime/chatClient.js +1 -1
  8. package/lib/runtime/chatSession.d.ts +78 -13
  9. package/lib/runtime/chatSession.js +494 -43
  10. package/lib/runtime/connectivity.d.ts +4 -0
  11. package/lib/runtime/connectivity.js +8 -0
  12. package/lib/runtime/conversationRead.d.ts +13 -0
  13. package/lib/runtime/conversationRead.js +34 -0
  14. package/lib/runtime/conversationReading.d.ts +28 -0
  15. package/lib/runtime/conversationReading.js +47 -0
  16. package/lib/runtime/createChat.d.ts +5 -5
  17. package/lib/runtime/createChat.js +16 -17
  18. package/lib/runtime/durableSender.d.ts +63 -14
  19. package/lib/runtime/durableSender.js +419 -45
  20. package/lib/runtime/index.d.ts +4 -0
  21. package/lib/runtime/index.js +4 -0
  22. package/lib/runtime/messageClient.d.ts +32 -8
  23. package/lib/runtime/messageClient.js +415 -75
  24. package/lib/runtime/messageControls.d.ts +19 -0
  25. package/lib/runtime/messageControls.js +44 -0
  26. package/lib/runtime/messageLaunch.d.ts +42 -0
  27. package/lib/runtime/messageLaunch.js +133 -0
  28. package/lib/runtime/messageQuotes.d.ts +3 -0
  29. package/lib/runtime/messageQuotes.js +17 -0
  30. package/lib/runtime/messageReaders.d.ts +12 -1
  31. package/lib/runtime/messageReaders.js +67 -3
  32. package/lib/runtime/messageStore.d.ts +40 -2
  33. package/lib/runtime/messageStore.js +218 -10
  34. package/lib/runtime/messageTimeline.d.ts +50 -0
  35. package/lib/runtime/messageTimeline.js +120 -0
  36. package/lib/runtime/readCounts.d.ts +15 -1
  37. package/lib/runtime/readCounts.js +96 -8
  38. package/lib/runtime/readIndex.d.ts +55 -6
  39. package/lib/runtime/readIndex.js +363 -14
  40. package/lib/runtime/readSync.d.ts +1 -0
  41. package/lib/runtime/readSync.js +25 -8
  42. package/lib/runtime/readingClient.d.ts +19 -0
  43. package/lib/runtime/readingClient.js +131 -19
  44. package/lib/runtime/readingContent.d.ts +5 -0
  45. package/lib/runtime/readingContent.js +23 -0
  46. package/lib/runtime/recordCache.d.ts +9 -0
  47. package/lib/runtime/recordCache.js +62 -5
  48. package/lib/runtime/responseCache.d.ts +44 -0
  49. package/lib/runtime/responseCache.js +173 -0
  50. package/lib/runtime/voiceUpload.d.ts +2 -0
  51. package/lib/runtime/voiceUpload.js +20 -0
  52. package/package.json +6 -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) {
@@ -86,12 +105,12 @@ export function recordReadIntent(current, intent) {
86
105
  throw new Error("INVALID_READ_INTENT");
87
106
  const observed = current.observedReadIntents ?? [];
88
107
  if (observed.some(item => item.threadId === intent.threadId && item.messageId === intent.messageId && item.readPolicy === intent.readPolicy))
89
- return current;
108
+ return resolveObservedReads(current);
90
109
  if (observed.some(item => item.operationId === intent.operationId) || current.pending.some(item => item.operationId === intent.operationId))
91
110
  throw new Error("INVALID_READ_OPERATION_ID");
92
111
  return resolveObservedReads({ ...current, observedReadIntents: [...observed, structuredClone(intent)] });
93
112
  }
94
- function resolveObservedReads(current) {
113
+ export function resolveObservedReads(current) {
95
114
  let next = current;
96
115
  const remaining = [];
97
116
  for (const intent of current.observedReadIntents ?? []) {
@@ -108,6 +127,9 @@ function resolveObservedReads(current) {
108
127
  next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
109
128
  : recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
110
129
  }
130
+ const previous = current.observedReadIntents;
131
+ if (next === current && previous && previous.length === remaining.length && previous.every((intent, i) => intent === remaining[i]))
132
+ return current;
111
133
  return { ...next, observedReadIntents: remaining };
112
134
  }
113
135
  /** Called only for a message the visible viewport actually reached. */
@@ -153,31 +175,73 @@ export function coalesceReadOperations(current) {
153
175
  return [...batches.values()];
154
176
  }
155
177
  /** Counts are over the indexed message coverage, not an estimate of missing history. */
156
- export function unreadInThread(state, threadId) {
178
+ export function unreadInThread(state, threadId, hidden) {
157
179
  const thread = state.threads.find(t => t.id === threadId);
158
180
  if (!thread)
159
181
  throw new Error("UNKNOWN_READ_THREAD");
160
182
  const explicit = new Set(thread.explicitReadMessageIds);
161
- const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
183
+ 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
184
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
163
185
  const unreadMessageCount = messages.length;
164
186
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
165
187
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
188
  }
189
+ function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
190
+ export function assertReadIndexVersions(expected, current = {}, teamId) {
191
+ const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
192
+ if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
193
+ throw new Error("READ_SCOPE_CHANGED");
194
+ }
195
+ /** Remove only the withdrawn team's original sources, logical conversations and
196
+ * queued observations. Other teams' pending operations must survive. */
197
+ export function removeReadTeam(state, teamId, knownThreadIds = []) {
198
+ const removed = new Set(knownThreadIds);
199
+ for (const thread of state.threads)
200
+ if (thread.teamId === teamId) {
201
+ removed.add(thread.id);
202
+ if (thread.conversationId)
203
+ removed.add(thread.conversationId);
204
+ }
205
+ return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
206
+ pending: state.pending.filter(operation => !removed.has(operation.threadId)),
207
+ observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
208
+ }
167
209
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
210
  export class IndexedDbReadIndex {
169
211
  storageScope;
212
+ migrateLegacy;
213
+ metadataOnly;
170
214
  database;
171
215
  profiles;
216
+ threadRows;
217
+ messageRows;
218
+ positionRows;
219
+ normalizedStates = new WeakSet();
220
+ observationMessages = new WeakMap();
221
+ versions;
172
222
  closed = false;
173
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
223
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
174
224
  this.storageScope = storageScope;
225
+ this.migrateLegacy = migrateLegacy;
226
+ this.metadataOnly = metadataOnly;
175
227
  if (!factory)
176
228
  throw new Error("READ_STORAGE_UNAVAILABLE");
177
229
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
178
230
  // The empty schema retains the legacy out-of-line primary key and records.
179
231
  this.database.version(1).stores({ profiles: "" });
232
+ this.database.version(2).stores({ profiles: "", versions: "" });
233
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
234
+ // Native indexes track scalar source identity even when an older writer
235
+ // changes rows. Reading these keys does not deserialize messageIds arrays.
236
+ this.database.version(4).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId]" });
237
+ // Keep v4 indexes for already-open older SDKs, and make source discovery
238
+ // dense for legitimate legacy rows whose optional team field is absent.
239
+ this.database.version(5).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]" });
240
+ this.positionRows = this.database.table("readPositions");
241
+ this.threadRows = this.database.table("readThreads");
242
+ this.messageRows = this.database.table("readMessages");
180
243
  this.profiles = this.database.table("profiles");
244
+ this.versions = this.database.table("versions");
181
245
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
182
246
  }
183
247
  async transaction(mode, execute) {
@@ -186,7 +250,7 @@ export class IndexedDbReadIndex {
186
250
  throw new Error("READ_STORAGE_CLOSED");
187
251
  let started = false;
188
252
  try {
189
- return await this.database.transaction(mode, this.profiles, async () => {
253
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
190
254
  if (this.closed)
191
255
  throw new Error("READ_STORAGE_CLOSED");
192
256
  started = true;
@@ -205,17 +269,302 @@ export class IndexedDbReadIndex {
205
269
  throw new Error("READ_STORAGE_UNAVAILABLE");
206
270
  }
207
271
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
272
+ async hydrate(current, key) {
273
+ if (current.normalized) {
274
+ const owner = JSON.stringify(key);
275
+ const [threads, messages, positions] = await Promise.all([
276
+ this.threadRows.where("owner").equals(owner).toArray(),
277
+ this.messageRows.where("owner").equals(owner).toArray(),
278
+ this.positionRows.where("owner").equals(owner).toArray(),
279
+ ]);
280
+ const byThread = new Map();
281
+ for (const row of messages) {
282
+ if (!byThread.has(row.thread))
283
+ byThread.set(row.thread, new Map());
284
+ byThread.get(row.thread).set(row.id, row.value);
285
+ }
286
+ const byPosition = new Map(positions.map(row => [row.id, row]));
287
+ const byId = new Map(threads.map(row => [row.id, row]));
288
+ const { normalized: _normalized, threadOrder, ...state } = current;
289
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
290
+ const row = byId.get(id);
291
+ const position = byPosition.get(id);
292
+ if (!row || !position)
293
+ throw new Error("READ_STORAGE_INCOMPLETE");
294
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
295
+ const message = byThread.get(id)?.get(messageId);
296
+ if (!message)
297
+ throw new Error("READ_STORAGE_INCOMPLETE");
298
+ return message;
299
+ }) };
300
+ }) };
301
+ this.normalizedStates.add(result);
302
+ return result;
303
+ }
304
+ return current;
305
+ }
306
+ async loadCurrent(appId, profileId) {
307
+ const current = await this.loadStored(appId, profileId);
308
+ if (!current || !this.metadataOnly)
309
+ return current;
310
+ const metadata = readingMetadata(current);
311
+ if (metadata !== current)
312
+ await this.persist(appId, profileId, current, metadata);
313
+ return metadata;
314
+ }
315
+ async loadStored(appId, profileId) {
316
+ const current = await this.profiles.get(this.key(appId, profileId));
317
+ if (current?.normalized)
318
+ return this.hydrate(current, this.key(appId, profileId));
319
+ if (current) {
320
+ const next = this.metadataOnly ? readingMetadata(current) : current;
321
+ if (this.migrateLegacy || this.metadataOnly)
322
+ await this.persist(appId, profileId, undefined, next, true);
323
+ return next;
324
+ }
325
+ if (!this.migrateLegacy || this.storageScope === undefined)
326
+ return undefined;
327
+ const markerKey = [appId, profileId, "legacy-owner-v1"];
328
+ const marker = await this.profiles.get(markerKey);
329
+ if (marker && marker.legacyOwner !== this.storageScope)
330
+ return undefined;
331
+ const legacyRow = await this.profiles.get([appId, profileId]);
332
+ if (!legacyRow)
333
+ return undefined;
334
+ const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
335
+ const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
336
+ if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
337
+ throw new Error("READ_IDENTITY_MISMATCH");
338
+ const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
339
+ await this.profiles.put(ownership, markerKey);
340
+ await this.persist(appId, profileId, undefined, legacy);
341
+ await this.profiles.delete([appId, profileId]);
342
+ const oldOwner = JSON.stringify([appId, profileId]);
343
+ await this.threadRows.where("owner").equals(oldOwner).delete();
344
+ await this.messageRows.where("owner").equals(oldOwner).delete();
345
+ await this.positionRows.where("owner").equals(oldOwner).delete();
346
+ return legacy;
347
+ }
348
+ /** Message bodies are immutable for local read observations: keep them out of
349
+ * profile/cursor writes, and update only changed normalized rows on sync. */
350
+ async persist(appId, profileId, previous, next, replaceLegacy = false) {
351
+ if (previous && !this.normalizedStates.has(previous)) {
352
+ previous = undefined;
353
+ replaceLegacy = true;
354
+ }
355
+ const owner = JSON.stringify(this.key(appId, profileId));
356
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
357
+ const nextIds = new Set(next.threads.map(thread => thread.id));
358
+ if (nextIds.size !== next.threads.length)
359
+ throw new Error("DUPLICATE_READ_THREAD");
360
+ const threadWrites = [], messageWrites = [], positionWrites = [];
361
+ const messageDeletes = [];
362
+ for (const thread of next.threads) {
363
+ const old = oldThreads.get(thread.id);
364
+ if (old === thread)
365
+ continue;
366
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
367
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
368
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
369
+ positionWrites.push(position);
370
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
371
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
372
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
373
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
374
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
375
+ threadWrites.push(metadata);
376
+ }
377
+ if (old?.messages === messages)
378
+ continue;
379
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
380
+ const ids = new Set();
381
+ for (const message of messages) {
382
+ if (ids.has(message.id))
383
+ throw new Error("INVALID_READ_MESSAGE");
384
+ ids.add(message.id);
385
+ const prior = byId.get(message.id);
386
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
387
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
388
+ }
389
+ for (const id of byId.keys())
390
+ if (!ids.has(id))
391
+ messageDeletes.push([owner, thread.id, id]);
392
+ }
393
+ for (const id of oldThreads.keys())
394
+ if (!nextIds.has(id)) {
395
+ await this.threadRows.delete([owner, id]);
396
+ await this.positionRows.delete([owner, id]);
397
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
398
+ }
399
+ // A legacy document can replace the full profile after normalization. Its
400
+ // replacement is authoritative for the row set; discard unreachable bodies
401
+ // in the same transaction before installing the rebuilt normalized records.
402
+ if (replaceLegacy) {
403
+ await this.messageRows.where("owner").equals(owner).delete();
404
+ await this.threadRows.where("owner").equals(owner).delete();
405
+ await this.positionRows.where("owner").equals(owner).delete();
406
+ }
407
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
408
+ // atomically; bounding total snapshot transaction bytes is separate work.
409
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
410
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
411
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
412
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
413
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
414
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
415
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
416
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
417
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
418
+ const nextHeader = header(next);
419
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
420
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
421
+ this.normalizedStates.add(next);
422
+ }
208
423
  async load(appId, profileId) {
209
- return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
424
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
425
+ }
426
+ async loadVersioned(appId, profileId) {
427
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
428
+ state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
429
+ }));
210
430
  }
211
- async update(appId, profileId, transform) {
431
+ async loadVersions(appId, profileId) {
432
+ return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
433
+ }
434
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
435
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
436
+ throw new Error("INVALID_READ_SCOPE_VERSION");
212
437
  return this.transaction("rw", async () => {
213
- const previous = await this.profiles.get(this.key(appId, profileId));
214
- const next = transform(previous ?? createReadIndex(appId, profileId));
438
+ const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
439
+ const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
440
+ if (readTeamVersion(teams, teamId) >= version)
441
+ return state;
442
+ const next = removeReadTeam(state, teamId, knownThreadIds);
443
+ await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
444
+ await this.persist(appId, profileId, state, next);
445
+ return next;
446
+ });
447
+ }
448
+ async observe(appId, profileId, transform, scope) {
449
+ if (!this.metadataOnly || scope.targets.length > 128)
450
+ return undefined;
451
+ const key = this.key(appId, profileId), owner = JSON.stringify(key), base = scope.base;
452
+ const header = await this.profiles.get(key);
453
+ if (header && (header.appId !== appId || header.profileId !== profileId || header.schemaVersion !== 1))
454
+ throw new Error("READ_IDENTITY_MISMATCH");
455
+ if (!header?.normalized || base.schemaVersion !== 1 || base.appId !== appId || base.profileId !== profileId || base.syncCursor !== header.syncCursor ||
456
+ header.threadOrder?.length !== base.threads.length || header.threadOrder.some((id, i) => id !== base.threads[i].id))
457
+ return undefined;
458
+ const sources = new Map();
459
+ for (const threadId of new Set(scope.targets.map(target => target.threadId))) {
460
+ const direct = await this.threadRows.where("[owner+id+value.kind]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
461
+ const assigned = await this.threadRows.where("[owner+value.conversationId+value.kind+id]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
462
+ if (direct.length + assigned.length > 128)
463
+ return undefined;
464
+ const identities = [...new Map([...direct.map(value => ({ id: value[1], kind: value[2] })), ...assigned.map(value => ({ id: value[3], kind: value[2] }))].map(value => [value.id, value])).values()];
465
+ const indexedIds = new Set(identities.map(identity => identity.id));
466
+ const expected = base.threads.filter(thread => thread.id === threadId || thread.conversationId === threadId);
467
+ // Sparse indexes omit legacy sources without team metadata. A partial
468
+ // family cannot safely resolve an observation for the logical thread.
469
+ if (indexedIds.size !== expected.length || expected.some(thread => !indexedIds.has(thread.id)))
470
+ return undefined;
471
+ for (const identity of identities) {
472
+ const cached = base.threads.find(thread => thread.id === identity.id);
473
+ if (!cached || cached.kind !== identity.kind)
474
+ return undefined;
475
+ const team = await this.threadRows.where("[owner+id+value.kind+value.teamId]").between([owner, identity.id, Dexie.minKey], [owner, identity.id, Dexie.maxKey], true, true).limit(2).keys();
476
+ if (team.length > 1 || cached.teamId !== team[0]?.[3])
477
+ return undefined;
478
+ const mapping = await this.threadRows.where("[owner+id+value.conversationId]").between([owner, identity.id, Dexie.minKey], [owner, identity.id, Dexie.maxKey], true, true).limit(2).keys();
479
+ if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
480
+ return undefined;
481
+ let source = sources.get(identity.id);
482
+ if (!source) {
483
+ source = { cached, ids: new Set() };
484
+ sources.set(identity.id, source);
485
+ }
486
+ for (const target of scope.targets)
487
+ if (target.threadId === threadId)
488
+ source.ids.add(target.messageId);
489
+ }
490
+ }
491
+ if ([...sources.values()].reduce((sum, source) => sum + source.ids.size, 0) > 128)
492
+ return undefined;
493
+ const sourceIds = [...sources.keys()];
494
+ // Cursors are the mutable cross-document view. Refresh their small rows,
495
+ // while message metadata remains targeted; otherwise a later batch based
496
+ // on an older snapshot could regress a different conversation's progress.
497
+ const allPositions = await this.positionRows.where("owner").equals(owner).toArray();
498
+ const positionById = new Map(allPositions.map(position => [position.id, position]));
499
+ if (base.threads.some(thread => !positionById.has(thread.id)))
500
+ return undefined;
501
+ const positions = sourceIds.map(id => positionById.get(id));
502
+ const partialThreads = [];
503
+ for (let i = 0; i < sourceIds.length; i++) {
504
+ const id = sourceIds[i], source = sources.get(id), position = positions[i];
505
+ if (!position)
506
+ return undefined;
507
+ let cached = this.observationMessages.get(source.cached.messages);
508
+ if (!cached) {
509
+ cached = new Map(source.cached.messages.map(message => [message.id, message]));
510
+ this.observationMessages.set(source.cached.messages, cached);
511
+ }
512
+ const ids = [...source.ids], rows = await this.messageRows.bulkGet(ids.map(messageId => [owner, id, messageId]));
513
+ const messages = [];
514
+ for (let j = 0; j < ids.length; j++) {
515
+ const previous = cached.get(ids[j]), message = rows[j]?.value;
516
+ if (message && (message.payload !== null || typeof message.readingControl !== "boolean"))
517
+ return undefined;
518
+ if (JSON.stringify(previous) !== JSON.stringify(message))
519
+ return undefined;
520
+ if (message)
521
+ messages.push(previous);
522
+ }
523
+ partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
524
+ }
525
+ const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
526
+ const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
527
+ if (result.appId !== appId || result.profileId !== profileId || result.schemaVersion !== 1)
528
+ throw new Error("READ_IDENTITY_MISMATCH");
529
+ if (result.syncCursor !== partial.syncCursor || result.threads.length !== partialThreads.length || result.threads.some((thread, i) => thread.id !== partialThreads[i].id || thread.kind !== partialThreads[i].kind || thread.teamId !== partialThreads[i].teamId || thread.conversationId !== partialThreads[i].conversationId || thread.messages !== partialThreads[i].messages))
530
+ return undefined;
531
+ const writes = [];
532
+ for (let i = 0; i < result.threads.length; i++) {
533
+ const thread = result.threads[i], before = positions[i];
534
+ if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
535
+ writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
536
+ }
537
+ if (writes.length)
538
+ await this.positionRows.bulkPut(writes);
539
+ if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
540
+ await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
541
+ const byId = new Map(allPositions.map(position => [position.id, position]));
542
+ result.threads.forEach(thread => byId.set(thread.id, thread));
543
+ let changed = false;
544
+ const threads = base.threads.map(thread => {
545
+ const position = byId.get(thread.id);
546
+ if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
547
+ return thread;
548
+ changed = true;
549
+ return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
550
+ });
551
+ if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
552
+ return base;
553
+ return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
554
+ }
555
+ async update(appId, profileId, transform, observation) {
556
+ return this.transaction("rw", async () => {
557
+ if (observation) {
558
+ const next = await this.observe(appId, profileId, transform, observation);
559
+ if (next)
560
+ return next;
561
+ }
562
+ const previous = await this.loadCurrent(appId, profileId);
563
+ const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
564
+ const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
215
565
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
216
566
  throw new Error("READ_IDENTITY_MISMATCH");
217
- if (JSON.stringify(next) !== JSON.stringify(previous))
218
- await this.profiles.put(next, this.key(appId, profileId));
567
+ await this.persist(appId, profileId, previous, next);
219
568
  return next;
220
569
  });
221
570
  }
@@ -231,14 +580,14 @@ function readSource(state, conversationId, messageId) {
231
580
  return sources[0];
232
581
  }
233
582
  /** Aggregate logical history while each original source retains its own read cursor. */
234
- export function unreadInConversation(state, conversationId) {
583
+ export function unreadInConversation(state, conversationId, hidden) {
235
584
  const sources = sourcesInConversation(state, conversationId);
236
585
  if (!sources.length)
237
586
  throw new Error("UNKNOWN_READ_CONVERSATION");
238
587
  if (new Set(sources.map(source => source.teamId)).size > 1)
239
588
  throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
240
589
  return sources.reduce((total, source) => {
241
- const unread = unreadInThread(state, source.id);
590
+ const unread = unreadInThread(state, source.id, hidden);
242
591
  return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
243
592
  }, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
244
593
  }
@@ -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, resolveObservedReads, 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,15 @@ 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
+ const normalize = (current) => {
15
+ const next = metadataOnly ? readingMetadata(current) : current;
16
+ return next.observedReadIntents?.length ? resolveObservedReads(next) : next;
17
+ };
18
+ if (normalize(state) === state)
19
+ return state;
20
+ return persistence.update(appId, profileId, normalize);
14
21
  }
15
22
  stop() { this.controller.abort(); }
16
23
  synchronize() {
@@ -46,7 +53,9 @@ export class ReadSyncClient {
46
53
  }
47
54
  async run() {
48
55
  const { appId, profileId, persistence } = this.options;
49
- const current = await this.loadLocal();
56
+ const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
57
+ const current = loaded.state ?? createReadIndex(appId, profileId);
58
+ const versions = loaded.versions;
50
59
  // Ordinary reads are monotonic: only the furthest position per thread needs
51
60
  // sending. Keep every original operation in storage until the projection
52
61
  // confirms it, and never rewrite an operation ID's payload during retries.
@@ -60,6 +69,8 @@ export class ReadSyncClient {
60
69
  }
61
70
  const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
62
71
  for (const operation of outgoing) {
72
+ if (persistence.loadVersions)
73
+ assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
63
74
  const receipt = await this.request("/api/social/reading", {
64
75
  method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
65
76
  });
@@ -69,8 +80,10 @@ export class ReadSyncClient {
69
80
  this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
70
81
  }
71
82
  const query = new URLSearchParams({ appId, profileId });
83
+ if (this.options.metadataOnly)
84
+ query.set("metadataOnly", "true");
72
85
  if (!this.readAfter && current.syncCursor !== "0") {
73
- const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
86
+ const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) })}`);
74
87
  if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
75
88
  || typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
76
89
  throw new Error("INVALID_READ_DELTA");
@@ -78,8 +91,9 @@ export class ReadSyncClient {
78
91
  try {
79
92
  if (this.controller.signal.aborted)
80
93
  throw new Error("READ_SYNC_STOPPED");
81
- const next = await persistence.update(appId, profileId, state => {
82
- const applied = delta.changes.reduce(applyReadChange, state);
94
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
95
+ assertReadIndexVersions(versions, currentVersions);
96
+ 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
97
  if (applied.syncCursor !== delta.syncCursor)
84
98
  throw new ReadSyncGap(applied.syncCursor);
85
99
  return applied;
@@ -100,7 +114,10 @@ export class ReadSyncClient {
100
114
  if (this.controller.signal.aborted)
101
115
  throw new Error("READ_SYNC_STOPPED");
102
116
  // Merge into the latest transaction, including reads made while HTTP was pending.
103
- const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
117
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
118
+ assertReadIndexVersions(versions, currentVersions);
119
+ return installReadSnapshot(state, this.options.metadataOnly ? readingMetadata(snapshot) : snapshot);
120
+ });
104
121
  if (!next.pending.length)
105
122
  this.readAfter = 0;
106
123
  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
  }