@pmate/chat-sdk 0.12.0-next.85 → 0.12.0-next.87

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.
@@ -2,6 +2,14 @@ export type ChatErrorCode = "unavailable" | "access-denied" | "authentication-re
2
2
  /** Stable recovery categories. The original failure remains available as cause. */
3
3
  export declare class ChatError extends Error {
4
4
  readonly code: ChatErrorCode;
5
+ readonly diagnostic?: {
6
+ source: string;
7
+ stage?: string;
8
+ database?: string;
9
+ tables?: string;
10
+ mode?: string;
11
+ attempt?: number;
12
+ };
5
13
  readonly retryable: boolean;
6
14
  readonly status?: number;
7
15
  /** Present only when access was denied for one conversation, not the Team. */
@@ -10,5 +18,6 @@ export declare class ChatError extends Error {
10
18
  cause?: unknown;
11
19
  status?: number;
12
20
  threadId?: string;
21
+ diagnostic?: ChatError["diagnostic"];
13
22
  });
14
23
  }
package/lib/chatError.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Stable recovery categories. The original failure remains available as cause. */
2
2
  export class ChatError extends Error {
3
3
  code;
4
+ diagnostic;
4
5
  retryable;
5
6
  status;
6
7
  /** Present only when access was denied for one conversation, not the Team. */
@@ -11,6 +12,7 @@ export class ChatError extends Error {
11
12
  this.name = "ChatError";
12
13
  this.status = options.status;
13
14
  this.threadId = options.threadId;
15
+ this.diagnostic = options.diagnostic;
14
16
  this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
15
17
  }
16
18
  }
@@ -11,7 +11,7 @@ export function asChatError(error) {
11
11
  : /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
12
12
  : status === 400 || /INVALID_|CONVERSATION_MISMATCH|CONVERSATION_UNKNOWN|NOT_OWNED/.test(message) ? "invalid-input"
13
13
  : /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
14
- return new ChatError(code, message, { cause: error, status, threadId: value?.threadId });
14
+ return new ChatError(code, message, { cause: error, status, threadId: value?.threadId, diagnostic: value?.diagnostic });
15
15
  }
16
16
  /** Keep a conversation denial distinct from loss of Team authority. */
