@pmate/chat-sdk 0.12.0-next.74 → 0.12.0-next.75

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.
@@ -4,8 +4,11 @@ export declare class ChatError extends Error {
4
4
  readonly code: ChatErrorCode;
5
5
  readonly retryable: boolean;
6
6
  readonly status?: number;
7
+ /** Present only when access was denied for one conversation, not the Team. */
8
+ readonly threadId?: string;
7
9
  constructor(code: ChatErrorCode, message: string, options?: {
8
10
  cause?: unknown;
9
11
  status?: number;
12
+ threadId?: string;
10
13
  });
11
14
  }
package/lib/chatError.js CHANGED
@@ -3,11 +3,14 @@ export class ChatError extends Error {
3
3
  code;
4
4
  retryable;
5
5
  status;
6
+ /** Present only when access was denied for one conversation, not the Team. */
7
+ threadId;
6
8
  constructor(code, message, options = {}) {
7
9
  super(message, { cause: options.cause });
8
10
  this.code = code;
9
11
  this.name = "ChatError";
10
12
  this.status = options.status;
13
+ this.threadId = options.threadId;
11
14
  this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
12
15
  }
13
16
  }
@@ -1,6 +1,6 @@
1
1
  import { createMessagePresenter } from "../messageView.js";
2
2
  import { createChat as createSession } from "../runtime/createChat.js";
3
- import { asChatError } from "../runtime/chatError.js";
3
+ import { asChatError, threadChatError } from "../runtime/chatError.js";
4
4
  import { ChatError } from "../chatError.js";
5
5
  export { ChatError } from "../chatError.js";
6
6
  const emptyMessages = Object.freeze([]);
@@ -61,7 +61,7 @@ export class ChatEngine {
61
61
  async deny(threadId, failure) {
62
62
  this.errors.set(threadId, failure);
63
63
  this.targets.delete(threadId);
64
- const retiring = this.advanced.revokeAccess();
64
+ const retiring = this.advanced.revokeThread(threadId);
65
65
  this.notify(threadId);
66
66
  try {
67
67
  await retiring;
@@ -79,7 +79,7 @@ export class ChatEngine {
79
79
  return await run();
80
80
  }
81
81
  catch (error) {
82
- const failure = asChatError(error);
82
+ const failure = threadId ? threadChatError(error, threadId) : asChatError(error);
83
83
  if (threadId && failure.code === "access-denied")
84
84
  await this.deny(threadId, failure);
85
85
  throw failure;
@@ -187,7 +187,7 @@ export class ChatEngine {
187
187
  return this.getSnapshot(threadId);
188
188
  }
189
189
  catch (error) {
190
- const failure = asChatError(error);
190
+ const failure = this.advanced.active ? threadChatError(error, threadId) : asChatError(error);
191
191
  if (!this.closed) {
192
192
  if (failure.code === "access-denied")
193
193
  await this.deny(threadId, failure);
@@ -1,3 +1,5 @@
1
1
  import { ChatError } from "../chatError.js";
2
2
  export { ChatError, type ChatErrorCode } from "../chatError.js";
3
3
  export declare function asChatError(error: unknown): ChatError;
4
+ /** Keep a conversation denial distinct from loss of Team authority. */
5
+ export declare function threadChatError(error: unknown, threadId: string): ChatError;
@@ -11,5 +11,10 @@ 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 });
14
+ return new ChatError(code, message, { cause: error, status, threadId: value?.threadId });
15
+ }
16
+ /** Keep a conversation denial distinct from loss of Team authority. */
17
+ export function threadChatError(error, threadId) {
18
+ const failure = asChatError(error);
19
+ return failure.code === "access-denied" ? new ChatError(failure.code, failure.message, { cause: failure, status: failure.status, threadId }) : failure;
15
20
  }
@@ -193,6 +193,8 @@ export declare class ChatSession<T extends StoredMessage> {
193
193
  private buildGlobal;
194
194
  private publishGlobal;
195
195
  private publish;
196
+ /** Withdraw one conversation without retiring sibling conversations. */
197
+ revokeThread(threadId: string): Promise<void>;
196
198
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
197
199
  * completion means all durable stores have crossed the same authority epoch. */
198
200
  revokeAccess(): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { threadChatError } from "./chatError.js";
1
2
  import { ConversationDirectory } from "./conversationDirectory.js";
2
3
  import { groupThreadId } from "./thread.js";
3
4
  import { GroupClient } from "./groupClient.js";
@@ -82,7 +83,7 @@ export class ChatSession {
82
83
  this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
83
84
  this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
84
85
  if (options.loadGroup) {
85
- this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: () => { void this.revokeAccess().catch(error => options.onError(error)); } });
86
+ this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: async (error, threadId) => { await this.messages.revoke(threadId); options.onError(threadChatError(error, threadId)); } });
86
87
  this.off.push(this.groups.subscribe(thread => this.publish(thread)));
87
88
  }
88
89
  this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
@@ -836,6 +837,8 @@ export class ChatSession {
836
837
  if (notifyGlobal)
837
838
  this.publishGlobal();
838
839
  }
840
+ /** Withdraw one conversation without retiring sibling conversations. */
841
+ revokeThread(threadId) { return this.messages.revoke(threadId); }
839
842
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
840
843
  * completion means all durable stores have crossed the same authority epoch. */
841
844
  revokeAccess() {
@@ -1,3 +1,4 @@
1
+ import { threadChatError } from "./chatError.js";
1
2
  import { ChatCredentials } from "../credentials.js";
2
3
  import { voiceUploader } from "./voiceUpload.js";
3
4
  import { isOffline } from "./connectivity.js";
@@ -41,7 +42,7 @@ export function createChatSession(options) {
41
42
  },
42
43
  loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
43
44
  activeThreadId: options.threadId,
44
- messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
45
+ messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError: (error, thread) => onError(thread ? threadChatError(error, thread) : error) },
45
46
  reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: fetcher, onError },
