@pmate/chat-sdk 0.12.0-next.6 → 0.12.0-next.61

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 (64) hide show
  1. package/README.md +98 -3
  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 +109 -12
  9. package/lib/runtime/chatSession.js +624 -40
  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 +30 -17
  18. package/lib/runtime/durableSender.d.ts +97 -14
  19. package/lib/runtime/durableSender.js +786 -71
  20. package/lib/runtime/groupClient.d.ts +76 -0
  21. package/lib/runtime/groupClient.js +325 -0
  22. package/lib/runtime/index.d.ts +6 -0
  23. package/lib/runtime/index.js +5 -0
  24. package/lib/runtime/messageClient.d.ts +30 -8
  25. package/lib/runtime/messageClient.js +332 -66
  26. package/lib/runtime/messageControls.d.ts +19 -0
  27. package/lib/runtime/messageControls.js +44 -0
  28. package/lib/runtime/messageLaunch.d.ts +42 -0
  29. package/lib/runtime/messageLaunch.js +133 -0
  30. package/lib/runtime/messageQuotes.d.ts +3 -0
  31. package/lib/runtime/messageQuotes.js +17 -0
  32. package/lib/runtime/messageReaders.d.ts +12 -1
  33. package/lib/runtime/messageReaders.js +67 -3
  34. package/lib/runtime/messageStore.d.ts +38 -2
  35. package/lib/runtime/messageStore.js +215 -11
  36. package/lib/runtime/messageTimeline.d.ts +50 -0
  37. package/lib/runtime/messageTimeline.js +120 -0
  38. package/lib/runtime/outboxSchema.d.ts +5 -0
  39. package/lib/runtime/outboxSchema.js +59 -0
  40. package/lib/runtime/outgoingContent.d.ts +85 -0
  41. package/lib/runtime/outgoingContent.js +106 -0
  42. package/lib/runtime/readBatchJournal.d.ts +35 -0
  43. package/lib/runtime/readBatchJournal.js +210 -0
  44. package/lib/runtime/readCounts.d.ts +15 -1
  45. package/lib/runtime/readCounts.js +96 -8
  46. package/lib/runtime/readIndex.d.ts +68 -7
  47. package/lib/runtime/readIndex.js +460 -22
  48. package/lib/runtime/readSchema.d.ts +2 -0
  49. package/lib/runtime/readSchema.js +58 -0
  50. package/lib/runtime/readSync.d.ts +1 -0
  51. package/lib/runtime/readSync.js +25 -8
  52. package/lib/runtime/readingClient.d.ts +19 -0
  53. package/lib/runtime/readingClient.js +131 -19
  54. package/lib/runtime/readingContent.d.ts +5 -0
  55. package/lib/runtime/readingContent.js +23 -0
  56. package/lib/runtime/recordCache.d.ts +9 -0
  57. package/lib/runtime/recordCache.js +62 -5
  58. package/lib/runtime/responseCache.d.ts +57 -0
  59. package/lib/runtime/responseCache.js +230 -0
  60. package/lib/runtime/socialReadClient.d.ts +3 -1
  61. package/lib/runtime/socialReadClient.js +8 -4
  62. package/lib/runtime/voiceUpload.d.ts +2 -0
  63. package/lib/runtime/voiceUpload.js +27 -0
  64. package/package.json +6 -1
@@ -1,5 +1,26 @@
1
+ import { prepareReadSchema } from "./readSchema.js";
2
+ import { ReadBatchJournal, lockReadOwner } from "./readBatchJournal.js";
1
3
  import { Dexie } from "dexie";
4
+ import { isReadingControl } from "./readingContent.js";
2
5
  import { isValidChatTeamId } from "./thread.js";
