@pmate/chat-sdk 0.12.0-next.62 → 0.12.0-next.64

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/README.md CHANGED
@@ -166,7 +166,9 @@ can leave the already trusted recent window usable. Cache hits do not repeat
166
166
  history requests. Reconnection and browser wake reconcile newer changes.
167
167
 
168
168
  A thread snapshot contains `threadId`, one renderable `messages` list, `ready`,
169
- `hasEarlier`, `unread`, `group`, `readCounts` and optional `error`. `ready` helps
169
+ `hasEarlier`, `unread`, `group`, `readCounts`, optional `conversation` target identity,
170
+ and optional `error`. Target identity survives recalls without exposing redacted
171
+ message bodies. `ready` helps
170
172
  route entry and startup before the first read; it is not a cache-source indicator.
171
173
  `getSnapshot()` and `subscribe(listener)` provide conversation summaries, aggregate
172
174
  unread counts and connection state. Unknown unread counts remain `undefined`.
@@ -213,7 +215,9 @@ selects receipt counts for the current viewport; clear with `watchReaders(undefi
213
215
  `ChatError` exposes `code`, `retryable`, optional HTTP `status`, and the original
214
216
  `cause`. Recovery categories are `unavailable`, `access-denied`,
215
217
  `storage-unavailable`, `invalid-input`, `conflict`, `closed`, and `busy`.
216
- Permission failures never turn into trusted offline data. Dispose and recreate
218
+ Permission failures withdraw the current scoped session and retire its caches;
219
+ failed cleanup remains in the error cause. They never turn into trusted offline
220
+ data. Dispose and recreate
217
221
  the SDK when account, profile, team or token changes.
218
222
 
219
223
  ### Advanced integration and migration
@@ -268,4 +272,5 @@ SDK group metadata uses a 200ms merge window, four concurrent requests, a bounde
268
272
  The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
269
273
  32-record write batches, conditional writes and bounded pruning. Conversation
270
274
  identities are stored separately without rosters; repeated registrations share
271
- one write and concurrent lookups use bulk reads. Scope withdrawal retires both.
275
+ one write, concurrent lookups use bulk reads, and a concurrent memory-hit burst
276
+ shares one fresh authority read without retaining authorization across bursts. Scope withdrawal retires both.
package/lib/chat.d.ts CHANGED
@@ -3,24 +3,34 @@ import type { ChatSession, ChatSummarySnapshot as SessionSummary, ChatThreadSnap
3
3
  import type { CachedChatMessage } from "./runtime/cachedMessage.js";
4
4
  import type { TimelineItem } from "./runtime/messageTimeline.js";
5
5
  import type { ConversationTarget } from "./runtime/conversationDirectory.js";
6
- import type { OutgoingContent } from "./runtime/outgoingContent.js";
6
+ import type { OutgoingContent as RuntimeContent } from "./runtime/outgoingContent.js";
7
7
  import { ChatError } from "./runtime/chatError.js";
8
8
  export { ChatError } from "./runtime/chatError.js";
9
9
  export type { ChatErrorCode } from "./runtime/chatError.js";
10
- export type { CreateChatOptions, ConversationTarget, OutgoingContent };
10
+ export type { CreateChatOptions, ConversationTarget };
11
+ export type OutgoingContent = Exclude<RuntimeContent, {
12
+ groupCreate: unknown;
13
+ } | {
14
+ groupUpdate: unknown;
15
+ }>;
11
16
  export type { ChatGroup } from "./runtime/groupClient.js";
12
- export type ChatMessage = TimelineItem<CachedChatMessage>;
13
- export type ChatThreadSnapshot = {
17
+ export type ChatMessage = Readonly<TimelineItem<CachedChatMessage>>;
18
+ export type ConversationInfo = Readonly<{
19
+ kind: "dm" | "group";
20
+ targetId: string;
21
+ }>;
22
+ export type ChatThreadSnapshot = Readonly<{
14
23
  threadId: string;
15
24
  messages: readonly ChatMessage[];
16
25
  ready: boolean;
17
26
  hasEarlier: boolean;
27
+ conversation?: ConversationInfo;
18
28
  unread: SessionThread<CachedChatMessage>["unread"];
19
29
  group: SessionThread<CachedChatMessage>["group"];
20
30
  readCounts: SessionThread<CachedChatMessage>["readCounts"];
21
31
  error?: ChatError;
22
- };
23
- export type ChatSnapshot = {
32
+ }>;
33
+ export type ChatSnapshot = Readonly<{
24
34
  threads: readonly {
25
35
  threadId: string;
26
36
  lastMessage?: ChatMessage;
@@ -31,7 +41,7 @@ export type ChatSnapshot = {
31
41
  unreadMessageCount: number | undefined;
32
42
  badgeCount: number | undefined;
33
43
  connection: SessionSummary<CachedChatMessage>["connection"];
34
- };
44
+ }>;
35
45
  export type MessageQuery = {
36
46
  earlier?: boolean;
37
47
  all?: boolean;
@@ -40,10 +50,10 @@ export type MessageQuery = {
40
50
  export type SendOptions = {
41
51
  waitFor?: "accepted" | "delivered";
42
52
  };
43
- export type SentMessage = {
53
+ export type SentMessage = Readonly<{
44
54
  id: string;
45
55
  timestamp: number;
46
- };
56
+ }>;
47
57
  type SubscribeOptions<T> = {
48
58
  fields?: readonly (keyof T)[];
49
59
  emitCurrent?: boolean;
@@ -54,11 +64,14 @@ export declare class Chat {
54
64
  private options;
55
65
  private views;
56
66
  private summary?;
67
+ private targets;
57
68
  private errors;
58
69
  private listeners;
59
70
  private closed;
60
71
  constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
61
72
  private operation;
73
+ private deny;
74
+ private threadOperation;
62
75
  start(): Promise<void>;
63
76
  getConversation(target: ConversationTarget): Promise<string>;
64
77
  getSnapshot(): ChatSnapshot;
package/lib/chat.js CHANGED
@@ -8,6 +8,7 @@ export class Chat {
8
8
  options;
9
9
  views = new Map();
10
10
  summary;
11
+ targets = new Map();
11
12
  errors = new Map();
12
13
  listeners = new Map();
13
14
  closed = false;
@@ -21,19 +22,63 @@ export class Chat {
21
22
  catch (error) {
22
23
  throw asChatError(error);
23
24
  } }
25
+ async deny(threadId, failure) {
26
+ this.errors.set(threadId, failure);
27
+ this.targets.delete(threadId);
28
+ const retiring = this.advanced.revokeAccess();
29
+ this.notify(threadId);
30
+ try {
31
+ await retiring;
32
+ }
33
+ catch (error) {
34
+ failure.cause = new AggregateError([failure.cause, error], "Access withdrawn; cache cleanup incomplete");
35
+ }
36
+ }
37
+ threadOperation(threadId, run) {
38
+ return this.operation(async () => {
39
+ const denied = threadId ? this.errors.get(threadId) : undefined;
40
+ if (denied?.code === "access-denied")
41
+ throw denied;
42
+ try {
43
+ return await run();
44
+ }
45
+ catch (error) {
46
+ const failure = asChatError(error);
47
+ if (threadId && failure.code === "access-denied")
48
+ await this.deny(threadId, failure);
49
+ throw failure;
50
+ }
51
+ });
52
+ }
24
53
  start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
25
- getConversation(target) { return this.operation(() => this.advanced.getConversation(target)); }
54
+ getConversation(target) {
55
+ return this.operation(async () => {
56
+ const id = await this.advanced.getConversation(target);
57
+ const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
58
+ this.targets.delete(id);
59
+ this.targets.set(id, value);
60
+ if (this.targets.size > 256)
61
+ this.targets.delete(this.targets.keys().next().value);
62
+ this.notify(id);
63
+ return id;
64
+ });
65
+ }
26
66
  getSnapshot(threadId) {
27
67
  if (threadId === undefined) {
28
68
  const source = this.advanced.getSnapshot(), previous = this.summary;
29
- const threads = source.threads.map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
30
- if (previous && previous.connection === source.connection && previous.badgeCount === source.badgeCount && previous.unreadMessageCount === source.unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
69
+ const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
70
+ const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
71
+ const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
72
+ if (previous && previous.connection === source.connection && previous.badgeCount === badgeCount && previous.unreadMessageCount === unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
31
73
  return previous;
32
- return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
74
+ return this.summary = { threads, connection: source.connection, badgeCount, unreadMessageCount };
33
75
  }
34
76
  const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
77
+ const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
78
+ const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
79
+ const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
35
80
  // Partial migrated rows never masquerade as a trusted recent window.
36
- const next = { threadId, messages: source.complete ? source.timeline : emptyMessages, ready: source.complete, hasEarlier: source.hasEarlier, unread: source.unread, group: source.group, readCounts: source.readCounts, error: this.closed ? undefined : this.errors.get(threadId) };
81
+ const next = { threadId, conversation, messages: !denied && source.complete ? source.timeline : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : source.group, readCounts: denied ? undefined : source.readCounts, error };
37
82
  if (previous && Object.keys(next).every(key => next[key] === previous[key]))
38
83
  return previous;
39
84
  this.views.set(threadId, next);
@@ -53,14 +98,23 @@ export class Chat {
53
98
  catch (error) {
54
99
  const failure = asChatError(error);
55
100
  if (!this.closed) {
56
- this.errors.set(threadId, failure);
57
- this.notify(threadId);
101
+ if (failure.code === "access-denied")
102
+ await this.deny(threadId, failure);
103
+ else {
104
+ this.errors.set(threadId, failure);
105
+ this.notify(threadId);
106
+ }
58
107
  }
59
108
  throw failure;
60
109
  }
61
110
  }
62
111
  sendMessage(threadId, content, options = {}) {
63
- return this.operation(async () => { const result = await this.advanced.sendMessage(threadId, content, options); return { id: result.hash, timestamp: result.t }; });
112
+ return this.threadOperation(threadId, async () => {
113
+ if (content && ("groupCreate" in content || "groupUpdate" in content))
114
+ throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
115
+ const result = await this.advanced.sendMessage(threadId, content, options);
116
+ return { id: result.hash, timestamp: result.t };
117
+ });
64
118
  }
65
119
  retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
66
120
  subscribe(target, callback, options = {}) {
@@ -97,16 +151,16 @@ export class Chat {
97
151
  return dispose;
98
152
  }
99
153
  sessionFields(fields) {
100
- return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
154
+ return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
101
155
  }
102
- notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); }
103
- observeMessages(threadId, ids, event) { return this.operation(() => this.advanced.observeMessages(threadId, ids, event)); }
104
- markRead(threadId) { return this.operation(() => this.advanced.markRead(threadId)); }
105
- getReaders(threadId, messageId, page = {}, signal) { return this.operation(() => this.advanced.getReaders(threadId, messageId, page, signal)); }
106
- watchReaders(threadId, ids, receiver) { return this.operation(() => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
156
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
157
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, event)); }
158
+ markRead(threadId) { return this.threadOperation(threadId, () => this.advanced.markRead(threadId)); }
159
+ getReaders(threadId, messageId, page = {}, signal) { return this.threadOperation(threadId, () => this.advanced.getReaders(threadId, messageId, page, signal)); }
160
+ watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
107
161
  handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
108
- getVoiceAttachment(threadId, messageId) { return this.operation(() => this.advanced.getVoiceAttachment(threadId, messageId)); }
109
- getGroup(threadId, options = {}) { return this.operation(() => this.advanced.getGroup(threadId, options)); }
162
+ getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
163
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, () => this.advanced.getGroup(threadId, options)); }
110
164
  createGroup(input) {
111
165
  return this.operation(async () => {
112
166
  const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
@@ -114,9 +168,9 @@ export class Chat {
114
168
  return { ...group, threadId };
115
169
  });
116
170
  }
117
- updateGroup(threadId, changes) { return this.operation(() => this.advanced.updateGroup(threadId, changes)); }
171
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, () => this.advanced.updateGroup(threadId, changes)); }
118
172
  revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
119
- close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
173
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
120
174
  }
121
175
  export function createChat(options) {
122
176
  const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
@@ -161,6 +161,8 @@ export declare class ChatSession<T extends StoredMessage> {
161
161
  getConversation(target: ConversationTarget): Promise<string>;
162
162
  private sendContent;
163
163
  private resolveConversation;
164
+ /** Validated target identity survives content redaction; no roster is exposed. */
165
+ getConversationTarget(threadId: string): ChatConversation | undefined;
164
166
  private sendControl;
165
167
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
166
168
  /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
@@ -190,6 +192,7 @@ export declare class ChatSession<T extends StoredMessage> {
190
192
  /** Permanently retire this identity/team session. UI withdrawal is immediate;
191
193
  * completion means all durable stores have crossed the same authority epoch. */
192
194
  revokeAccess(): Promise<void>;
195
+ get active(): boolean;
193
196
  close(): Promise<void>;
194
197
  private dispose;
195
198
  }
@@ -436,6 +436,13 @@ export class ChatSession {
436
436
  const known = await this.conversations.get(threadId);
437
437
  if (known)
438
438
  return known;
439
+ const inferred = this.getConversationTarget(threadId);
440
+ if (!inferred)
441
+ throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
442
+ return inferred;
443
+ }
444
+ /** Validated target identity survives content redaction; no roster is exposed. */
445
+ getConversationTarget(threadId) {
439
446
  const scope = this.options.messages.scope;
440
447
  const group = this.getSnapshot(threadId).group;
441
448
  if (group)
@@ -453,7 +460,7 @@ export class ChatSession {
453
460
  }
454
461
  catch { }
455
462
  }
456
- throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
463
+ return undefined;
457
464
  }
458
465
  sendControl(thread, wire, delivered = false) {
459
466
  const content = wire.body.content;
@@ -857,6 +864,7 @@ export class ChatSession {
857
864
  throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
858
865
  });
859
866
  }
867
+ get active() { return !this.closed && !this.withdrawn; }
860
868
  close() { return this.revoking ?? this.dispose(); }
861
869
  dispose() {
862
870
  return this.closing ??= (async () => {
@@ -18,10 +18,12 @@ export declare class ConversationDirectory {
18
18
  private queue;
19
19
  private writes;
20
20
  private version;
21
+ private authority;
21
22
  private scheduled;
22
23
  private closed;
23
24
  private retired;
24
25
  constructor(scope: MessageScope, access: ResponseCacheAccess);
26
+ private authorize;
25
27
  private assertOpen;
26
28
  private remember;
27
29
  create(target: ConversationTarget): Promise<string>;
@@ -12,14 +12,16 @@ export class ConversationDirectory {
12
12
  queue = [];
13
13
  writes = new Map();
14
14
  version;
15
+ authority;
15
16
  scheduled = false;
16
17
  closed = false;
17
18
  retired = false;
18
19
  constructor(scope, access) {
19
20
  this.scope = scope;
20
21
  this.access = access;
21
- this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access, entries: 256, recordBytes: 4096 });
22
+ this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096 });
22
23
  }
24
+ authorize() { return this.authority ??= this.access().finally(() => { this.authority = undefined; }); }
23
25
  assertOpen() { if (this.retired)
24
26
  throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
25
27
  throw new Error("CHAT_SESSION_CLOSED"); }
@@ -67,7 +69,7 @@ export class ConversationDirectory {
67
69
  }
68
70
  async get(id) {
69
71
  this.assertOpen();
70
- const authority = await this.access();
72
+ const authority = await this.authorize();
71
73
  this.assertOpen();
72
74
  if (!authority.allowed)
73
75
  throw new Error("CHAT_ACCESS_REVOKED");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.62",
3
+ "version": "0.12.0-next.64",
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",