17
17
  export function threadChatError(error, threadId) {
@@ -156,8 +156,16 @@ export class ChatSession {
156
156
  this.groups?.hint({ id: groupThreadId(scope.appId, message.to, scope.teamId), kind: "group", targetId: message.to, teamId: scope.teamId }, Number.isSafeInteger(body.payload.versionNumber) ? body.payload.versionNumber : undefined);
157
157
  }
158
158
  const decoded = options.decode(message);
159
+ // A peer can react before history returns our own ACKed source message.
160
+ // Discussion windows store controls against canonical target IDs; retain
161
+ // the known sent local target in the same atomic receive batch so the
162
+ // reaction is visible without waiting for another network history read.
163
+ const control = message.body?.content;
164
+ const targetId = control?.reaction?.messageId ?? control?.recall?.messageId;
165
+ const outgoing = decoded && targetId ? this.sender.snapshot(decoded.threadId).find(record => record.hash === targetId && record.status === 'sent' && record.message.from === scope.profileId) : undefined;
166
+ const localTarget = outgoing && options.decode(outgoing.message);
159
167
  if (decoded)
160
- void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
168
+ void this.messages.receive(decoded.threadId, localTarget ? [localTarget, decoded] : [decoded]).then(async () => {
161
169
  if (!this.closed && this.readingStarted)
162
170
  this.reading.refresh();
163
171
  if (message.from === scope.profileId)
@@ -1,4 +1,6 @@
1
1
  import type { Dexie } from "dexie";
2
2
  /** WebKit can invalidate an IDB connection while the application is suspended.
3
3
  * Only replay a whole rejected transaction; never replay individual requests or clear data. */
4
- export declare function recoverableTransactions(db: Dexie, isClosed: () => boolean): Dexie["transaction"];
4
+ export declare function recoverableTransactions(db: Dexie, isClosed: () => boolean, options?: {
5
+ retryAbort?: boolean;
6
+ }): Dexie["transaction"];
@@ -1,16 +1,31 @@
1
1
  /** WebKit can invalidate an IDB connection while the application is suspended.
2
2
  * Only replay a whole rejected transaction; never replay individual requests or clear data. */
3
- export function recoverableTransactions(db, isClosed) {
4
- const transaction = (...args) => db.transaction.apply(db, args);
3
+ export function recoverableTransactions(db, isClosed, options = {}) {
4
+ const transaction = async (args, attempt) => {
5
+ try {
6
+ return await db.transaction.apply(db, args);
7
+ }
8
+ catch (cause) {
9
+ const original = cause;
10
+ const error = new Error(original.message, { cause });
11
+ error.name = original.name;
12
+ Object.assign(error, { status: original.status, threadId: original.threadId, inner: original.inner, diagnostic: { source: "storage", database: db.name, tables: args.slice(1, -1).flat().map((table) => table.name ?? String(table)).join(","), mode: String(args[0]), attempt } });
13
+ throw error;
14
+ }
15
+ };
5
16
  let recovery;
6
17
  return (async (...args) => {
7
18
  try {
8
- return await transaction(...args);
19
+ return await transaction(args, 1);
9
20
  }
10
21
  catch (error) {
11
22
  const failure = error;
12
- if (isClosed() || !(failure.name === "UnknownError" || failure.inner?.name === "UnknownError") || !/without an in-progress transaction/i.test(failure.message ?? ""))
23
+ const aborted = options.retryAbort && (failure.name === "AbortError" || failure.inner?.name === "AbortError");
24
+ if (isClosed() || !aborted && (!(failure.name === "UnknownError" || failure.inner?.name === "UnknownError") || !/without an in-progress transaction/i.test(failure.message ?? "")))
13
25
  throw error;
26
+ // An aborted transaction has already rolled back; replay without closing other live transactions.
27
+ if (aborted)
28
+ return transaction(args, 2);
14
29
  if (!recovery) {
15
30
  db.close({ disableAutoOpen: false });
16
31
  const opening = db.open();
@@ -21,7 +36,7 @@ export function recoverableTransactions(db, isClosed) {
21
36
  await recovery;
22
37
  if (isClosed())
23
38
  throw error;
24
- return transaction(...args);
39
+ return transaction(args, 2);
25
40
  }
26
41
  });
27
42
  }
@@ -44,6 +44,7 @@ export declare class ReadCountsClient {
44
44
  private requestedRevision;
45
45
  private force;
46
46
  constructor(options: ReadCountsOptions);
47
+ private report;
47
48
  private denyCache;
48
49
  private post;
49
50
  private subscription;
@@ -1,3 +1,4 @@
1
+ import { asChatError, ChatError } from "./chatError.js";
1
2
  import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
2
3
  import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
3
4
  import { messageScopeKey } from "./messageStore.js";
@@ -38,14 +39,18 @@ export class ReadCountsClient {
38
39
  if (options.scope) {
39
40
  if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
40
41
  throw new Error("READ_COUNTS_SCOPE_MISMATCH");
41
- this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
42
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access, retryAbort: true });
42
43
  }
43
44
  }
45
+ report(error, stage) {
46
+ const failure = asChatError(error);
47
+ this.options.onError?.(new ChatError(failure.code, failure.message, { cause: error, status: failure.status, threadId: failure.threadId, diagnostic: { ...failure.diagnostic, source: "read-counts", stage } }));
48
+ }
44
49
  denyCache() {
45
50
  this.revoked = true;
46
51
  this.accessVersion++;
47
52
  this.lastResult = undefined;
48
- void this.cache?.invalidate().catch(error => this.options.onError?.(error));
53
+ void this.cache?.invalidate().catch(error => this.report(error, "cache.invalidate"));
49
54
  }
50
55
  async post(path, body, signal) {
51
56
  const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
@@ -102,7 +107,7 @@ export class ReadCountsClient {
102
107
  this.heartbeat = undefined;
103
108
  }
104
109
  if (!isOfflineNetworkFailure(error))
105
- this.options.onError?.(error);
110
+ this.report(error, "subscription.renew");
106
111
  }).finally(() => { if (this.subscriptionPending === pending)
107
112
  this.subscriptionPending = undefined; });
108
113
  this.subscriptionPending = pending;
@@ -137,7 +142,7 @@ export class ReadCountsClient {
137
142
  this.subscribed = false;
138
143
  this.subscriptionDenied = false;
139
144
  if (old)
140
- void this.subscription(old, true).catch(error => this.options.onError?.(error));
145
+ void this.subscription(old, true).catch(error => this.report(error, "subscription.release"));
141
146
  if (!threadId)
142
147
  return;
143
148
  this.renew();
@@ -190,7 +195,7 @@ export class ReadCountsClient {
190
195
  const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
191
196
  try {
192
197
  const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
193
- throw error; this.options.onError?.(error); return undefined; }) : undefined;
198
+ throw error; this.report(error, "cache.read"); return undefined; }) : undefined;
194
199
  if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
195
200
  return;
196
201
  const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
@@ -214,7 +219,7 @@ export class ReadCountsClient {
214
219
  this.revoked = false;
215
220
  if (stored)
216
221
  await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
217
- throw error; this.options.onError?.(error); });
222
+ throw error; this.report(error, "cache.write"); });
218
223
  if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
