@pmate/chat-sdk 0.12.0-next.47 → 0.12.0-next.49

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.
@@ -424,19 +424,32 @@ export class DurableChatSender {
424
424
  if (!hashes.length)
425
425
  return;
426
426
  await this.ready;
427
- await this.withEpoch("rw", async () => {
428
- const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
429
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
430
- if (confirmed.length)
431
- await this.outbox.bulkPut(confirmed);
432
- });
433
427
  let changed = false;
434
- for (const hash of hashes) {
435
- changed = this.values.delete(hash) || changed;
436
- this.releaseUrl(hash);
428
+ try {
429
+ for (let offset = 0; offset < hashes.length; offset += 128) {
430
+ const batch = hashes.slice(offset, offset + 128);
431
+ await this.withEpoch("rw", async () => {
432
+ const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
433
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
434
+ if (confirmed.length)
435
+ await this.outbox.bulkPut(confirmed);
436
+ });
437
+ // Retire only committed rows. A later failed transaction leaves the
438
+ // remaining sends retryable, including after a browser restart.
439
+ for (const hash of batch) {
440
+ changed = this.values.delete(hash) || changed;
441
+ this.releaseUrl(hash);
442
+ }
443
+ if (offset + batch.length < hashes.length)
444
+ await new Promise(resolve => setTimeout(resolve, 0));
445
+ }
446
+ }
447
+ finally {
448
+ // One confirmation operation publishes at most once, also on partial
449
+ // failure so consumers see exactly the progress that was committed.
450
+ if (changed)
451
+ this.emit();
437
452
  }
438
- if (changed)
439
- this.emit();
440
453
  }
441
454
  async resume() {
442
455
  await this.ready;
@@ -71,6 +71,7 @@ export declare function installReadSnapshot(current: ReadIndexState, snapshot: R
71
71
  export declare function applyReadChange(current: ReadIndexState, change: ReadChange): ReadIndexState;
72
72
  /** Persist observations even if the message arrived before its Sequence metadata. */
73
73
  export declare function recordReadIntent(current: ReadIndexState, intent: ObservedReadIntent): ReadIndexState;
74
+ export declare function resolveObservedReads(current: ReadIndexState): ReadIndexState;
74
75
  /** Called only for a message the visible viewport actually reached. */
75
76
  export declare function recordVisibleRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
76
77
  export declare function recordExplicitRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
@@ -105,12 +105,12 @@ export function recordReadIntent(current, intent) {
105
105
  throw new Error("INVALID_READ_INTENT");
106
106
  const observed = current.observedReadIntents ?? [];
107
107
  if (observed.some(item => item.threadId === intent.threadId && item.messageId === intent.messageId && item.readPolicy === intent.readPolicy))
108
- return current;
108
+ return resolveObservedReads(current);
109
109
  if (observed.some(item => item.operationId === intent.operationId) || current.pending.some(item => item.operationId === intent.operationId))
110
110
  throw new Error("INVALID_READ_OPERATION_ID");
111
111
  return resolveObservedReads({ ...current, observedReadIntents: [...observed, structuredClone(intent)] });
112
112
  }
113
- function resolveObservedReads(current) {
113
+ export function resolveObservedReads(current) {
114
114
  let next = current;
115
115
  const remaining = [];
116
116
  for (const intent of current.observedReadIntents ?? []) {
@@ -127,6 +127,9 @@ function resolveObservedReads(current) {
127
127
  next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
128
128
  : recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
129
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;
130
133
  return { ...next, observedReadIntents: remaining };
131
134
  }
132
135
  /** Called only for a message the visible viewport actually reached. */
@@ -231,6 +234,9 @@ export class IndexedDbReadIndex {
231
234
  // Native indexes track scalar source identity even when an older writer
232
235
  // changes rows. Reading these keys does not deserialize messageIds arrays.
233
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]" });
234
240
  this.positionRows = this.database.table("readPositions");
235
241
  this.threadRows = this.database.table("readThreads");
236
242
  this.messageRows = this.database.table("readMessages");
@@ -451,16 +457,23 @@ export class IndexedDbReadIndex {
451
457
  return undefined;
452
458
  const sources = new Map();
453
459
  for (const threadId of new Set(scope.targets.map(target => target.threadId))) {
454
- const direct = await this.threadRows.where("[owner+id+value.kind+value.teamId]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
455
- const assigned = await this.threadRows.where("[owner+value.conversationId+value.kind+value.teamId+id]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
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();
456
462
  if (direct.length + assigned.length > 128)
457
463
  return undefined;
458
- const identities = [...direct.map(value => ({ id: value[1], kind: value[2], teamId: value[3] })), ...assigned.map(value => ({ id: value[4], kind: value[2], teamId: value[3] }))];
459
- if (!identities.length && base.threads.some(thread => thread.id === threadId || thread.conversationId === threadId))
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)))
460
470
  return undefined;
461
471
  for (const identity of identities) {
462
472
  const cached = base.threads.find(thread => thread.id === identity.id);
463
- if (!cached || cached.kind !== identity.kind || cached.teamId !== identity.teamId)
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])
464
477
  return undefined;
465
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();
466
479
  if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
@@ -1,4 +1,4 @@
1
- import { readingMetadata, assertReadIndexVersions, 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;
@@ -11,9 +11,13 @@ export class ReadSyncClient {
11
11
  async loadLocal() {
12
12
  const { appId, profileId, persistence, metadataOnly } = this.options;
13
13
  const state = await persistence.load(appId, profileId) ?? createReadIndex(appId, profileId);
14
- if (!metadataOnly || readingMetadata(state) === state)
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)
15
19
  return state;
16
- return persistence.update(appId, profileId, readingMetadata);
20
+ return persistence.update(appId, profileId, normalize);
17
21
  }
18
22
  stop() { this.controller.abort(); }
19
23
  synchronize() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.47",
3
+ "version": "0.12.0-next.49",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",