46
47
  connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: fetcher,
47
48
  getToken: credentials?.getToken,
@@ -34,7 +34,7 @@ export type GroupClientOptions = {
34
34
  access: ResponseCacheAccess;
35
35
  load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
36
36
  onError: (error: unknown) => void;
37
- onDenied?: (error: unknown) => Promise<void> | void;
37
+ onDenied?: (error: unknown, threadId: string) => Promise<void> | void;
38
38
  indexedDB?: IDBFactory;
39
39
  name?: string;
40
40
  };
@@ -56,6 +56,7 @@ export declare class GroupClient {
56
56
  private queue;
57
57
  private controllers;
58
58
  private channel?;
59
+ private denied;
59
60
  private withdrawn;
60
61
  private closed;
61
62
  private closing?;
@@ -23,6 +23,7 @@ export class GroupClient {
23
23
  queue = [];
24
24
  controllers = new Set();
25
25
  channel;
26
+ denied = new Set();
26
27
  withdrawn = false;
27
28
  closed = false;
28
29
  closing;
@@ -177,6 +178,8 @@ export class GroupClient {
177
178
  }
178
179
  async reload(entry) { entry.loaded = false; await this.local(entry); }
179
180
  async get(thread, options = {}) {
181
+ if (this.denied.has(thread.id))
182
+ throw Object.assign(new Error("GROUP_ACCESS_REVOKED"), { status: 403, threadId: thread.id });
180
183
  const entry = this.entry(thread);
181
184
  await this.local(entry);
182
185
  await this.checkAuthority();
@@ -190,7 +193,7 @@ export class GroupClient {
190
193
  throw entry.snapshot.error ?? new Error("GROUP_METADATA_OFFLINE");
191
194
  return this.request(entry);
192
195
  }
193
- schedule(entry) { if (!this.closed && !this.withdrawn && !isOffline())
196
+ schedule(entry) { if (!this.closed && !this.withdrawn && !this.denied.has(entry.thread.id) && !isOffline())
194
197
  this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
195
198
  wake() { for (const entry of this.entries.values())
196
199
  this.schedule(entry); }
@@ -264,11 +267,16 @@ export class GroupClient {
264
267
  return value;
265
268
  }
266
269
  catch (error) {
270
+ if (error?.status === 403)
271
+ Object.assign(error, { threadId: entry.thread.id });
267
272
  if (!this.closed) {
268
273
  const denied = [401, 403].includes(error?.status ?? 0);
269
274
  this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
270
- if (denied)
271
- await this.options.onDenied?.(error);
275
+ if (denied) {
276
+ this.denied.add(entry.thread.id);
277
+ await this.cache.remove(entry.thread.id);
278
+ await this.options.onDenied?.(error, entry.thread.id);
279
+ }
272
280
  }
273
281
  throw error;
274
282
  }
@@ -51,6 +51,8 @@ export declare class IndexedDbResponseCache<T> {
51
51
  } | undefined;
52
52
  }>>;
53
53
  write(key: string, value: T, epoch: number, expectedRevision?: string | null): Promise<void>;
54
+ /** Forget one response without invalidating unrelated cached conversations. */
55
+ remove(key: string): Promise<void>;
54
56
  /** Idempotent shared-scope retirement: an old cleanup cannot erase windows
55
57
  * created after the same authority version was already recovered. */
56
58
  retire(version: number): Promise<void>;
@@ -105,6 +105,12 @@ export class IndexedDbResponseCache {
105
105
  this.tasks.schedule("write", () => this.flush());
106
106
  return promise;
107
107
  }
108
+ /** Forget one response without invalidating unrelated cached conversations. */
109
+ async remove(key) {
110
+ await this.flush();
111
+ await this.synchronizeAccess();
112
+ await this.transact("rw", this.rows, () => this.rows.delete([this.scope, key]));
113
+ }
108
114
  /** Idempotent shared-scope retirement: an old cleanup cannot erase windows
109
115
  * created after the same authority version was already recovered. */
110
116
  retire(version) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.74",
3
+ "version": "0.12.0-next.75",
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",
@@ -53,15 +53,6 @@
53
53
  "publishConfig": {
54
54
  "access": "public"
55
55
  },
56
- "scripts": {
57
- "clean": "rimraf lib",
58
- "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
59
- "prepack": "pnpm run build",
60
- "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
61
- "test": "vitest run",
62
- "storybook": "storybook dev -p 6009",
63
- "build-storybook": "storybook build --output-dir dist"
64
- },
65
56
  "dependencies": {
66
57
  "@emoji-mart/data": "^1.2.1",
67
58
  "@emoji-mart/react": "^1.1.1",
@@ -77,16 +68,24 @@
77
68
  "devDependencies": {
78
69
  "@storybook/addon-docs": "^9.0.17",
79
70
  "@storybook/react-vite": "9.0.17",
80
- "@tailwindcss/vite": "catalog:",
81
- "@types/react": "catalog:",
82
- "@types/react-dom": "catalog:",
71
+ "@tailwindcss/vite": "^4.1.3",
72
+ "@types/react": "^19.2.7",
73
+ "@types/react-dom": "^19.2.3",
83
74
  "fake-indexeddb": "^6.2.5",
84
- "react": "catalog:",
85
- "react-dom": "catalog:",
75
+ "react": "^19.1.1",
76
+ "react-dom": "^19.1.1",
86
77
  "rimraf": "^6.1.2",
87
78
  "storybook": "^9.0.17",
88
- "tailwindcss": "catalog:",
79
+ "tailwindcss": "^4.0.14",
89
80
  "typescript": "5.9.2",
90
81
  "vitest": "^4.0.17"
82
+ },
83
+ "scripts": {
84
+ "clean": "rimraf lib",
85
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
86
+ "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
87
+ "test": "vitest run",
88
+ "storybook": "storybook dev -p 6009",
89
+ "build-storybook": "storybook build --output-dir dist"
91
90
  }
92
- }
91
+ }