@pmate/chat-sdk 0.9.5 → 0.9.7

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.
@@ -24,6 +24,12 @@ export type IndexedReadThread = {
24
24
  readThroughSeq: number;
25
25
  explicitReadMessageIds: string[];
26
26
  };
27
+ export type ObservedReadIntent = {
28
+ operationId: string;
29
+ threadId: string;
30
+ messageId: string;
31
+ readPolicy: "visible" | "explicit";
32
+ };
27
33
  export type ReadIndexState = {
28
34
  schemaVersion: 1;
29
35
  appId: string;
@@ -31,8 +37,10 @@ export type ReadIndexState = {
31
37
  syncCursor: string;
32
38
  threads: IndexedReadThread[];
33
39
  pending: ReadingOperation[];
40
+ /** Actual viewport/playback observation awaiting authoritative message metadata. */
41
+ observedReadIntents?: ObservedReadIntent[];
34
42
  };
35
- export type ReadSnapshot = Omit<ReadIndexState, "pending">;
43
+ export type ReadSnapshot = Omit<ReadIndexState, "pending" | "observedReadIntents">;
36
44
  export type ReadChange = {
37
45
  eventId: string;
38
46
  appId: string;
@@ -50,6 +58,8 @@ export declare function createReadIndex(appId: string, profileId: string): ReadI
50
58
  export declare function installReadSnapshot(current: ReadIndexState, snapshot: ReadSnapshot): ReadIndexState;
51
59
  /** Cursor positions are opaque; only exact predecessor matching proves continuity. */
52
60
  export declare function applyReadChange(current: ReadIndexState, change: ReadChange): ReadIndexState;
61
+ /** Persist observations even if the message arrived before its Sequence metadata. */
62
+ export declare function recordReadIntent(current: ReadIndexState, intent: ObservedReadIntent): ReadIndexState;
53
63
  /** Called only for a message the visible viewport actually reached. */
54
64
  export declare function recordVisibleRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
55
65
  export declare function recordExplicitRead(current: ReadIndexState, threadId: string, messageId: string, operationId: string): ReadIndexState;
@@ -56,7 +56,7 @@ export function installReadSnapshot(current, snapshot) {
56
56
  byId.set(thread.id, { ...thread, readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
57
57
  explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...previous.explicitReadMessageIds])] });
58
58
  }
59
- return { ...snapshot, threads: [...byId.values()], pending };
59
+ return resolveObservedReads({ ...snapshot, threads: [...byId.values()], pending, observedReadIntents: current.observedReadIntents ?? [] });
60
60
  }
61
61
  /** Cursor positions are opaque; only exact predecessor matching proves continuity. */
62
62
  export function applyReadChange(current, change) {
@@ -72,7 +72,36 @@ export function applyReadChange(current, change) {
72
72
  const merged = previous ? { ...incoming, readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
73
73
  explicitReadMessageIds: [...new Set([...incoming.explicitReadMessageIds, ...previous.explicitReadMessageIds])] } : incoming;
74
74
  const projected = pending.filter(operation => operation.threadId === incoming.id).reduce(applyOperation, merged);
75
- return { ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] };
75
+ return resolveObservedReads({ ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] });
76
+ }
77
+ /** Persist observations even if the message arrived before its Sequence metadata. */
78
+ export function recordReadIntent(current, intent) {
79
+ if (!intent.operationId || !intent.threadId || !intent.messageId || !["visible", "explicit"].includes(intent.readPolicy))
80
+ throw new Error("INVALID_READ_INTENT");
81
+ const observed = current.observedReadIntents ?? [];
82
+ if (observed.some(item => item.threadId === intent.threadId && item.messageId === intent.messageId && item.readPolicy === intent.readPolicy))
83
+ return current;
84
+ if (observed.some(item => item.operationId === intent.operationId) || current.pending.some(item => item.operationId === intent.operationId))
85
+ throw new Error("INVALID_READ_OPERATION_ID");
86
+ return resolveObservedReads({ ...current, observedReadIntents: [...observed, structuredClone(intent)] });
87
+ }
88
+ function resolveObservedReads(current) {
89
+ let next = current;
90
+ const remaining = [];
91
+ for (const intent of current.observedReadIntents ?? []) {
92
+ const message = next.threads.find(thread => thread.id === intent.threadId)?.messages.find(message => message.id === intent.messageId);
93
+ if (!message) {
94
+ remaining.push(intent);
95
+ continue;
96
+ }
97
+ if (message.recalled || message.from === next.profileId || !message.recipients.includes(next.profileId))
98
+ continue;
99
+ if (message.readPolicy !== intent.readPolicy)
100
+ throw new Error("READ_POLICY_MISMATCH");
101
+ next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
102
+ : recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
103
+ }
104
+ return { ...next, observedReadIntents: remaining };
76
105
  }
77
106
  /** Called only for a message the visible viewport actually reached. */
78
107
  export function recordVisibleRead(current, threadId, messageId, operationId) {
@@ -1,4 +1,4 @@
1
- import { createReadIndex, installReadSnapshot } from "./readIndex.js";
1
+ import { 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;
@@ -57,6 +57,31 @@ export class ReadSyncClient {
57
57
  this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
58
58
  }
59
59
  const query = new URLSearchParams({ appId, profileId });
60
+ if (!this.readAfter && current.syncCursor !== "0") {
61
+ const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
62
+ if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
63
+ || typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
64
+ throw new Error("INVALID_READ_DELTA");
65
+ if (!delta.snapshotRequired) {
66
+ try {
67
+ if (this.controller.signal.aborted)
68
+ throw new Error("READ_SYNC_STOPPED");
69
+ const next = await persistence.update(appId, profileId, state => {
70
+ const applied = delta.changes.reduce(applyReadChange, state);
71
+ if (applied.syncCursor !== delta.syncCursor)
72
+ throw new ReadSyncGap(applied.syncCursor);
73
+ return applied;
74
+ });
75
+ if (!this.controller.signal.aborted)
76
+ this.options.onChange?.(next);
77
+ return next;
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof ReadSyncGap))
81
+ throw error;
82
+ }
83
+ }
84
+ }
60
85
  const snapshot = await this.request(`/api/social/reading/snapshot?${query}`, {
61
86
  headers: this.readAfter ? { "x-pmate-read-after-time": String(this.readAfter) } : {},
62
87
  });
@@ -64,6 +89,8 @@ export class ReadSyncClient {
64
89
  throw new Error("READ_SYNC_STOPPED");
65
90
  // Merge into the latest transaction, including reads made while HTTP was pending.
66
91
  const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
92
+ if (!next.pending.length)
93
+ this.readAfter = 0;
67
94
  if (!this.controller.signal.aborted)
68
95
  this.options.onChange?.(next);
69
96
  return next;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
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",