@pmate/chat-sdk 0.12.0-next.86 → 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.
- package/lib/chatError.d.ts +9 -0
- package/lib/chatError.js +2 -0
- package/lib/runtime/chatError.js +1 -1
- package/lib/runtime/indexedDbRecovery.d.ts +3 -1
- package/lib/runtime/indexedDbRecovery.js +20 -5
- package/lib/runtime/readCounts.d.ts +1 -0
- package/lib/runtime/readCounts.js +13 -8
- package/lib/runtime/responseCache.d.ts +1 -0
- package/lib/runtime/responseCache.js +1 -1
- package/package.json +16 -17
package/lib/chatError.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/runtime/chatError.js
CHANGED
|
@@ -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) {
|
|
@@ -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
|
|
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 = (
|
|
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(
|
|
19
|
+
return await transaction(args, 1);
|
|
9
20
|
}
|
|
10
21
|
catch (error) {
|
|
11
22
|
const failure = error;
|
|
12
|
-
|
|
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(
|
|
39
|
+
return transaction(args, 2);
|
|
25
40
|
}
|
|
26
41
|
});
|
|
27
42
|
}
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -58,15 +58,6 @@
|
|
|
58
58
|
"publishConfig": {
|
|
59
59
|
"access": "public"
|
|
60
60
|
},
|
|
61
|
-
"scripts": {
|
|
62
|
-
"clean": "rimraf lib",
|
|
63
|
-
"build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
|
|
64
|
-
"prepack": "pnpm run build",
|
|
65
|
-
"npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
|
|
66
|
-
"test": "vitest run",
|
|
67
|
-
"storybook": "storybook dev -p 6009",
|
|
68
|
-
"build-storybook": "storybook build --output-dir dist"
|
|
69
|
-
},
|
|
70
61
|
"dependencies": {
|
|
71
62
|
"@emoji-mart/data": "^1.2.1",
|
|
72
63
|
"@emoji-mart/react": "^1.1.1",
|
|
@@ -82,16 +73,24 @@
|
|
|
82
73
|
"devDependencies": {
|
|
83
74
|
"@storybook/addon-docs": "^9.0.17",
|
|
84
75
|
"@storybook/react-vite": "9.0.17",
|
|
85
|
-
"@tailwindcss/vite": "
|
|
86
|
-
"@types/react": "
|
|
87
|
-
"@types/react-dom": "
|
|
76
|
+
"@tailwindcss/vite": "^4.1.3",
|
|
77
|
+
"@types/react": "^19.2.7",
|
|
78
|
+
"@types/react-dom": "^19.2.3",
|
|
88
79
|
"fake-indexeddb": "^6.2.5",
|
|
89
|
-
"react": "
|
|
90
|
-
"react-dom": "
|
|
80
|
+
"react": "^19.1.1",
|
|
81
|
+
"react-dom": "^19.1.1",
|
|
91
82
|
"rimraf": "^6.1.2",
|
|
92
83
|
"storybook": "^9.0.17",
|
|
93
|
-
"tailwindcss": "
|
|
84
|
+
"tailwindcss": "^4.0.14",
|
|
94
85
|
"typescript": "5.9.2",
|
|
95
86
|
"vitest": "^4.0.17"
|
|
87
|
+
},
|
|
88
|
+
"scripts": {
|
|
89
|
+
"clean": "rimraf lib",
|
|
90
|
+
"build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
|
|
91
|
+
"npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
|
|
92
|
+
"test": "vitest run",
|
|
93
|
+
"storybook": "storybook dev -p 6009",
|
|
94
|
+
"build-storybook": "storybook build --output-dir dist"
|
|
96
95
|
}
|
|
97
|
-
}
|
|
96
|
+
}
|