@pmate/chat-sdk 0.12.0-next.82 → 0.12.0-next.83

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.
@@ -20,5 +20,6 @@ export declare class ReadSyncClient {
20
20
  stop(): void;
21
21
  synchronize(): Promise<ReadIndexState>;
22
22
  private request;
23
+ private repairLegacyDiscussion;
23
24
  private run;
24
25
  }
@@ -39,8 +39,12 @@ export class ReadSyncClient {
39
39
  const response = await (this.options.fetch ?? globalThis.fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, {
40
40
  ...init, signal: timeout.signal, headers: { ...init.headers, authorization: `Bearer ${this.options.sessionToken}` },
41
41
  });
42
- if (!response.ok)
43
- throw Object.assign(new Error(`READ_SYNC_HTTP_${response.status}`), { status: response.status });
42
+ if (!response.ok) {
43
+ // Keep only a fixed domain code, never a provider response body.
44
+ const failure = await response.json().catch(() => undefined);
45
+ const code = failure?.message === "INVALID_READING_CURSOR" ? failure.message : undefined;
46
+ throw Object.assign(new Error(`READ_SYNC_HTTP_${response.status}`), { status: response.status, code });
47
+ }
44
48
  const envelope = await response.json();
45
49
  if (envelope.success !== true || envelope.data === undefined)
46
50
  throw new Error("INVALID_READ_SYNC_RESPONSE");
@@ -51,7 +55,42 @@ export class ReadSyncClient {
51
55
  this.controller.signal.removeEventListener("abort", abort);
52
56
  }
53
57
  }
54
- async run() {
58
+ async repairLegacyDiscussion(operation, versions) {
59
+ if (operation.threadRootId !== undefined || operation.readThroughSeq <= 0 || operation.explicitReadMessageIds.length)
60
+ return false;
61
+ const { appId, profileId, persistence } = this.options;
62
+ const query = new URLSearchParams({ appId, profileId, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) });
63
+ const snapshot = await this.request(`/api/social/reading/snapshot?${query}`);
64
+ // Validate the complete response before changing any durable operation.
65
+ installReadSnapshot(createReadIndex(appId, profileId), snapshot);
66
+ const thread = snapshot.threads.find(thread => thread.id === operation.threadId);
67
+ const message = thread?.messages.find(message => message.id === operation.readThroughMessageId
68
+ && message.seq === operation.readThroughSeq && message.readPolicy === "visible" && message.threadRootId != null);
69
+ if (!thread || !message)
70
+ return false;
71
+ let repaired = false;
72
+ await persistence.update(appId, profileId, (state, currentVersions) => {
73
+ assertReadIndexVersions(versions, currentVersions);
74
+ if (this.controller.signal.aborted)
75
+ throw new Error("READ_SYNC_STOPPED");
76
+ const pending = state.pending.map(item => {
77
+ if (item.operationId !== operation.operationId || JSON.stringify(item) !== JSON.stringify(operation))
78
+ return item;
79
+ repaired = true;
80
+ // New payload, new identity: an operation ID must never change meaning.
81
+ return { ...item, operationId: crypto.randomUUID(), threadRootId: message.threadRootId };
82
+ });
83
+ if (!repaired)
84
+ return state;
85
+ // Undo the rejected main-conversation optimistic position, then replay all
86
+ // remaining operations on the authoritative snapshot in their own scopes.
87
+ const threads = state.threads.map(item => item.id === thread.id && item.readThroughSeq === operation.readThroughSeq
88
+ ? { ...item, readThroughSeq: thread.readThroughSeq } : item);
89
+ return installReadSnapshot({ ...state, threads, pending }, snapshot);
90
+ });
91
+ return repaired;
92
+ }
93
+ async run(repairedLegacy = false) {
55
94
  const { appId, profileId, persistence } = this.options;
56
95
  const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
57
96
  const current = loaded.state ?? createReadIndex(appId, profileId);
@@ -72,9 +111,19 @@ export class ReadSyncClient {
72
111
  for (const operation of outgoing) {
73
112
  if (persistence.loadVersions)
74
113
  assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
75
- const receipt = await this.request("/api/social/reading", {
76
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
77
- });
114
+ let receipt;
115
+ try {
116
+ receipt = await this.request("/api/social/reading", {
117
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
118
+ });
119
+ }
120
+ catch (error) {
121
+ if (!repairedLegacy && error?.status === 400
122
+ && error.code === "INVALID_READING_CURSOR"
123
+ && await this.repairLegacyDiscussion(operation, versions))
124
+ return this.run(true);
125
+ throw error;
126
+ }
78
127
  if (receipt.operationId !== operation.operationId || receipt.accepted !== true
79
128
  || !Number.isSafeInteger(receipt.acceptedAt) || receipt.acceptedAt <= 0)
80
129
  throw new Error("INVALID_READING_RECEIPT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.82",
3
+ "version": "0.12.0-next.83",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",