@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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
|
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:
|
|
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.
|
|
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.
|
|
104
|
-
markRead(threadId) { return this.
|
|
105
|
-
getReaders(threadId, messageId, page = {}, signal) { return this.
|
|
106
|
-
watchReaders(threadId, ids, receiver) { return this.
|
|
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.
|
|
109
|
-
getGroup(threadId, options = {}) { return this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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