@pmate/chat-sdk 0.9.4 → 0.9.6

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.
@@ -13,3 +13,4 @@ export * from "./call.js";
13
13
  export * from "./presence.js";
14
14
  export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
+ export * from "./readSync.js";
@@ -13,3 +13,4 @@ export * from "./call.js";
13
13
  export * from "./presence.js";
14
14
  export * from "./meeting.js";
15
15
  export * from "./notification.js";
16
+ export * from "./readSync.js";
@@ -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) {
@@ -0,0 +1,23 @@
1
+ import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
2
+ export type ReadSyncOptions = {
3
+ appId: string;
4
+ profileId: string;
5
+ sessionToken: string;
6
+ hubHttpUrl: string;
7
+ persistence: ReadIndexPersistence;
8
+ fetch?: typeof globalThis.fetch;
9
+ onChange?: (state: ReadIndexState) => void;
10
+ };
11
+ /** Keeps local operations until an authoritative projection covers them. */
12
+ export declare class ReadSyncClient {
13
+ private readonly options;
14
+ private readonly controller;
15
+ private inFlight?;
16
+ private readAfter;
17
+ constructor(options: ReadSyncOptions);
18
+ loadLocal(): Promise<ReadIndexState>;
19
+ stop(): void;
20
+ synchronize(): Promise<ReadIndexState>;
21
+ private request;
22
+ private run;
23
+ }
@@ -0,0 +1,71 @@
1
+ import { createReadIndex, installReadSnapshot } from "./readIndex.js";
2
+ /** Keeps local operations until an authoritative projection covers them. */
3
+ export class ReadSyncClient {
4
+ options;
5
+ controller = new AbortController();
6
+ inFlight;
7
+ readAfter = 0;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ async loadLocal() {
12
+ return await this.options.persistence.load(this.options.appId, this.options.profileId)
13
+ ?? createReadIndex(this.options.appId, this.options.profileId);
14
+ }
15
+ stop() { this.controller.abort(); }
16
+ synchronize() {
17
+ if (this.inFlight)
18
+ return this.inFlight;
19
+ const task = this.run().finally(() => { if (this.inFlight === task)
20
+ this.inFlight = undefined; });
21
+ this.inFlight = task;
22
+ return task;
23
+ }
24
+ async request(path, init = {}) {
25
+ if (this.controller.signal.aborted)
26
+ throw new Error("READ_SYNC_STOPPED");
27
+ const timeout = new AbortController();
28
+ const abort = () => timeout.abort();
29
+ this.controller.signal.addEventListener("abort", abort, { once: true });
30
+ const timer = setTimeout(abort, 10_000);
31
+ try {
32
+ const response = await (this.options.fetch ?? globalThis.fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, {
33
+ ...init, signal: timeout.signal, headers: { ...init.headers, authorization: `Bearer ${this.options.sessionToken}` },
34
+ });
35
+ if (!response.ok)
36
+ throw new Error(`READ_SYNC_HTTP_${response.status}`);
37
+ const envelope = await response.json();
38
+ if (envelope.success !== true || envelope.data === undefined)
39
+ throw new Error("INVALID_READ_SYNC_RESPONSE");
40
+ return envelope.data;
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ this.controller.signal.removeEventListener("abort", abort);
45
+ }
46
+ }
47
+ async run() {
48
+ const { appId, profileId, persistence } = this.options;
49
+ const current = await this.loadLocal();
50
+ for (const operation of current.pending) {
51
+ const receipt = await this.request("/api/social/reading", {
52
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
53
+ });
54
+ if (receipt.operationId !== operation.operationId || receipt.accepted !== true
55
+ || !Number.isSafeInteger(receipt.acceptedAt) || receipt.acceptedAt <= 0)
56
+ throw new Error("INVALID_READING_RECEIPT");
57
+ this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
58
+ }
59
+ const query = new URLSearchParams({ appId, profileId });
60
+ const snapshot = await this.request(`/api/social/reading/snapshot?${query}`, {
61
+ headers: this.readAfter ? { "x-pmate-read-after-time": String(this.readAfter) } : {},
62
+ });
63
+ if (this.controller.signal.aborted)
64
+ throw new Error("READ_SYNC_STOPPED");
65
+ // Merge into the latest transaction, including reads made while HTTP was pending.
66
+ const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
67
+ if (!this.controller.signal.aborted)
68
+ this.options.onChange?.(next);
69
+ return next;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
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",