@pmate/chat-sdk 0.12.0-next.73 → 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.
@@ -145,4 +145,7 @@ export interface Chat {
145
145
  threadId: string;
146
146
  }>;
147
147
  updateGroup(threadId: string, changes: GroupChanges): Promise<ChatGroup>;
148
+ dissolveGroup(threadId: string, options?: {
149
+ expectedRevision?: string;
150
+ }): Promise<ChatGroup>;
148
151
  }
@@ -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
  }
@@ -52,7 +52,7 @@ export type ChatMessage = Readonly<{
52
52
  mentions?: readonly ChatMention[];
53
53
  recipientCount?: number;
54
54
  system?: Readonly<{
55
- type: "group-created" | "group-updated" | "contact-updated";
55
+ type: "group-created" | "group-updated" | "group-dissolved" | "contact-updated";
56
56
  title?: string;
57
57
  }>;
58
58
  }>;
@@ -60,11 +60,17 @@ export type ChatGroup = Readonly<{
60
60
  groupId: string;
61
61
  teamId: string;
62
62
  ownerId: string;
63
+ revision: string;
64
+ } & ({
65
+ status: "active";
63
66
  title: string;
64
67
  avatar?: string;
65
68
  memberIds: readonly string[];
66
- revision: string;
67
- }>;
69
+ } | {
70
+ status: "dissolved";
71
+ dissolvedBy?: string;
72
+ dissolvedAt?: number;
73
+ })>;
68
74
  export type ConversationInfo = Readonly<{
69
75
  kind: "dm" | "group";
70
76
  targetId: string;
@@ -11,8 +11,10 @@ export type OutgoingContent = Exclude<RuntimeContent, {
11
11
  groupCreate: unknown;
12
12
  } | {
13
13
  groupUpdate: unknown;
14
+ } | {
15
+ groupDissolve: unknown;
14
16
  }>;
15
- import type { ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
17
+ import type { ChatGroup, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
16
18
  export type { ChatGroup, ConversationInfo, ChatThreadSnapshot, ChatSnapshot, ReadOptions } from "../chatTypes.js";
17
19
  export type MessageQuery = {
18
20
  earlier?: boolean;
@@ -94,15 +96,7 @@ export declare class ChatEngine {
94
96
  getVoiceAttachment(threadId: string, messageId: string): Promise<import("../runtime/durableSender.js").VoiceAttachmentLease>;
95
97
  getGroup(threadId: string, options?: {
96
98
  refresh?: boolean;
97
- }): Promise<Readonly<{
98
- groupId: string;
99
- teamId: string;
100
- ownerId: string;
101
- title: string;
102
- avatar?: string;
103
- memberIds: readonly string[];
104
- revision: string;
105
- }>>;
99
+ }): Promise<ChatGroup>;
106
100
  createGroup(input: {
107
101
  groupId?: string;
108
102
  title: string;
@@ -112,20 +106,25 @@ export declare class ChatEngine {
112
106
  groupId: string;
113
107
  teamId: string;
114
108
  ownerId: string;
109
+ revision: string;
110
+ status: "active";
115
111
  title: string;
116
112
  avatar?: string;
117
113
  memberIds: readonly string[];
118
- revision: string;
119
- }>;
120
- updateGroup(threadId: string, changes: GroupChanges): Promise<Readonly<{
114
+ } | {
115
+ threadId: string;
121
116
  groupId: string;
122
117
  teamId: string;
123
118
  ownerId: string;
124
- title: string;
125
- avatar?: string;
126
- memberIds: readonly string[];
127
119
  revision: string;
128
- }>>;
120
+ status: "dissolved";
121
+ dissolvedBy?: string;
122
+ dissolvedAt?: number;
123
+ }>;
124
+ updateGroup(threadId: string, changes: GroupChanges): Promise<ChatGroup>;
125
+ dissolveGroup(threadId: string, options?: {
126
+ expectedRevision?: string;
127
+ }): Promise<ChatGroup>;
129
128
  revokeAccess(): Promise<void>;
130
129
  close(): Promise<void>;
131
130
  }
@@ -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);
@@ -201,8 +201,8 @@ export class ChatEngine {
201
201
  }
202
202
  sendMessage(threadId, content, options = {}) {
203
203
  return this.threadOperation(threadId, async () => {
204
- if (content && ("groupCreate" in content || "groupUpdate" in content))
205
- throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
204
+ if (content && ("groupCreate" in content || "groupUpdate" in content || "groupDissolve" in content))
205
+ throw new ChatError("invalid-input", "Use group lifecycle methods for group changes");
206
206
  const result = await this.advanced.sendMessage(threadId, content, options);
207
207
  return { id: result.hash, timestamp: result.t };
208
208
  });
@@ -275,6 +275,7 @@ export class ChatEngine {
275
275
  });
276
276
  }
277
277
  updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
278
+ dissolveGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.dissolveGroup(threadId, options))); }
278
279
  revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