219
224
  return;
220
225
  this.revision = value.revision;
@@ -237,7 +242,7 @@ export class ReadCountsClient {
237
242
  clearInterval(this.heartbeat);
238
243
  this.heartbeat = undefined;
239
244
  }
240
- this.options.onError?.(error);
245
+ this.report(error, "counts.request");
241
246
  if (!isResponseCacheAuthorityError(error) && (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) || /READ_COUNTS_HTTP_(409|429)/.test(String(error))) && this.retries < 3) {
242
247
  retryDelay = 1000 * 2 ** this.retries++;
243
248
  this.dirty = true;
@@ -265,6 +270,6 @@ export class ReadCountsClient {
265
270
  if (old)
266
271
  void this.subscription(old, true).catch(() => { });
267
272
  this.thread = undefined;
268
- return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
273
+ return this.closing = this.cache?.close().catch(error => this.report(error, "cache.close")) ?? Promise.resolve();
269
274
  }
270
275
  }
@@ -5,6 +5,7 @@ export type ResponseCacheAccess = () => Promise<{
5
5
  export declare function isResponseCacheAuthorityError(error: unknown): boolean;
6
6
  export declare function assertResponseCacheAccess(access?: ResponseCacheAccess, expectedVersion?: number): Promise<number | undefined>;
7
7
  export type ResponseCacheOptions<T> = {
8
+ retryAbort?: boolean;
8
9
  indexedDB?: IDBFactory;
9
10
  name?: string;
10
11
  access?: ResponseCacheAccess;
@@ -36,7 +36,7 @@ export class IndexedDbResponseCache {
36
36
  if (!Number.isSafeInteger(value) || value < 1 || value > max)
37
37
  throw new Error("INVALID_RESPONSE_CACHE_LIMIT");
38
38
  this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
39
- this.transact = recoverableTransactions(this.db, () => this.closed);
39
+ this.transact = recoverableTransactions(this.db, () => this.closed, { retryAbort: options.retryAbort });
40
40
  this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
41
41
  this.rows = this.db.table("windows");
42
42
  this.epochs = this.db.table("epochs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.85",
3
+ "version": "0.12.0-next.87",
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",