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

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`.
@@ -268,4 +270,5 @@ SDK group metadata uses a 200ms merge window, four concurrent requests, a bounde
268
270
  The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
269
271
  32-record write batches, conditional writes and bounded pruning. Conversation
270
272
  identities are stored separately without rosters; repeated registrations share
271
- one write and concurrent lookups use bulk reads. Scope withdrawal retires both.
273
+ one write, concurrent lookups use bulk reads, and a concurrent memory-hit burst
274
+ 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,13 @@ 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 threadOperation;
62
74
  start(): Promise<void>;
63
75
  getConversation(target: ConversationTarget): Promise<string>;
64
76
  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,8 +22,38 @@ export class Chat {
21
22
  catch (error) {
22
23
  throw asChatError(error);
23
24
  } }
25
+ threadOperation(threadId, run) {
26
+ return this.operation(async () => {
27
+ const denied = threadId ? this.errors.get(threadId) : undefined;
28
+ if (denied?.code === "access-denied")
29
+ throw denied;
30
+ try {
31
+ return await run();
32
+ }
33
+ catch (error) {
34
+ const failure = asChatError(error);
35
+ if (threadId && failure.code === "access-denied") {
36
+ this.errors.set(threadId, failure);
37
+ this.targets.delete(threadId);
38
+ this.notify(threadId);
39
+ }
40
+ throw failure;
41
+ }
42
+ });
43
+ }
24
44
  start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
25
- getConversation(target) { return this.operation(() => this.advanced.getConversation(target)); }
45
+ getConversation(target) {
46
+ return this.operation(async () => {
47
+ const id = await this.advanced.getConversation(target);
48
+ const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
49
+ this.targets.delete(id);
50
+ this.targets.set(id, value);
51
+ if (this.targets.size > 256)
52
+ this.targets.delete(this.targets.keys().next().value);
53
+ this.notify(id);
54
+ return id;
55
+ });
56
+ }
26
57
  getSnapshot(threadId) {
27
58
  if (threadId === undefined) {
28
59
  const source = this.advanced.getSnapshot(), previous = this.summary;
@@ -32,8 +63,11 @@ export class Chat {
32
63
  return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
33
64
  }
34
65
  const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
66
+ const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
67
+ const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
68
+ const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
35
69
  // 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) };
70
+ 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
71
  if (previous && Object.keys(next).every(key => next[key] === previous[key]))
38
72
  return previous;
39
73
  this.views.set(threadId, next);
@@ -60,7 +94,12 @@ export class Chat {
60
94
  }
61
95
  }
62
96
  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 }; });
97
+ return this.threadOperation(threadId, async () => {
98
+ if (content && ("groupCreate" in content || "groupUpdate" in content))
99
+ throw new ChatError("invalid-input", "Use createGroup or updateGroup for group changes");
100
+ const result = await this.advanced.sendMessage(threadId, content, options);
101
+ return { id: result.hash, timestamp: result.t };
102
+ });
64
103
  }
65
104
  retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
66
105
  subscribe(target, callback, options = {}) {
@@ -97,16 +136,16 @@ export class Chat {
97
136
  return dispose;
98
137
  }
99
138
  sessionFields(fields) {
100
- return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
139
+ 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
140
  }
102
141
  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)); }
142
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, event)); }
143
+ markRead(threadId) { return this.threadOperation(threadId, () => this.advanced.markRead(threadId)); }
144
+ getReaders(threadId, messageId, page = {}, signal) { return this.threadOperation(threadId, () => this.advanced.getReaders(threadId, messageId, page, signal)); }
145
+ watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
107
146
  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)); }
147
+ getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
148
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, () => this.advanced.getGroup(threadId, options)); }
110
149
  createGroup(input) {
111
150
  return this.operation(async () => {
112
151
  const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
@@ -114,9 +153,9 @@ export class Chat {
114
153
  return { ...group, threadId };
115
154
  });
116
155
  }
117
- updateGroup(threadId, changes) { return this.operation(() => this.advanced.updateGroup(threadId, changes)); }
156
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, () => this.advanced.updateGroup(threadId, changes)); }
118
157
  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; }); }
158
+ 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
159
  }
121
160
  export function createChat(options) {
122
161
  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.63",
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",