6
+ /** Retain read semantics without a second durable copy of message bodies. */
7
+ export function readingMetadata(state) {
8
+ let changed = false;
9
+ const threads = state.threads.map(thread => {
10
+ let different = false;
11
+ const messages = thread.messages.map(message => {
12
+ if (message.payload === null && typeof message.readingControl === "boolean")
13
+ return message;
14
+ different = true;
15
+ return { ...message, payload: null, readingControl: Boolean(message.readingControl) || isReadingControl(message.payload, state.appId) };
16
+ });
17
+ if (!different)
18
+ return thread;
19
+ changed = true;
20
+ return { ...thread, messages };
21
+ });
22
+ return changed ? { ...state, threads } : state;
23
+ }
3
24
  export class ReadSyncGap extends Error {
4
25
  cursor;
5
26
  constructor(cursor) {
@@ -86,12 +107,12 @@ export function recordReadIntent(current, intent) {
86
107
  throw new Error("INVALID_READ_INTENT");
87
108
  const observed = current.observedReadIntents ?? [];
88
109
  if (observed.some(item => item.threadId === intent.threadId && item.messageId === intent.messageId && item.readPolicy === intent.readPolicy))
89
- return current;
110
+ return resolveObservedReads(current);
90
111
  if (observed.some(item => item.operationId === intent.operationId) || current.pending.some(item => item.operationId === intent.operationId))
91
112
  throw new Error("INVALID_READ_OPERATION_ID");
92
113
  return resolveObservedReads({ ...current, observedReadIntents: [...observed, structuredClone(intent)] });
93
114
  }
94
- function resolveObservedReads(current) {
115
+ export function resolveObservedReads(current) {
95
116
  let next = current;
96
117
  const remaining = [];
97
118
  for (const intent of current.observedReadIntents ?? []) {
@@ -108,6 +129,9 @@ function resolveObservedReads(current) {
108
129
  next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
109
130
  : recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
110
131
  }
132
+ const previous = current.observedReadIntents;
133
+ if (next === current && previous && previous.length === remaining.length && previous.every((intent, i) => intent === remaining[i]))
134
+ return current;
111
135
  return { ...next, observedReadIntents: remaining };
112
136
  }
113
137
  /** Called only for a message the visible viewport actually reached. */
@@ -153,32 +177,108 @@ export function coalesceReadOperations(current) {
153
177
  return [...batches.values()];
154
178
  }
155
179
  /** Counts are over the indexed message coverage, not an estimate of missing history. */
156
- export function unreadInThread(state, threadId) {
180
+ export function unreadInThread(state, threadId, hidden) {
157
181
  const thread = state.threads.find(t => t.id === threadId);
158
182
  if (!thread)
159
183
  throw new Error("UNKNOWN_READ_THREAD");
160
184
  const explicit = new Set(thread.explicitReadMessageIds);
161
- const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
185
+ 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
186
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
163
187
  const unreadMessageCount = messages.length;
164
188
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
165
189
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
190
  }
167
- /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
191
+ function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
192
+ export function assertReadIndexVersions(expected, current = {}, teamId) {
193
+ const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
194
+ if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
195
+ throw new Error("READ_SCOPE_CHANGED");
196
+ }
197
+ /** Remove only the withdrawn team's original sources, logical conversations and
198
+ * queued observations. Other teams' pending operations must survive. */
199
+ export function removeReadTeam(state, teamId, knownThreadIds = []) {
200
+ const removed = new Set(knownThreadIds);
201
+ for (const thread of state.threads)
202
+ if (thread.teamId === teamId) {
203
+ removed.add(thread.id);
204
+ if (thread.conversationId)
205
+ removed.add(thread.conversationId);
206
+ }
207
+ return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
208
+ pending: state.pending.filter(operation => !removed.has(operation.threadId)),
209
+ observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
210
+ }
211
+ /** Preserves the database and logical keys; store renaming fences old format writers. */
168
212
  export class IndexedDbReadIndex {
169
213
  storageScope;
214
+ migrateLegacy;
215
+ metadataOnly;
170
216
  database;
171
217
  profiles;
218
+ threadRows;
219
+ messageRows;
220
+ positionRows;
221
+ normalizedStates = new WeakSet();
222
+ orderedStates = new WeakSet();
223
+ legacyClean = new Set();
224
+ observationMessages = new WeakMap();
225
+ versions;
172
226
  closed = false;
173
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
227
+ journal;
228
+ factory;
229
+ schemaReady;
230
+ ownerTail = Promise.resolve();
231
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
174
232
  this.storageScope = storageScope;
233
+ this.migrateLegacy = migrateLegacy;
234
+ this.metadataOnly = metadataOnly;
175
235
  if (!factory)
176
236
  throw new Error("READ_STORAGE_UNAVAILABLE");
237
+ this.factory = factory;
177
238
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
178
239
  // The empty schema retains the legacy out-of-line primary key and records.
179
240
  this.database.version(1).stores({ profiles: "" });
180
- this.profiles = this.database.table("profiles");
241
+ this.database.version(2).stores({ profiles: "", versions: "" });
242
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
243
+ // Native indexes track scalar source identity even when an older writer
244
+ // changes rows. Reading these keys does not deserialize messageIds arrays.
245
+ 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]" });
246
+ // Keep v4 indexes for already-open older SDKs, and make source discovery
247
+ // dense for legitimate legacy rows whose optional team field is absent.
248
+ 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]" });
249
+ this.database.version(6).stores({ profiles: "unsupportedReadingFormat", versions: "unsupportedReadingFormat", readingProfiles: "", readingVersions: "", readThreads: null, readMessages: null, readPositions: null, readingThreads: "[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]", readingMessages: "[owner+thread+id],[owner+thread],owner", readingPositions: "[owner+id],owner", readBatches: "[owner+key],owner" });
250
+ this.journal = new ReadBatchJournal(this.database, () => this.closed);
251
+ this.positionRows = this.database.table("readingPositions");
252
+ this.threadRows = this.database.table("readingThreads");
253
+ this.messageRows = this.database.table("readingMessages");
254
+ this.profiles = this.database.table("readingProfiles");
255
+ this.versions = this.database.table("readingVersions");
181
256
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
257
+ this.schemaReady = prepareReadSchema(factory, name);
258
+ void this.schemaReady.catch(() => { });
259
+ }
260
+ async owned(appId, profileId, run) {
261
+ const previous = this.ownerTail;
262
+ let release;
263
+ this.ownerTail = new Promise(resolve => { release = resolve; });
264
+ await previous;
265
+ try {
266
+ if (this.closed)
267
+ throw new Error("READ_STORAGE_CLOSED");
268
+ await this.schemaReady;
269
+ if (this.closed)
270
+ throw new Error("READ_STORAGE_CLOSED");
271
+ const owner = JSON.stringify(this.key(appId, profileId));
272
+ return await lockReadOwner(this.factory, this.database.name, owner, async () => { await this.journal.recover(owner); return run(); }, work => this.journal.lease(owner, work));
273
+ }
274
+ catch (error) {
275
+ if (this.closed)
276
+ throw new Error("READ_STORAGE_CLOSED");
277
+ throw error;
278
+ }
279
+ finally {
280
+ release();
281
+ }
182
282
  }
183
283
  async transaction(mode, execute) {
184
284
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -186,11 +286,15 @@ export class IndexedDbReadIndex {
186
286
  throw new Error("READ_STORAGE_CLOSED");
187
287
  let started = false;
188
288
  try {
189
- return await this.database.transaction(mode, this.profiles, async () => {
289
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows, this.database.table("readBatches")], async () => {
190
290
  if (this.closed)
191
291
  throw new Error("READ_STORAGE_CLOSED");
192
292
  started = true;
193
- return execute();
293
+ await this.journal.checkLeases();
294
+ const result = await execute();
295
+ if (mode === "rw")
296
+ await this.journal.checkLeases(true);
297
+ return result;
194
298
  });
195
299
  }
196
300
  catch (error) {
@@ -205,21 +309,355 @@ export class IndexedDbReadIndex {
205
309
  throw new Error("READ_STORAGE_UNAVAILABLE");
206
310
  }
207
311
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
208
- async load(appId, profileId) {
209
- return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
312
+ /** Keyset pages avoid materializing a second, unbounded array of durable rows.
313
+ * Keep the caller's transaction so every page belongs to one consistent view. */
314
+ async readPages(table, owner, key, visit) {
315
+ let after;
316
+ for (;;) {
317
+ const rows = await table.where(":id").between(after ?? [owner, Dexie.minKey], [owner, Dexie.maxKey], !after, true).limit(128).toArray();
318
+ for (const row of rows)
319
+ visit(row);
320
+ if (rows.length < 128)
321
+ return;
322
+ after = key(rows[rows.length - 1]);
323
+ }
324
+ }
325
+ async hydrate(current, key) {
326
+ if (current.normalized) {
327
+ const owner = JSON.stringify(key);
328
+ const byThread = new Map();
329
+ const byPosition = new Map(), byId = new Map();
330
+ await Promise.all([
331
+ this.readPages(this.threadRows, owner, row => [row.owner, row.id], row => byId.set(row.id, row)),
332
+ this.readPages(this.messageRows, owner, row => [row.owner, row.thread, row.id], row => {
333
+ if (!byThread.has(row.thread))
334
+ byThread.set(row.thread, new Map());
335
+ byThread.get(row.thread).set(row.id, row);
336
+ }),
337
+ this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => byPosition.set(row.id, row)),
338
+ ]);
339
+ const { normalized: _normalized, threadOrder, ...state } = current;
340
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
341
+ const row = byId.get(id);
342
+ const position = byPosition.get(id);
343
+ if (!row || !position)
344
+ throw new Error("READ_STORAGE_INCOMPLETE");
345
+ const stored = byThread.get(id) ?? new Map();
346
+ let messages;
347
+ if (row.messageCount !== undefined && !row.messageIds.length) {
348
+ const ordered = [...stored.values()], ranked = ordered.every(row => Number.isSafeInteger(row.ordinal));
349
+ ordered.sort((a, b) => ranked ? a.ordinal - b.ordinal : a.value.seq - b.value.seq);
350
+ if (ordered.length !== row.messageCount)
351
+ throw new Error("READ_STORAGE_INCOMPLETE");
352
+ messages = ordered.map(row => row.value);
353
+ }
354
+ else
355
+ messages = row.messageIds.map(messageId => {
356
+ const message = stored.get(messageId);
357
+ if (!message)
358
+ throw new Error("READ_STORAGE_INCOMPLETE");
359
+ return message.value;
360
+ });
361
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages };
362
+ }) };
363
+ this.normalizedStates.add(result);
364
+ if ((threadOrder ?? []).every(id => byId.get(id)?.messageCount !== undefined && !byId.get(id).messageIds.length && [...(byThread.get(id)?.values() ?? [])].every(row => Number.isSafeInteger(row.ordinal))))
365
+ this.orderedStates.add(result);
366
+ return result;
367
+ }
368
+ return current;
210
369
  }
211
- async update(appId, profileId, transform) {
212
- return this.transaction("rw", async () => {
213
- const previous = await this.profiles.get(this.key(appId, profileId));
214
- const next = transform(previous ?? createReadIndex(appId, profileId));
215
- if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
370
+ async loadCurrent(appId, profileId) {
371
+ const current = await this.loadStored(appId, profileId);
372
+ if (!current || !this.metadataOnly)
373
+ return current;
374
+ const metadata = readingMetadata(current);
375
+ if (metadata !== current)
376
+ await this.writeState(appId, profileId, current, metadata);
377
+ return metadata;
378
+ }
379
+ async loadStored(appId, profileId) {
380
+ const current = await this.transaction("r", async () => {
381
+ if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
382
+ return undefined;
383
+ const row = await this.profiles.get(this.key(appId, profileId));
384
+ if (row && (row.appId !== appId || row.profileId !== profileId || row.schemaVersion !== 1))
216
385
  throw new Error("READ_IDENTITY_MISMATCH");
217
- if (JSON.stringify(next) !== JSON.stringify(previous))
218
- await this.profiles.put(next, this.key(appId, profileId));
219
- return next;
386
+ return row?.normalized ? this.hydrate(row, this.key(appId, profileId)) : row;
387
+ });
388
+ if (current) {
389
+ await this.cleanupClaimedLegacy(appId, profileId);
390
+ if (!this.normalizedStates.has(current) && (this.migrateLegacy || this.metadataOnly)) {
391
+ const next = this.metadataOnly ? readingMetadata(current) : current;
392
+ await this.writeState(appId, profileId, undefined, next);
393
+ return next;
394
+ }
395
+ return current;
396
+ }
397
+ if (!this.migrateLegacy || this.storageScope === undefined)
398
+ return undefined;
399
+ const legacyOwner = JSON.stringify([appId, profileId]);
400
+ const legacy = await lockReadOwner(this.factory, this.database.name, legacyOwner, async () => {
401
+ await this.journal.recover(legacyOwner);
402
+ const allowed = await this.transaction("rw", async () => {
403
+ const key = [appId, profileId, "legacy-owner-v1"], marker = await this.profiles.get(key);
404
+ if (marker && marker.legacyOwner !== this.storageScope)
405
+ return false;
406
+ const raw = await this.profiles.get([appId, profileId]);
407
+ if (!raw)
408
+ return false;
409
+ if (raw.appId !== appId || raw.profileId !== profileId || raw.schemaVersion !== 1)
410
+ throw new Error("READ_IDENTITY_MISMATCH");
411
+ if (!marker)
412
+ await this.profiles.put({ ...createReadIndex(appId, profileId), legacyOwner: this.storageScope }, key);
413
+ return true;
414
+ });
415
+ if (!allowed)
416
+ return undefined;
417
+ return this.transaction("r", async () => { const row = await this.profiles.get([appId, profileId]); return row?.normalized ? this.hydrate(row, [appId, profileId]) : row; });
418
+ }, work => this.journal.lease(legacyOwner, work));
419
+ if (!legacy)
420
+ return undefined;
421
+ if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
422
+ throw new Error("READ_IDENTITY_MISMATCH");
423
+ const next = this.metadataOnly ? readingMetadata(legacy) : legacy;
424
+ await this.writeState(appId, profileId, undefined, next);
425
+ await this.cleanupClaimedLegacy(appId, profileId);
426
+ return next;
427
+ }
428
+ async cleanupClaimedLegacy(appId, profileId) {
429
+ if (this.storageScope === undefined || !this.migrateLegacy)
430
+ return;
431
+ const key = JSON.stringify(this.key(appId, profileId));
432
+ if (this.legacyClean.has(key))
433
+ return;
434
+ const marker = await this.transaction("r", () => this.profiles.get([appId, profileId, "legacy-owner-v1"]));
435
+ if (marker?.legacyOwner === this.storageScope) {
436
+ await this.transaction("rw", () => this.profiles.delete([appId, profileId]));
437
+ await this.cleanUnreachable(JSON.stringify([appId, profileId]), undefined);
438
+ }
439
+ this.legacyClean.add(key);
440
+ }
441
+ async load(appId, profileId) {
442
+ return this.owned(appId, profileId, () => this.loadCurrent(appId, profileId));
443
+ }
444
+ async loadVersioned(appId, profileId) {
445
+ return this.owned(appId, profileId, async () => ({ state: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) }));
446
+ }
447
+ async loadVersions(appId, profileId) {
448
+ return this.owned(appId, profileId, () => this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}));
449
+ }
450
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
451
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
452
+ throw new Error("INVALID_READ_SCOPE_VERSION");
453
+ return this.owned(appId, profileId, async () => {
454
+ const teams = await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
455
+ if (readTeamVersion(teams, teamId) >= version)
456
+ return await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
457
+ return this.updateOwned(appId, profileId, state => removeReadTeam(state, teamId, knownThreadIds), undefined, { teams: { ...teams, [teamId]: version } });
458
+ });
459
+ }
460
+ async observe(appId, profileId, transform, scope) {
461
+ if (!this.metadataOnly || scope.targets.length > 128)
462
+ return undefined;
463
+ const key = this.key(appId, profileId), owner = JSON.stringify(key), base = scope.base;
464
+ const header = await this.profiles.get(key);
465
+ if (header && (header.appId !== appId || header.profileId !== profileId || header.schemaVersion !== 1))
466
+ throw new Error("READ_IDENTITY_MISMATCH");
467
+ if (!header?.normalized || base.schemaVersion !== 1 || base.appId !== appId || base.profileId !== profileId || base.syncCursor !== header.syncCursor ||
468
+ header.threadOrder?.length !== base.threads.length || header.threadOrder.some((id, i) => id !== base.threads[i].id))
469
+ return undefined;
470
+ const sources = new Map();
471
+ for (const threadId of new Set(scope.targets.map(target => target.threadId))) {
472
+ const direct = await this.threadRows.where("[owner+id+value.kind]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
473
+ 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();
474
+ if (direct.length + assigned.length > 128)
475
+ return undefined;
476
+ 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()];
477
+ const indexedIds = new Set(identities.map(identity => identity.id));
478
+ const expected = base.threads.filter(thread => thread.id === threadId || thread.conversationId === threadId);
479
+ // Sparse indexes omit legacy sources without team metadata. A partial
480
+ // family cannot safely resolve an observation for the logical thread.
481
+ if (indexedIds.size !== expected.length || expected.some(thread => !indexedIds.has(thread.id)))
482
+ return undefined;
483
+ for (const identity of identities) {
484
+ const cached = base.threads.find(thread => thread.id === identity.id);
485
+ if (!cached || cached.kind !== identity.kind)
486
+ return undefined;
487
+ 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();
488
+ if (team.length > 1 || cached.teamId !== team[0]?.[3])
489
+ return undefined;
490
+ 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();
491
+ if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
492
+ return undefined;
493
+ let source = sources.get(identity.id);
494
+ if (!source) {
495
+ source = { cached, ids: new Set() };
496
+ sources.set(identity.id, source);
497
+ }
498
+ for (const target of scope.targets)
499
+ if (target.threadId === threadId)
500
+ source.ids.add(target.messageId);
501
+ }
502
+ }
503
+ if ([...sources.values()].reduce((sum, source) => sum + source.ids.size, 0) > 128)
504
+ return undefined;
505
+ const sourceIds = [...sources.keys()];
506
+ // Cursors are the mutable cross-document view. Refresh their small rows,
507
+ // while message metadata remains targeted; otherwise a later batch based
508
+ // on an older snapshot could regress a different conversation's progress.
509
+ const positionById = new Map();
510
+ await this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => positionById.set(row.id, row));
511
+ if (base.threads.some(thread => !positionById.has(thread.id)))
512
+ return undefined;
513
+ const positions = sourceIds.map(id => positionById.get(id));
514
+ const partialThreads = [];
515
+ for (let i = 0; i < sourceIds.length; i++) {
516
+ const id = sourceIds[i], source = sources.get(id), position = positions[i];
517
+ if (!position)
518
+ return undefined;
519
+ let cached = this.observationMessages.get(source.cached.messages);
520
+ if (!cached) {
521
+ cached = new Map(source.cached.messages.map(message => [message.id, message]));
522
+ this.observationMessages.set(source.cached.messages, cached);
523
+ }
524
+ const ids = [...source.ids], rows = await this.messageRows.bulkGet(ids.map(messageId => [owner, id, messageId]));
525
+ const messages = [];
526
+ for (let j = 0; j < ids.length; j++) {
527
+ const previous = cached.get(ids[j]), message = rows[j]?.value;
528
+ if (message && (message.payload !== null || typeof message.readingControl !== "boolean"))
529
+ return undefined;
530
+ if (JSON.stringify(previous) !== JSON.stringify(message))
531
+ return undefined;
532
+ if (message)
533
+ messages.push(previous);
534
+ }
535
+ partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
536
+ }
537
+ const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
538
+ const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
539
+ if (result.appId !== appId || result.profileId !== profileId || result.schemaVersion !== 1)
540
+ throw new Error("READ_IDENTITY_MISMATCH");
541
+ 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))
542
+ return undefined;
543
+ const writes = [];
544
+ for (let i = 0; i < result.threads.length; i++) {
545
+ const thread = result.threads[i], before = positions[i];
546
+ if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
547
+ writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
548
+ }
549
+ if (writes.length > 126)
550
+ return undefined;
551
+ if (writes.length)
552
+ await this.positionRows.bulkPut(writes);
553
+ if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
554
+ await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
555
+ const byId = new Map(positionById);
556
+ result.threads.forEach(thread => byId.set(thread.id, thread));
557
+ let changed = false;
558
+ const threads = base.threads.map(thread => {
559
+ const position = byId.get(thread.id);
560
+ if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
561
+ return thread;
562
+ changed = true;
563
+ return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
220
564
  });
