@pmate/chat-sdk 0.12.0-next.45 → 0.12.0-next.46

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.
@@ -1,5 +1,7 @@
1
- import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform } from "./readIndex.js";
1
+ import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform, type ReadObservationScope } from "./readIndex.js";
2
2
  type Change = {
3
+ weight: number;
4
+ observation?: ReadObservationScope;
3
5
  transform: ReadIndexTransform;
4
6
  resolve: (state: ReadIndexState) => void;
5
7
  reject: (error: unknown) => void;
@@ -13,6 +15,7 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
13
15
  private queueLimit;
14
16
  private queues;
15
17
  private closed;
18
+ private sourceCounts;
16
19
  constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
17
20
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
18
21
  loadVersioned(appId: string, profileId: string): Promise<{
@@ -24,7 +27,8 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
24
27
  }>;
25
28
  loadVersions(appId: string, profileId: string): Promise<{}>;
26
29
  invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
27
- update(appId: string, profileId: string, transform: Change["transform"]): Promise<ReadIndexState>;
30
+ private weight;
31
+ update(appId: string, profileId: string, transform: Change["transform"], observation?: ReadObservationScope): Promise<ReadIndexState>;
28
32
  private drain;
29
33
  flush(appId: string, profileId: string): Promise<void>;
30
34
  close(): Promise<void>;
@@ -8,6 +8,7 @@ export class BatchedReadIndex {
8
8
  queueLimit;
9
9
  queues = new Map();
10
10
  closed = false;
11
+ sourceCounts = new WeakMap();
11
12
  constructor(storage, windowMs = 16, batchSize = 128, queueLimit = 4096) {
12
13
  this.storage = storage;
13
14
  this.windowMs = windowMs;
@@ -42,7 +43,22 @@ export class BatchedReadIndex {
42
43
  // barrier; any queued transform sees it inside its eventual transaction.
43
44
  return this.storage.invalidateTeam(appId, profileId, teamId, version, knownThreadIds);
44
45
  }
45
- update(appId, profileId, transform) {
46
+ weight(observation) {
47
+ if (!observation)
48
+ return 1;
49
+ let counts = this.sourceCounts.get(observation.base);
50
+ if (!counts) {
51
+ counts = new Map();
52
+ for (const thread of observation.base.threads) {
53
+ counts.set(thread.id, (counts.get(thread.id) ?? 0) + 1);
54
+ if (thread.conversationId && thread.conversationId !== thread.id)
55
+ counts.set(thread.conversationId, (counts.get(thread.conversationId) ?? 0) + 1);
56
+ }
57
+ this.sourceCounts.set(observation.base, counts);
58
+ }
59
+ return observation.targets.reduce((sum, target) => sum + (counts.get(target.threadId) ?? 1), 0);
60
+ }
61
+ update(appId, profileId, transform, observation) {
46
62
  if (this.closed)
47
63
  return Promise.reject(new Error("READ_STORAGE_CLOSED"));
48
64
  const key = JSON.stringify([appId, profileId]);
@@ -53,7 +69,7 @@ export class BatchedReadIndex {
53
69
  }
54
70
  if (queue.pending.length >= this.queueLimit)
55
71
  return Promise.reject(new Error("READ_STORAGE_BACKPRESSURE"));
56
- const promise = new Promise((resolve, reject) => queue.pending.push({ transform, resolve, reject }));
72
+ const promise = new Promise((resolve, reject) => queue.pending.push({ transform, observation, weight: this.weight(observation), resolve, reject }));
57
73
  if (!queue.running && queue.timer === undefined)
58
74
  queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, this.windowMs);
59
75
  return promise;
@@ -63,12 +79,22 @@ export class BatchedReadIndex {
63
79
  return queue.running;
64
80
  clearTimeout(queue.timer);
65
81
  queue.timer = undefined;
66
- const batch = queue.pending.splice(0, this.batchSize);
82
+ const batch = [];
83
+ let weight = 0;
84
+ while (queue.pending.length && batch.length < this.batchSize) {
85
+ const nextWeight = queue.pending[0].weight;
86
+ if (batch.length && weight + nextWeight > 128)
87
+ break;
88
+ weight += nextWeight;
89
+ batch.push(queue.pending.shift());
90
+ }
67
91
  if (!batch.length)
68
92
  return;
69
93
  const run = (async () => {
70
94
  try {
71
- const next = await this.storage.update(queue.appId, queue.profileId, (state, versions) => batch.reduce((current, change) => change.transform(current, versions), state));
95
+ const first = batch[0].observation;
96
+ const observation = first && batch.every(change => change.observation?.base === first.base) ? { base: first.base, targets: batch.flatMap(change => change.observation.targets) } : undefined;
97
+ const next = await this.storage.update(queue.appId, queue.profileId, (state, versions) => batch.reduce((current, change) => change.transform(current, versions), state), observation);
72
98
  batch.forEach(change => change.resolve(next));
73
99
  }
74
100
  catch (error) {
@@ -90,6 +90,14 @@ export declare function assertReadIndexVersions(expected: ReadIndexVersions, cur
90
90
  /** Remove only the withdrawn team's original sources, logical conversations and
91
91
  * queued observations. Other teams' pending operations must survive. */
92
92
  export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
93
+ /** Internal hint for typed reading observations; arbitrary transforms omit it. */
94
+ export type ReadObservationScope = {
95
+ base: ReadIndexState;
96
+ targets: readonly {
97
+ threadId: string;
98
+ messageId: string;
99
+ }[];
100
+ };
93
101
  export interface ReadIndexPersistence {
94
102
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
95
103
  loadVersioned?(appId: string, profileId: string): Promise<{
@@ -99,7 +107,7 @@ export interface ReadIndexPersistence {
99
107
  loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
100
108
  invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
101
109
  /** Transform and persist atomically; UI publishes only the committed result. */
102
- update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
110
+ update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
103
111
  }
104
112
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
105
113
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
@@ -112,6 +120,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
112
120
  private messageRows;
113
121
  private positionRows;
114
122
  private normalizedStates;
123
+ private observationMessages;
115
124
  private versions;
116
125
  private closed;
117
126
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
@@ -130,7 +139,8 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
130
139
  }>;
131
140
  loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
132
141
  invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
133
- update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
142
+ private observe;
143
+ update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
134
144
  close(): Promise<void>;
135
145
  }
136
146
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
@@ -214,6 +214,7 @@ export class IndexedDbReadIndex {
214
214
  messageRows;
215
215
  positionRows;
216
216
  normalizedStates = new WeakSet();
217
+ observationMessages = new WeakMap();
217
218
  versions;
218
219
  closed = false;
219
220
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
@@ -227,6 +228,9 @@ export class IndexedDbReadIndex {
227
228
  this.database.version(1).stores({ profiles: "" });
228
229
  this.database.version(2).stores({ profiles: "", versions: "" });
229
230
  this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
231
+ // Native indexes track scalar source identity even when an older writer
232
+ // changes rows. Reading these keys does not deserialize messageIds arrays.
233
+ 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]" });
230
234
  this.positionRows = this.database.table("readPositions");
231
235
  this.threadRows = this.database.table("readThreads");
232
236
  this.messageRows = this.database.table("readMessages");
@@ -435,8 +439,113 @@ export class IndexedDbReadIndex {
435
439
  return next;
436
440
  });
437
441
  }
438
- async update(appId, profileId, transform) {
442
+ async observe(appId, profileId, transform, scope) {
443
+ if (!this.metadataOnly || scope.targets.length > 128)
444
+ return undefined;
445
+ const key = this.key(appId, profileId), owner = JSON.stringify(key), base = scope.base;
446
+ const header = await this.profiles.get(key);
447
+ if (header && (header.appId !== appId || header.profileId !== profileId || header.schemaVersion !== 1))
448
+ throw new Error("READ_IDENTITY_MISMATCH");
449
+ if (!header?.normalized || base.schemaVersion !== 1 || base.appId !== appId || base.profileId !== profileId || base.syncCursor !== header.syncCursor ||
450
+ header.threadOrder?.length !== base.threads.length || header.threadOrder.some((id, i) => id !== base.threads[i].id))
451
+ return undefined;
452
+ const sources = new Map();
453
+ 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();
456
+ if (direct.length + assigned.length > 128)
457
+ 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))
460
+ return undefined;
461
+ for (const identity of identities) {
462
+ const cached = base.threads.find(thread => thread.id === identity.id);
463
+ if (!cached || cached.kind !== identity.kind || cached.teamId !== identity.teamId)
464
+ return undefined;
465
+ 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
+ if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
467
+ return undefined;
468
+ let source = sources.get(identity.id);
469
+ if (!source) {
470
+ source = { cached, ids: new Set() };
471
+ sources.set(identity.id, source);
472
+ }
473
+ for (const target of scope.targets)
474
+ if (target.threadId === threadId)
475
+ source.ids.add(target.messageId);
476
+ }
477
+ }
478
+ if ([...sources.values()].reduce((sum, source) => sum + source.ids.size, 0) > 128)
479
+ return undefined;
480
+ const sourceIds = [...sources.keys()];
481
+ // Cursors are the mutable cross-document view. Refresh their small rows,
482
+ // while message metadata remains targeted; otherwise a later batch based
483
+ // on an older snapshot could regress a different conversation's progress.
484
+ const allPositions = await this.positionRows.where("owner").equals(owner).toArray();
485
+ const positionById = new Map(allPositions.map(position => [position.id, position]));
486
+ if (base.threads.some(thread => !positionById.has(thread.id)))
487
+ return undefined;
488
+ const positions = sourceIds.map(id => positionById.get(id));
489
+ const partialThreads = [];
490
+ for (let i = 0; i < sourceIds.length; i++) {
491
+ const id = sourceIds[i], source = sources.get(id), position = positions[i];
492
+ if (!position)
493
+ return undefined;
494
+ let cached = this.observationMessages.get(source.cached.messages);
495
+ if (!cached) {
496
+ cached = new Map(source.cached.messages.map(message => [message.id, message]));
497
+ this.observationMessages.set(source.cached.messages, cached);
498
+ }
499
+ const ids = [...source.ids], rows = await this.messageRows.bulkGet(ids.map(messageId => [owner, id, messageId]));
500
+ const messages = [];
501
+ for (let j = 0; j < ids.length; j++) {
502
+ const previous = cached.get(ids[j]), message = rows[j]?.value;
503
+ if (message && (message.payload !== null || typeof message.readingControl !== "boolean"))
504
+ return undefined;
505
+ if (JSON.stringify(previous) !== JSON.stringify(message))
506
+ return undefined;
507
+ if (message)
508
+ messages.push(previous);
509
+ }
510
+ partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
511
+ }
512
+ const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
513
+ const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
514
+ if (result.appId !== appId || result.profileId !== profileId || result.schemaVersion !== 1)
515
+ throw new Error("READ_IDENTITY_MISMATCH");
516
+ 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))
517
+ return undefined;
518
+ const writes = [];
519
+ for (let i = 0; i < result.threads.length; i++) {
520
+ const thread = result.threads[i], before = positions[i];
521
+ if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
522
+ writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
523
+ }
524
+ if (writes.length)
525
+ await this.positionRows.bulkPut(writes);
526
+ if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
527
+ await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
528
+ const byId = new Map(allPositions.map(position => [position.id, position]));
529
+ result.threads.forEach(thread => byId.set(thread.id, thread));
530
+ let changed = false;
531
+ const threads = base.threads.map(thread => {
532
+ const position = byId.get(thread.id);
533
+ if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
534
+ return thread;
535
+ changed = true;
536
+ return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
537
+ });
538
+ if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
539
+ return base;
540
+ return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
541
+ }
542
+ async update(appId, profileId, transform, observation) {
439
543
  return this.transaction("rw", async () => {
544
+ if (observation) {
545
+ const next = await this.observe(appId, profileId, transform, observation);
546
+ if (next)
547
+ return next;
548
+ }
440
549
  const previous = await this.loadCurrent(appId, profileId);
441
550
  const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
442
551
  const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
@@ -39,7 +39,7 @@ export class ReadingClient {
39
39
  getSnapshot = () => this.state;
40
40
  subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
41
41
  publish(state) {
42
- if (this.closed || JSON.stringify(state) === JSON.stringify(this.state))
42
+ if (this.closed || state === this.state || JSON.stringify(state) === JSON.stringify(this.state))
43
43
  return;
44
44
  this.state = state;
45
45
  this.listeners.forEach(listener => listener());
@@ -102,7 +102,7 @@ export class ReadingClient {
102
102
  // methods below only derive changes for their configured team.
103
103
  return this.updateScoped(transform);
104
104
  }
105
- updateScoped(transform, teamId) {
105
+ updateScoped(transform, teamId, observation) {
106
106
  if (this.closed)
107
107
  return Promise.reject(new Error("READING_CLIENT_CLOSED"));
108
108
  // One shared metadata read per local authority generation, rather than one
@@ -114,7 +114,7 @@ export class ReadingClient {
114
114
  assertReadIndexVersions(expected, currentVersions, teamId);
115
115
  const next = transform(state);
116
116
  return this.options.metadataOnly ? readingMetadata(next) : next;
117
- });
117
+ }, observation);
118
118
  if (!this.closed) {
119
119
  this.publish(next);
120
120
  if (next.pending.length || next.observedReadIntents?.length)
@@ -162,7 +162,16 @@ export class ReadingClient {
162
162
  recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
163
163
  record(threadId, ids, readPolicy) {
164
164
  const messageIds = [...new Set(ids)];
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);
165
+ if (!messageIds.length && this.state) {
166
+ try {
167
+ this.assertThreadTeam(this.state, threadId);
168
+ return Promise.resolve(this.state);
169
+ }
170
+ catch (error) {
171
+ return Promise.reject(error);
172
+ }
173
+ }
174
+ 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, this.state && messageIds.length ? { base: this.state, targets: messageIds.map(messageId => ({ threadId, messageId })) } : undefined);
166
175
  }
167
176
  withdraw() { this.stop(); }
168
177
  stop() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.45",
3
+ "version": "0.12.0-next.46",
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",