279
280
  close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.threadSubscribers.clear(); this.openQueue.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
280
281
  }
@@ -45,7 +45,7 @@ export function createPublicChat(options) {
45
45
  },
46
46
  getVoiceAttachment: (id, messageId, options = {}) => read(() => engine.getVoiceAttachment(id, messageId), options, lease => lease.release()),
47
47
  getGroup: (id, options = {}) => read(() => engine.getGroup(id), options), refreshGroup: (id, options = {}) => read(() => engine.getGroup(id, { refresh: true }), options),
48
- createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes),
48
+ createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes), dissolveGroup: (id, options) => engine.dissolveGroup(id, options),
49
49
  });
50
50
  registerChat(chat, engine);
51
51
  return chat;
@@ -51,7 +51,7 @@ function content(message) {
51
51
  }
52
52
  else if (body.kind === "group") {
53
53
  result.kind = "system";
54
- result.system = { type: body.action === "groupCreate" ? "group-created" : "group-updated", title: text(object(body.payload).title) || undefined };
54
+ result.system = { type: body.action === "groupCreate" ? "group-created" : body.action === "groupDissolve" ? "group-dissolved" : "group-updated", title: text(object(body.payload).title) || undefined };
55
55
  }
56
56
  else if (file) {
57
57
  result.kind = "attachment";
@@ -123,6 +123,11 @@ export declare class ChatClient {
123
123
  title?: string;
124
124
  avatar?: string;
125
125
  }): Promise<ChatRuntimeMessage>;
126
+ dissolveGroup(input: {
127
+ groupId: string;
128
+ teamId?: string;
129
+ expectedRevision: string;
130
+ }): Promise<ChatRuntimeMessage>;
126
131
  sendGroup(input: {
127
132
  groupId: string;
128
133
  teamId?: string;
@@ -252,6 +252,12 @@ export class ChatClient {
252
252
  addMembers: input.addMembers ?? [], removeMembers: input.removeMembers ?? [], ...(input.title !== undefined ? { title: input.title } : {}), ...(input.avatar !== undefined ? { avatar: input.avatar } : {}) },
253
253
  });
254
254
  }