565
+ if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
566
+ return base;
567
+ return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
568
+ }
569
+ async update(appId, profileId, transform, observation) {
570
+ return this.owned(appId, profileId, () => this.updateOwned(appId, profileId, transform, observation));
571
+ }
572
+ async updateOwned(appId, profileId, transform, observation, versions) {
573
+ if (observation) {
574
+ const observed = await this.transaction("rw", () => this.observe(appId, profileId, transform, observation));
575
+ if (observed)
576
+ return observed;
577
+ }
578
+ if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
579
+ throw new Error("READ_LEGACY_OWNERSHIP_CHANGED");
580
+ const loaded = { previous: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) };
581
+ const previous = loaded.previous, transformed = transform(previous ?? createReadIndex(appId, profileId), loaded.versions);
582
+ const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
583
+ if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
584
+ throw new Error("READ_IDENTITY_MISMATCH");
585
+ if (next === previous && !versions)
586
+ return next;
587
+ await this.writeState(appId, profileId, previous, next, versions);
588
+ return next;
589
+ }
590
+ async writeState(appId, profileId, previous, next, versions) {
591
+ const owner = JSON.stringify(this.key(appId, profileId)), header = { ...next, threads: [], normalized: true, threadOrder: next.threads.map(thread => thread.id) };
592
+ if (!previous || !this.orderedStates.has(previous))
593
+ await this.cleanUnreachable(owner, previous);
594
+ await this.journal.commit(owner, this.changes(owner, previous, next), { profileKey: this.key(appId, profileId), header, versions });
595
+ this.normalizedStates.add(next);
596
+ this.orderedStates.add(next);
597
+ }
598
+ async cleanUnreachable(owner, previous) {
599
+ const threads = new Map(previous?.threads.map(thread => [thread.id, new Set(thread.messages.map(message => message.id))]) ?? []);
600
+ for (const name of ["readingThreads", "readingPositions", "readingMessages"]) {
601
+ const table = this.database.table(name);
602
+ let after;
603
+ for (;;) {
604
+ const keys = await table.where(":id").between(after ?? [owner, Dexie.minKey], [owner, Dexie.maxKey], !after, true).limit(126).primaryKeys();
605
+ if (!keys.length)
606
+ break;
607
+ const dead = keys.filter(key => !threads.has(key[1]) || name === "readingMessages" && !threads.get(key[1]).has(key[2]));
608
+ if (dead.length)
609
+ await this.transaction("rw", () => table.bulkDelete(dead));
610
+ after = keys[keys.length - 1];
611
+ if (keys.length < 126)
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ *changes(owner, previous, next) {
617
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []), ids = new Set(), modern = previous !== undefined && this.orderedStates.has(previous);
618
+ for (const thread of next.threads) {
619
+ if (ids.has(thread.id))
620
+ throw new Error("DUPLICATE_READ_THREAD");
621
+ ids.add(thread.id);
622
+ const old = oldThreads.get(thread.id);
623
+ if (old === thread && modern)
624
+ continue;
625
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
626
+ if (!modern || !old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
627
+ yield { table: "readingPositions", key: [owner, thread.id], value: { owner, id: thread.id, readThroughSeq, explicitReadMessageIds } };
628
+ const metadata = { owner, id: thread.id, value, messageIds: [], messageCount: messages.length };
629
+ const oldMetadata = old ? { owner, id: old.id, value: (({ messages: _m, readThroughSeq: _r, explicitReadMessageIds: _e, ...rest }) => rest)(old), messageIds: modern ? [] : old.messages.map(message => message.id), ...(modern ? { messageCount: old.messages.length } : {}) } : undefined;
630
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
631
+ yield { table: "readingThreads", key: [owner, thread.id], value: metadata };
632
+ if (old?.messages === messages && modern)
633
+ continue;
634
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []), ranks = new Map(old?.messages.map((message, index) => [message.id, index]) ?? []), seen = new Set();
635
+ for (const [ordinal, message] of messages.entries()) {
636
+ if (seen.has(message.id))
637
+ throw new Error("INVALID_READ_MESSAGE");
638
+ seen.add(message.id);
639
+ const prior = byId.get(message.id);
640
+ if (!modern || ranks.get(message.id) !== ordinal || prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
641
+ yield { table: "readingMessages", key: [owner, thread.id, message.id], value: { owner, thread: thread.id, id: message.id, value: message, ordinal } };
642
+ }
643
+ for (const id of byId.keys())
644
+ if (!seen.has(id))
645
+ yield { table: "readingMessages", key: [owner, thread.id, id] };
646
+ }
647
+ for (const old of oldThreads.values())
648
+ if (!ids.has(old.id)) {
649
+ yield { table: "readingThreads", key: [owner, old.id] };
650
+ yield { table: "readingPositions", key: [owner, old.id] };
651
+ for (const message of old.messages)
652
+ yield { table: "readingMessages", key: [owner, old.id, message.id] };
653
+ }
654
+ }
655
+ async close() { this.closed = true; try {
656
+ await this.journal.releaseLeases();
221
657
  }
222
- async close() { this.closed = true; this.database.close(); }
658
+ finally {
659
+ this.database.close();
660
+ } }
223
661
  }
224
662
  export function sourcesInConversation(state, conversationId) {
225
663
  return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
@@ -231,14 +669,14 @@ function readSource(state, conversationId, messageId) {
231
669
  return sources[0];
232
670
  }
233
671
  /** Aggregate logical history while each original source retains its own read cursor. */
234
- export function unreadInConversation(state, conversationId) {
672
+ export function unreadInConversation(state, conversationId, hidden) {
235
673
  const sources = sourcesInConversation(state, conversationId);
236
674
  if (!sources.length)
237
675
  throw new Error("UNKNOWN_READ_CONVERSATION");
238
676
  if (new Set(sources.map(source => source.teamId)).size > 1)
239
677
  throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
240
678
  return sources.reduce((total, source) => {
241
- const unread = unreadInThread(state, source.id);
679
+ const unread = unreadInThread(state, source.id, hidden);
242
680
  return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
243
681
  }, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
244
682
  }
@@ -0,0 +1,2 @@
1
+ /** Metadata-only store renaming fences SDKs that cannot read chunked snapshots. */
2
+ export declare function prepareReadSchema(factory: IDBFactory, name: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ /** Metadata-only store renaming fences SDKs that cannot read chunked snapshots. */
2
+ export async function prepareReadSchema(factory, name) {
3
+ const existing = await new Promise((resolve, reject) => {
4
+ const request = factory.open(name);
5
+ let fresh = false;
6
+ request.onupgradeneeded = event => { if (event.oldVersion === 0) {
7
+ fresh = true;
8
+ request.transaction.abort();
9
+ } };
10
+ request.onerror = event => { event.preventDefault(); if (fresh)
11
+ resolve(undefined);
12
+ else
13
+ reject(request.error); };
14
+ request.onsuccess = () => resolve(request.result);
15
+ });
16
+ if (!existing)
17
+ return;
18
+ const prepared = existing.objectStoreNames.contains("readingProfiles") && existing.objectStoreNames.contains("readingMessages");
19
+ const version = existing.version;
20
+ existing.close();
21
+ if (version >= 60 && prepared)
22
+ return;
23
+ await new Promise((resolve, reject) => {
24
+ const request = factory.open(name, Math.max(60, version + 1));
25
+ request.onerror = () => reject(request.error);
26
+ request.onupgradeneeded = () => {
27
+ const db = request.result, tx = request.transaction;
28
+ for (const [before, after] of [["profiles", "readingProfiles"], ["versions", "readingVersions"], ["readThreads", "readingThreads"], ["readMessages", "readingMessages"], ["readPositions", "readingPositions"]]) {
29
+ if (db.objectStoreNames.contains(before)) {
30
+ if (db.objectStoreNames.contains(after)) {
31
+ tx.abort();
32
+ return;
33
+ }
34
+ tx.objectStore(before).name = after;
35
+ }
36
+ }
37
+ if (!db.objectStoreNames.contains("readingProfiles"))
38
+ db.createObjectStore("readingProfiles");
39
+ if (!db.objectStoreNames.contains("readingVersions"))
40
+ db.createObjectStore("readingVersions");
41
+ // Explicit-key writes from older SDKs must fail rather than recreate a cache
42
+ // beside a partially applied transition or silently return an empty history.
43
+ db.createObjectStore("profiles", { keyPath: "unsupportedReadingFormat" });
44
+ db.createObjectStore("versions", { keyPath: "unsupportedReadingFormat" });
45
+ const define = (name, keyPath, indexes) => {
46
+ const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
47
+ for (const [index, path] of Object.entries(indexes))
48
+ if (!store.indexNames.contains(index))
49
+ store.createIndex(index, path);
50
+ };
51
+ define("readingThreads", ["owner", "id"], { owner: "owner", "[owner+id+value.kind+value.teamId]": ["owner", "id", "value.kind", "value.teamId"], "[owner+value.conversationId+value.kind+value.teamId+id]": ["owner", "value.conversationId", "value.kind", "value.teamId", "id"], "[owner+id+value.conversationId]": ["owner", "id", "value.conversationId"], "[owner+id+value.kind]": ["owner", "id", "value.kind"], "[owner+value.conversationId+value.kind+id]": ["owner", "value.conversationId", "value.kind", "id"] });
52
+ define("readingMessages", ["owner", "thread", "id"], { owner: "owner", "[owner+thread]": ["owner", "thread"] });
53
+ define("readingPositions", ["owner", "id"], { owner: "owner" });
54
+ define("readBatches", ["owner", "key"], { owner: "owner" });
55
+ };
56
+ request.onsuccess = () => { request.result.close(); resolve(); };
57
+ });
58
+ }
@@ -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;