255
+ async dissolveGroup(input) {
256
+ return this.sendChat(input.groupId, {
257
+ kind: "group", threadKind: "group", action: "groupDissolve",
258
+ payload: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), expectedRevision: input.expectedRevision },
259
+ });
260
+ }
255
261
  async sendGroup(input) {
256
262
  return this.sendChat(input.groupId, {
257
263
  kind: "message",
@@ -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
  }
@@ -149,6 +149,9 @@ export declare class ChatSession<T extends StoredMessage> {
149
149
  title?: string;
150
150
  avatar?: string;
151
151
  }): Promise<ChatGroup>;
152
+ dissolveGroup(target: ChatConversation | string, options?: {
153
+ expectedRevision?: string;
154
+ }): Promise<ChatGroup>;
152
155
  sendMessage(threadId: string, input: OutgoingContent, options?: {
153
156
  waitFor?: "accepted" | "delivered";
154
157
  }): Promise<ChatRuntimeMessage>;
@@ -190,6 +193,8 @@ export declare class ChatSession<T extends StoredMessage> {
190
193
  private buildGlobal;
191
194
  private publishGlobal;
192
195
  private publish;
196
+ /** Withdraw one conversation without retiring sibling conversations. */
197
+ revokeThread(threadId: string): Promise<void>;
193
198
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
194
199
  * completion means all durable stores have crossed the same authority epoch. */
195
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) => {
@@ -403,6 +404,16 @@ export class ChatSession {
403
404
  await this.sendMessage(thread, { groupUpdate: { ...changes, expectedRevision: changes.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
404
405
  return this.groups.after(thread, previous.versionNumber + 1);
405
406
  }
407
+ async dissolveGroup(target, options = {}) {
408
+ const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
409
+ const previous = await this.getGroup(thread);
410
+ if (previous.status === "dissolved")
411
+ return previous;
412
+ if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
413
+ throw new Error("INVALID_GROUP_VERSION");
414
+ await this.sendMessage(thread, { groupDissolve: { expectedRevision: options.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
415
+ return this.groups.after(thread, previous.versionNumber + 1);
416
+ }
406
417
  sendMessage(thread, input, options = {}) {
407
418
  this.assertOpen();
408
419
  if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
@@ -434,8 +445,10 @@ export class ChatSession {
434
445
  await this.conversations.put(thread);
435
446
  this.assertOpen();
436
447
  // Recipients are SDK-owned metadata, never an App-supplied roster.
437
- if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
448
+ if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate && !input.groupDissolve) {
438
449
  const group = await this.getGroup(thread);
450
+ if (group.status === "dissolved")
451
+ throw new Error("GROUP_DISSOLVED");
439
452
  this.assertOpen();
440
453
  thread = { ...thread, memberIds: group.memberIds };
441
454
  }
@@ -824,6 +837,8 @@ export class ChatSession {
824
837
  if (notifyGlobal)
825
838
  this.publishGlobal();
826
839
  }
840
+ /** Withdraw one conversation without retiring sibling conversations. */
841
+ revokeThread(threadId) { return this.messages.revoke(threadId); }
827
842
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
828
843
  * completion means all durable stores have crossed the same authority epoch. */
829
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,
@@ -876,7 +876,7 @@ export class DurableChatSender {
876
876
  }
877
877
  }
878
878
  }
879
- function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
879
+ function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate" || body.action === "groupDissolve"); }
880
880
  function commandId(scope, version, threadId, message) {
881
881
  return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
882
882
  }
@@ -2,7 +2,7 @@ import { type ResponseCacheAccess } from "./responseCache.js";
2
2
  import { type MessageScope } from "./messageStore.js";
3
3
  import { type ChatConversation } from "./outgoingContent.js";
4
4
  import type { SocialGroup } from "./socialReadClient.js";
5
- export type ChatGroup = {
5
+ export type ActiveChatGroup = {
6
6
  groupId: string;
7
7
  teamId: string;
8
8
  ownerId: string;
@@ -11,7 +11,19 @@ export type ChatGroup = {
11
11
  memberIds: readonly string[];
12
12
  revision: string;
13
13
  versionNumber: number;
14
+ status: "active";
14
15
  };
16
+ export type DissolvedChatGroup = {
17
+ groupId: string;
18
+ teamId: string;
19
+ ownerId: string;
20
+ revision: string;
21
+ versionNumber: number;
22
+ status: "dissolved";
23
+ dissolvedBy?: string;
24
+ dissolvedAt?: number;
25
+ };
26
+ export type ChatGroup = ActiveChatGroup | DissolvedChatGroup;
15
27
  export type GroupSnapshot = {
16
28
  data?: ChatGroup;
17
29
  loading: boolean;
@@ -22,7 +34,7 @@ export type GroupClientOptions = {
22
34
  access: ResponseCacheAccess;
23
35
  load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
24
36
  onError: (error: unknown) => void;
25
- onDenied?: (error: unknown) => Promise<void> | void;
37
+ onDenied?: (error: unknown, threadId: string) => Promise<void> | void;
26
38
  indexedDB?: IDBFactory;
27
39
  name?: string;
28
40
  };
@@ -44,6 +56,7 @@ export declare class GroupClient {
44
56
  private queue;
45
57
  private controllers;
46
58
  private channel?;
59
+ private denied;
47
60
  private withdrawn;
48
61
  private closed;
49
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;
@@ -106,12 +107,19 @@ export class GroupClient {
106
107
  }
107
108
  normalize(value, entry) {
108
109
  const scope = this.options.scope;
109
- if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1 || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
110
+ if (!value || value.appId !== scope.appId || value.groupId !== entry.thread.targetId || value.teamId !== scope.teamId || !validMessageIdentifier(value.ownerId) || !validMessageIdentifier(value.revision) || !Number.isSafeInteger(value.versionNumber) || value.versionNumber < 1)
110
111
  throw new Error("INVALID_GROUP_METADATA");
111
- return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber });
112
+ if (value.status === "dissolved") {
113
+ if (value.dissolvedBy !== undefined && !validMessageIdentifier(value.dissolvedBy) || value.dissolvedAt !== undefined && (!Number.isSafeInteger(value.dissolvedAt) || value.dissolvedAt < 0))
114
+ throw new Error("INVALID_GROUP_METADATA");
115
+ return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, revision: value.revision, versionNumber: value.versionNumber, status: "dissolved", ...(value.dissolvedBy ? { dissolvedBy: value.dissolvedBy } : {}), ...(value.dissolvedAt === undefined ? {} : { dissolvedAt: value.dissolvedAt }) });
116
+ }
117
+ if (value.status !== undefined && value.status !== "active" || !Array.isArray(value.members) || !value.members.length || value.members.length > 256 || !value.members.every(validMessageIdentifier) || !value.members.includes(value.ownerId) || !value.members.includes(scope.profileId) || typeof value.title !== "string" || value.avatar !== undefined && value.avatar !== null && typeof value.avatar !== "string")
118
+ throw new Error("INVALID_GROUP_METADATA");
119
+ return Object.freeze({ groupId: value.groupId, teamId: scope.teamId, ownerId: value.ownerId, title: value.title, ...(value.avatar ? { avatar: value.avatar } : {}), memberIds: Object.freeze([...new Set(value.members)].sort()), revision: value.revision, versionNumber: value.versionNumber, status: "active" });
112
120
  }
113
121
  cached(value, entry) {
114
- return this.normalize({ appId: this.options.scope.appId, groupId: value?.groupId, teamId: value?.teamId, ownerId: value?.ownerId, title: value?.title, avatar: value?.avatar, members: value?.memberIds, revision: value?.revision, versionNumber: value?.versionNumber }, entry);
122
+ return this.normalize(value.status === "dissolved" ? { appId: this.options.scope.appId, ...value } : { appId: this.options.scope.appId, ...value, members: value.memberIds }, entry);
115
123
  }
116
124
  local(entry) {
117
125
  if (entry.loaded)
@@ -170,6 +178,8 @@ export class GroupClient {
170
178
  }
171
179
  async reload(entry) { entry.loaded = false; await this.local(entry); }
172
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 });
173
183
  const entry = this.entry(thread);
174
184
  await this.local(entry);
175
185
  await this.checkAuthority();
@@ -183,7 +193,7 @@ export class GroupClient {
183
193
  throw entry.snapshot.error ?? new Error("GROUP_METADATA_OFFLINE");
184
194
  return this.request(entry);
185
195
  }
186
- schedule(entry) { if (!this.closed && !this.withdrawn && !isOffline())
196
+ schedule(entry) { if (!this.closed && !this.withdrawn && !this.denied.has(entry.thread.id) && !isOffline())
187
197
  this.tasks.schedule(entry.thread.id, async () => { await this.get(entry.thread, { refresh: true }); }); }
188
198
  wake() { for (const entry of this.entries.values())
189
199
  this.schedule(entry); }
@@ -257,11 +267,16 @@ export class GroupClient {
257
267
  return value;
258
268
  }
259
269
  catch (error) {
270
+ if (error?.status === 403)
271
+ Object.assign(error, { threadId: entry.thread.id });
260
272
  if (!this.closed) {
261
273
  const denied = [401, 403].includes(error?.status ?? 0);
262
274
  this.publish(entry, { data: denied ? undefined : entry.snapshot.data, loading: false, error });
263
- if (denied)
264
- 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
+ }
265
280
  }
266
281
  throw error;
267
282
  }
@@ -73,6 +73,11 @@ type ContentChoices = {
73
73
  avatar?: string;
74
74
  };
75
75
  };
76
+ groupDissolve: {
77
+ groupDissolve: {
78
+ expectedRevision: string;
79
+ };
80
+ };
76
81
  };
77
82
  export type OutgoingContent = {
78
83
  [K in keyof ContentChoices]: ContentChoices[K] & {
@@ -1,6 +1,6 @@
1
1
  import { parseChatFile } from "../fileMetadata.js";
2
2
  import { dmThreadId, groupThreadId } from "./thread.js";
3
- const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
+ const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate", "groupDissolve"];
4
4
  export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
5
5
  function members(value) {
6
6
  if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
@@ -63,6 +63,11 @@ export function outgoingContent(scope, thread, input) {
63
63
  throw new Error("INVALID_GROUP_AVATAR");
64
64
  return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupUpdate", payload: { ...identity, groupId: thread.targetId, expectedRevision: update.expectedRevision, addMembers: add, removeMembers: remove, ...(update.title === undefined ? {} : { title: update.title.trim() }), ...(update.avatar === undefined ? {} : { avatar: update.avatar }) } } };
65
65
  }
66
+ if (input.groupDissolve) {
67
+ if (thread.kind !== "group" || !validMessageIdentifier(input.groupDissolve.expectedRevision))
68
+ throw new Error("INVALID_GROUP_DISSOLVE");
69
+ return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupDissolve", payload: { ...identity, groupId: thread.targetId, expectedRevision: input.groupDissolve.expectedRevision } } };
70
+ }
66
71
  if (thread.kind === "group" && !Array.isArray(thread.memberIds))
67
72
  throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
68
73
  const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
@@ -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) {
@@ -18,11 +18,14 @@ export type SocialGroup = {
18
18
  groupId: string;
19
19
  teamId?: string | null;
20
20
  ownerId: string;
21
- members: string[];
22
- title: string;
21
+ members?: readonly string[];
22
+ title?: string;
23
23
  avatar?: string | null;
24
24
  revision: string;
25
25
  versionNumber?: number;
26
+ status?: "active" | "dissolved";
27
+ dissolvedBy?: string;
28
+ dissolvedAt?: number;
26
29
  };
27
30
  export type SocialRelationship = {
28
31
  appId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.73",
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",