@pmate/chat-sdk 0.12.0-next.59 → 0.12.0-next.60
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 +28 -0
- package/lib/runtime/chatSession.d.ts +32 -2
- package/lib/runtime/chatSession.js +109 -11
- package/lib/runtime/createChat.js +1 -0
- package/lib/runtime/durableSender.d.ts +3 -1
- package/lib/runtime/durableSender.js +38 -9
- package/lib/runtime/groupClient.d.ts +76 -0
- package/lib/runtime/groupClient.js +325 -0
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/outboxSchema.js +1 -0
- package/lib/runtime/outgoingContent.d.ts +63 -25
- package/lib/runtime/outgoingContent.js +57 -9
- package/lib/runtime/responseCache.d.ts +19 -6
- package/lib/runtime/responseCache.js +86 -29
- package/lib/runtime/socialReadClient.d.ts +2 -1
- package/lib/runtime/socialReadClient.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -280,3 +280,31 @@ schema version 1; applications do not select storage formats or recovery steps.
|
|
|
280
280
|
Pending voice snapshots contain `voiceAttachment` metadata, without loading audio bytes or creating Blob URLs at startup. For playback or transcription, acquire media through `await chat.getVoiceAttachment(threadId, messageId)`, use the returned `url`, and call `release()` when the consumer stops. The same method returns a remote voice reference for delivered messages. Attachment reads are charged before allocation, and close/revocation releases local URLs. A pending voice remains unread until the player actually starts; acquiring media does not mark it read.
|
|
281
281
|
|
|
282
282
|
The outbox format upgrade renames the old canonical store without copying binary data in the schema transaction, then atomically separates inline payloads in bounded, resumable transactions. An already-open older application must reload; unsupported senders reject the new format. Original wire/hash, failed status and retry semantics are retained.
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
Control messages use the same send API:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
await chat.sendMessage(conversation, {recall: {messageId}})
|
|
289
|
+
await chat.sendMessage(conversation, {reaction: {messageId, emojiId, active: true}})
|
|
290
|
+
await chat.sendMessage(conversation, {invitation: {message: "Hello"}})
|
|
291
|
+
await chat.sendMessage(group, {groupCreate: {title, members}})
|
|
292
|
+
await chat.sendMessage(group, {groupUpdate: {expectedRevision, addMembers}})
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
These calls retain durable-acceptance semantics. Recall and reaction validate the target against the current scoped timeline or canonical local cache; the App does not supply protocol bodies or reaction versions. Reaction counters and exact wires commit in one IndexedDB transaction, including across documents. Validation ordering does not wait for each preceding commit, so a same-key burst still reaches the bounded acceptance batches. Pass `{waitFor:"delivered"}` as the third argument when an action requires server confirmation; the default still resolves after durable acceptance.
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
Group metadata uses the same identity, cache and subscriptions:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
const group = await chat.getGroup(conversation)
|
|
302
|
+
const off = chat.subscribe(conversation.id, () => {
|
|
303
|
+
renderGroup(chat.getSnapshot(conversation.id).group)
|
|
304
|
+
}, ["group"])
|
|
305
|
+
await chat.createGroup(conversation, {title, members})
|
|
306
|
+
await chat.updateGroup(conversation, {title: "New title"})
|
|
307
|
+
off()
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Reads restore trusted IndexedDB metadata before background revalidation. Creation and updates wait for confirmed delivery and a causal server version, including newer concurrent revisions; Apps do not poll for an exact message hash. Group events and browser recovery share a 200ms window. At most four metadata requests run concurrently, with 128 queued requests, a five-second request timeout, and at most five read-after-write attempts. Per-scope persistent metadata is capped at 64 groups / 1MiB, with 128KiB records, 32-record write batches, conditional cross-document writes and bounded pruning. Cached metadata does not bypass scope withdrawal or server membership checks. Binary media remains on demand.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ChatGroup } from "./groupClient.js";
|
|
2
|
+
import type { SocialGroup } from "./socialReadClient.js";
|
|
1
3
|
import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
|
|
2
4
|
import { type TimelineItem } from "./messageTimeline.js";
|
|
3
5
|
import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
|
|
@@ -19,6 +21,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
19
21
|
activeThreadId?: string;
|
|
20
22
|
uploadVoice?: DurableSenderOptions["uploadVoice"];
|
|
21
23
|
reconcileOutgoing?: DurableSenderOptions["reconcile"];
|
|
24
|
+
loadGroup?: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
|
|
22
25
|
reading: ReadingClientOptions;
|
|
23
26
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
24
27
|
readingCandidate?: (message: T) => ReadingCandidate | undefined;
|
|
@@ -31,6 +34,7 @@ export type ChatThreadSnapshot<T> = {
|
|
|
31
34
|
hasEarlier: boolean;
|
|
32
35
|
resetVersion: number;
|
|
33
36
|
complete: boolean;
|
|
37
|
+
group?: ChatGroup;
|
|
34
38
|
readCounts?: ReadCountsResult;
|
|
35
39
|
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
36
40
|
messageReading: Readonly<Record<string, MessageReadingState>>;
|
|
@@ -40,6 +44,7 @@ export type ChatSummarySnapshot<T> = {
|
|
|
40
44
|
threadId: string;
|
|
41
45
|
lastMessage?: T;
|
|
42
46
|
lastItem?: TimelineItem<T>;
|
|
47
|
+
group?: ChatGroup;
|
|
43
48
|
unread: ChatThreadSnapshot<T>["unread"];
|
|
44
49
|
outgoingCount: number;
|
|
45
50
|
}[];
|
|
@@ -54,12 +59,17 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
54
59
|
private connection;
|
|
55
60
|
private reading;
|
|
56
61
|
private readers;
|
|
62
|
+
private groups?;
|
|
57
63
|
private counts?;
|
|
58
64
|
private countsReceiver?;
|
|
59
65
|
private countsThread?;
|
|
60
66
|
private countSnapshots;
|
|
61
67
|
private countClosings;
|
|
62
68
|
private countTasks;
|
|
69
|
+
private controlTails;
|
|
70
|
+
private acceptedControls;
|
|
71
|
+
private controlCount;
|
|
72
|
+
private controlBytes;
|
|
63
73
|
private sender;
|
|
64
74
|
private listeners;
|
|
65
75
|
private snapshots;
|
|
@@ -121,8 +131,28 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
121
131
|
private rememberHistoryEdge;
|
|
122
132
|
private readEarlier;
|
|
123
133
|
private readAll;
|
|
124
|
-
|
|
125
|
-
|
|
134
|
+
/** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
|
|
135
|
+
getGroup(thread: ChatConversation, options?: {
|
|
136
|
+
refresh?: boolean;
|
|
137
|
+
}): Promise<ChatGroup>;
|
|
138
|
+
createGroup(thread: ChatConversation, content: {
|
|
139
|
+
title: string;
|
|
140
|
+
members: readonly string[];
|
|
141
|
+
}): Promise<ChatGroup>;
|
|
142
|
+
updateGroup(thread: ChatConversation, changes: {
|
|
143
|
+
expectedRevision?: string;
|
|
144
|
+
addMembers?: readonly string[];
|
|
145
|
+
removeMembers?: readonly string[];
|
|
146
|
+
title?: string;
|
|
147
|
+
avatar?: string;
|
|
148
|
+
}): Promise<ChatGroup>;
|
|
149
|
+
sendMessage(thread: ChatConversation, input: OutgoingContent, options?: {
|
|
150
|
+
waitFor?: "accepted" | "delivered";
|
|
151
|
+
}): Promise<ChatRuntimeMessage>;
|
|
152
|
+
sendMessage(threadId: string, input: SendMessageInput, options?: {
|
|
153
|
+
waitFor?: "accepted" | "delivered";
|
|
154
|
+
}): Promise<ChatRuntimeMessage>;
|
|
155
|
+
private sendControl;
|
|
126
156
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
127
157
|
/** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
|
|
128
158
|
getVoiceAttachment(threadId: string, messageId: string): Promise<VoiceAttachmentLease>;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { groupThreadId } from "./thread.js";
|
|
2
|
+
import { GroupClient } from "./groupClient.js";
|
|
3
|
+
import { isReadingControl } from "./readingContent.js";
|
|
1
4
|
import { outgoingContent } from "./outgoingContent.js";
|
|
2
5
|
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
3
6
|
import { projectConversationReading } from "./conversationReading.js";
|
|
@@ -16,12 +19,17 @@ export class ChatSession {
|
|
|
16
19
|
connection;
|
|
17
20
|
reading;
|
|
18
21
|
readers;
|
|
22
|
+
groups;
|
|
19
23
|
counts;
|
|
20
24
|
countsReceiver;
|
|
21
25
|
countsThread;
|
|
22
26
|
countSnapshots = new Map();
|
|
23
27
|
countClosings = new Set();
|
|
24
28
|
countTasks;
|
|
29
|
+
controlTails = new Map();
|
|
30
|
+
acceptedControls = new Set();
|
|
31
|
+
controlCount = 0;
|
|
32
|
+
controlBytes = 0;
|
|
25
33
|
sender;
|
|
26
34
|
listeners = new Map();
|
|
27
35
|
snapshots = new Map();
|
|
@@ -70,6 +78,10 @@ export class ChatSession {
|
|
|
70
78
|
} });
|
|
71
79
|
this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
|
|
72
80
|
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 });
|
|
81
|
+
if (options.loadGroup) {
|
|
82
|
+
this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: () => { void this.revokeAccess().catch(error => options.onError(error)); } });
|
|
83
|
+
this.off.push(this.groups.subscribe(thread => this.publish(thread)));
|
|
84
|
+
}
|
|
73
85
|
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
|
|
74
86
|
const cached = await this.messages.cachedMessage(record.threadId, record.message.hash);
|
|
75
87
|
if (cached && cached.from === scope.profileId && cached.to === record.message.to)
|
|
@@ -94,6 +106,7 @@ export class ChatSession {
|
|
|
94
106
|
if (typeof document !== "undefined" && document.hidden)
|
|
95
107
|
return;
|
|
96
108
|
this.wakeCounts();
|
|
109
|
+
this.groups?.wake();
|
|
97
110
|
this.scheduleCatchUp();
|
|
98
111
|
};
|
|
99
112
|
for (const type of ["online", "focus", "pageshow"])
|
|
@@ -114,6 +127,10 @@ export class ChatSession {
|
|
|
114
127
|
this.readingListeners.forEach(listener => listener()); }));
|
|
115
128
|
this.off.push(this.connection.watchMessages(message => {
|
|
116
129
|
try {
|
|
130
|
+
const body = message.body;
|
|
131
|
+
if (body?.kind === "group" && body.payload?.appId === scope.appId && body.payload.teamId === scope.teamId && body.payload.groupId === message.to) {
|
|
132
|
+
this.groups?.hint({ id: groupThreadId(scope.appId, message.to, scope.teamId), kind: "group", targetId: message.to, teamId: scope.teamId }, Number.isSafeInteger(body.payload.versionNumber) ? body.payload.versionNumber : undefined);
|
|
133
|
+
}
|
|
117
134
|
const decoded = options.decode(message);
|
|
118
135
|
if (decoded)
|
|
119
136
|
void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
|
|
@@ -138,6 +155,7 @@ export class ChatSession {
|
|
|
138
155
|
if (this.readingStarted)
|
|
139
156
|
this.reading.refresh();
|
|
140
157
|
this.wakeCounts();
|
|
158
|
+
this.groups?.wake();
|
|
141
159
|
}
|
|
142
160
|
}));
|
|
143
161
|
}
|
|
@@ -360,13 +378,89 @@ export class ChatSession {
|
|
|
360
378
|
}
|
|
361
379
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
362
380
|
}
|
|
363
|
-
|
|
381
|
+
/** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
|
|
382
|
+
getGroup(thread, options = {}) {
|
|
383
|
+
this.assertOpen();
|
|
384
|
+
if (!this.groups)
|
|
385
|
+
throw new Error("GROUP_METADATA_UNAVAILABLE");
|
|
386
|
+
return this.groups.get(thread, options);
|
|
387
|
+
}
|
|
388
|
+
async createGroup(thread, content) {
|
|
389
|
+
this.assertOpen();
|
|
390
|
+
if (!this.groups)
|
|
391
|
+
throw new Error("GROUP_METADATA_UNAVAILABLE");
|
|
392
|
+
await this.sendMessage(thread, { groupCreate: content }, { waitFor: "delivered" });
|
|
393
|
+
return this.groups.after(thread, 1);
|
|
394
|
+
}
|
|
395
|
+
async updateGroup(thread, changes) {
|
|
396
|
+
const previous = await this.getGroup(thread);
|
|
397
|
+
if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
|
|
398
|
+
throw new Error("INVALID_GROUP_VERSION");
|
|
399
|
+
await this.sendMessage(thread, { groupUpdate: { ...changes, expectedRevision: changes.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
|
|
400
|
+
return this.groups.after(thread, previous.versionNumber + 1);
|
|
401
|
+
}
|
|
402
|
+
sendMessage(thread, input, options = {}) {
|
|
364
403
|
this.assertOpen();
|
|
404
|
+
if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
|
|
405
|
+
throw new Error("INVALID_MESSAGE_WAIT");
|
|
365
406
|
const threadId = typeof thread === "string" ? thread : thread.id;
|
|
366
407
|
const wire = typeof thread === "string" ? input : outgoingContent(this.options.messages.scope, thread, input);
|
|
367
|
-
|
|
408
|
+
const delivered = options.waitFor === "delivered";
|
|
409
|
+
if (typeof thread !== "string" && (input.recall || input.reaction))
|
|
410
|
+
return this.sendControl({ id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: thread.teamId, memberIds: wire.recipients }, wire, delivered);
|
|
411
|
+
return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
|
|
368
412
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
369
413
|
}
|
|
414
|
+
sendControl(thread, wire, delivered = false) {
|
|
415
|
+
const content = wire.body.content;
|
|
416
|
+
const recall = content.recall, reaction = content.reaction;
|
|
417
|
+
const id = recall?.messageId ?? reaction.messageId, key = JSON.stringify([thread.id, id, reaction?.emojiId ?? "recall"]);
|
|
418
|
+
const bytes = JSON.stringify(wire).length * 3 + 1024;
|
|
419
|
+
if (this.controlCount >= 4096 || this.controlBytes + bytes > 64 * 1024 * 1024)
|
|
420
|
+
return Promise.reject(new Error("MESSAGE_CONTROL_BACKPRESSURE"));
|
|
421
|
+
this.controlCount++;
|
|
422
|
+
this.controlBytes += bytes;
|
|
423
|
+
const previous = this.controlTails.get(key) ?? Promise.resolve();
|
|
424
|
+
let delivery;
|
|
425
|
+
const validated = previous.catch(() => { }).then(async () => {
|
|
426
|
+
this.assertOpen();
|
|
427
|
+
const item = this.getSnapshot(thread.id).timeline.find(item => item.id === id);
|
|
428
|
+
const target = (item?.message ?? await this.messages.cachedMessage(thread.id, id));
|
|
429
|
+
this.assertOpen();
|
|
430
|
+
const scope = this.options.messages.scope, from = item?.from ?? target?.from, body = target?.body;
|
|
431
|
+
const wrongScope = target?.threadId !== thread.id || target.appId !== undefined && target.appId !== scope.appId || body?.content?.appId !== undefined && body.content.appId !== scope.appId || body?.threadKind !== undefined && body.threadKind !== thread.kind;
|
|
432
|
+
const unavailable = !target || !from || id.startsWith("local-") || target.recalled || item?.recalled || target.legacyDisplay?.kind === "system" || item?.status === "failed" || item?.status === "sending";
|
|
433
|
+
const ordinary = body && ["message", "quote"].includes(String(body.kind)) && !isReadingControl(body, scope.appId);
|
|
434
|
+
if (wrongScope || unavailable || !ordinary)
|
|
435
|
+
throw new Error("MESSAGE_CONTROL_TARGET_UNAVAILABLE");
|
|
436
|
+
if (recall && from !== scope.profileId)
|
|
437
|
+
throw new Error("MESSAGE_RECALL_NOT_OWNED");
|
|
438
|
+
if (thread.kind === "dm" && from !== scope.profileId && from !== thread.targetId || thread.kind === "group" && (!wire.recipients?.includes(scope.profileId) || !wire.recipients.includes(from)))
|
|
439
|
+
throw new Error("MESSAGE_CONTROL_TARGET_UNAVAILABLE");
|
|
440
|
+
if (reaction) {
|
|
441
|
+
let floor = 0;
|
|
442
|
+
const events = item?.reactionEvents ?? target.reactionEvents ?? target.legacyDisplay?.reactionEvents ?? [];
|
|
443
|
+
for (const event of events)
|
|
444
|
+
if (event.from === scope.profileId && event.messageId === id && event.emojiId === reaction.emojiId && Number.isSafeInteger(event.revision) && event.revision > floor)
|
|
445
|
+
floor = event.revision;
|
|
446
|
+
for (const record of this.sender.snapshot(thread.id)) {
|
|
447
|
+
const envelope = record.message.body, event = envelope?.content?.reaction;
|
|
448
|
+
if (record.message.from === scope.profileId && envelope?.content?.appId === scope.appId && event?.messageId === id && event.emojiId === reaction.emojiId && Number.isSafeInteger(event.revision) && event.revision > floor)
|
|
449
|
+
floor = event.revision;
|
|
450
|
+
}
|
|
451
|
+
wire = { ...wire, reactionRevisionFloor: floor };
|
|
452
|
+
}
|
|
453
|
+
delivery = delivered ? this.sender.send(thread.id, wire) : this.sender.enqueue(thread.id, wire);
|
|
454
|
+
void delivery.catch(() => { });
|
|
455
|
+
});
|
|
456
|
+
const tail = validated.finally(() => { if (this.controlTails.get(key) === tail)
|
|
457
|
+
this.controlTails.delete(key); });
|
|
458
|
+
const task = tail.then(() => delivery).then(message => { if (this.withdrawn)
|
|
459
|
+
throw new Error("CHAT_ACCESS_REVOKED"); return message; }).finally(() => { this.controlCount--; this.controlBytes -= bytes; this.acceptedControls.delete(task); });
|
|
460
|
+
this.controlTails.set(key, tail);
|
|
461
|
+
this.acceptedControls.add(task);
|
|
462
|
+
return task;
|
|
463
|
+
}
|
|
370
464
|
async getReaders(threadId, messageId, page = {}, signal) {
|
|
371
465
|
this.assertOpen();
|
|
372
466
|
const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
|
|
@@ -546,7 +640,7 @@ export class ChatSession {
|
|
|
546
640
|
const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
|
|
547
641
|
const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
|
|
548
642
|
const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
|
|
549
|
-
return { timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
|
|
643
|
+
return { group: this.groups?.snapshot(threadId).data, timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
|
|
550
644
|
}
|
|
551
645
|
subscribe(target, listener, fields) {
|
|
552
646
|
if (this.closed || this.withdrawn)
|
|
@@ -572,9 +666,10 @@ export class ChatSession {
|
|
|
572
666
|
this.listeners.set(threadId, listeners);
|
|
573
667
|
}
|
|
574
668
|
listeners.add(notify);
|
|
575
|
-
const off = this.messages.subscribe(threadId, () => { });
|
|
669
|
+
const off = this.messages.subscribe(threadId, () => { }), unpin = this.groups?.pin(threadId);
|
|
576
670
|
return () => {
|
|
577
671
|
off();
|
|
672
|
+
unpin?.();
|
|
578
673
|
listeners.delete(notify);
|
|
579
674
|
if (!listeners.size)
|
|
580
675
|
this.listeners.delete(threadId);
|
|
@@ -596,7 +691,7 @@ export class ChatSession {
|
|
|
596
691
|
ids.add(outgoing.threadId);
|
|
597
692
|
const threads = [...ids].sort().map(threadId => {
|
|
598
693
|
const snapshot = this.getSnapshot(threadId);
|
|
599
|
-
return { threadId, lastMessage: snapshot.timeline.at(-1)?.message, lastItem: snapshot.timeline.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
694
|
+
return { threadId, group: snapshot.group, lastMessage: snapshot.timeline.at(-1)?.message, lastItem: snapshot.timeline.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
600
695
|
});
|
|
601
696
|
const state = this.getReadingSnapshot();
|
|
602
697
|
const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
|
|
@@ -609,7 +704,7 @@ export class ChatSession {
|
|
|
609
704
|
const previous = this.globalSnapshot;
|
|
610
705
|
if (previous && previous.connection === next.connection && previous.unreadMessageCount === next.unreadMessageCount && previous.badgeCount === next.badgeCount && previous.threads.length === next.threads.length && previous.threads.every((thread, index) => {
|
|
611
706
|
const other = next.threads[index];
|
|
612
|
-
return thread.threadId === other.threadId && thread.lastMessage === other.lastMessage && thread.lastItem === other.lastItem && thread.outgoingCount === other.outgoingCount && JSON.stringify(thread.unread) === JSON.stringify(other.unread);
|
|
707
|
+
return thread.threadId === other.threadId && thread.group === other.group && thread.lastMessage === other.lastMessage && thread.lastItem === other.lastItem && thread.outgoingCount === other.outgoingCount && JSON.stringify(thread.unread) === JSON.stringify(other.unread);
|
|
613
708
|
}))
|
|
614
709
|
return;
|
|
615
710
|
this.globalSnapshot = next;
|
|
@@ -620,7 +715,7 @@ export class ChatSession {
|
|
|
620
715
|
this.snapshots.delete(threadId);
|
|
621
716
|
const next = this.buildThreadSnapshot(threadId, previous);
|
|
622
717
|
this.snapshots.set(threadId, next);
|
|
623
|
-
if (previous && previous.messages === next.messages && previous.timeline === next.timeline && previous.outgoing.length === next.outgoing.length && previous.outgoing.every((record, index) => record === next.outgoing[index]) && previous.hasEarlier === next.hasEarlier && previous.resetVersion === next.resetVersion && previous.complete === next.complete && previous.readCounts === next.readCounts && JSON.stringify(previous.unread) === JSON.stringify(next.unread) && JSON.stringify(previous.messageReading) === JSON.stringify(next.messageReading)) {
|
|
718
|
+
if (previous && previous.group === next.group && previous.messages === next.messages && previous.timeline === next.timeline && previous.outgoing.length === next.outgoing.length && previous.outgoing.every((record, index) => record === next.outgoing[index]) && previous.hasEarlier === next.hasEarlier && previous.resetVersion === next.resetVersion && previous.complete === next.complete && previous.readCounts === next.readCounts && JSON.stringify(previous.unread) === JSON.stringify(next.unread) && JSON.stringify(previous.messageReading) === JSON.stringify(next.messageReading)) {
|
|
624
719
|
this.snapshots.set(threadId, previous);
|
|
625
720
|
return;
|
|
626
721
|
}
|
|
@@ -643,12 +738,14 @@ export class ChatSession {
|
|
|
643
738
|
this.sender.withdraw();
|
|
644
739
|
this.reading.withdraw();
|
|
645
740
|
this.readers.withdraw();
|
|
741
|
+
this.groups?.withdraw();
|
|
646
742
|
this.counts?.watch(undefined, []);
|
|
647
743
|
this.countSnapshots.clear();
|
|
648
744
|
this.snapshots.clear();
|
|
649
745
|
this.timelineDecoded = new WeakMap();
|
|
650
746
|
this.historyEdges.clear();
|
|
651
747
|
this.earlierReads.clear();
|
|
748
|
+
this.controlTails.clear();
|
|
652
749
|
this.globalSnapshot = undefined;
|
|
653
750
|
this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
|
|
654
751
|
this.globalListeners.forEach(listener => listener());
|
|
@@ -674,7 +771,7 @@ export class ChatSession {
|
|
|
674
771
|
const results = await Promise.allSettled([
|
|
675
772
|
this.sender.revoke(version),
|
|
676
773
|
this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
|
|
677
|
-
this.readers.retireScope(version), this.counts?.retireScope(version),
|
|
774
|
+
this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version),
|
|
678
775
|
]);
|
|
679
776
|
for (const result of results)
|
|
680
777
|
if (result.status === "rejected")
|
|
@@ -698,11 +795,11 @@ export class ChatSession {
|
|
|
698
795
|
this.off.splice(0).forEach(off => off());
|
|
699
796
|
if (!this.options.sharedConnection)
|
|
700
797
|
this.connection.close();
|
|
701
|
-
const senderClosing = this.sender.close();
|
|
702
|
-
const readingBarrier = Promise.allSettled([...this.acceptedReading]);
|
|
798
|
+
const senderClosing = this.sender.close(), groupsClosing = this.groups?.close();
|
|
799
|
+
const readingBarrier = Promise.allSettled([...this.acceptedReading, ...this.acceptedControls]);
|
|
703
800
|
const readingClosing = readingBarrier.then(() => this.reading.close());
|
|
704
801
|
try {
|
|
705
|
-
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, readingBarrier]))]);
|
|
802
|
+
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
|
|
706
803
|
const failed = results.find((result) => result.status === "rejected");
|
|
707
804
|
if (failed)
|
|
708
805
|
throw failed.reason;
|
|
@@ -716,6 +813,7 @@ export class ChatSession {
|
|
|
716
813
|
this.countSnapshots.clear();
|
|
717
814
|
this.historyEdges.clear();
|
|
718
815
|
this.earlierReads.clear();
|
|
816
|
+
this.controlTails.clear();
|
|
719
817
|
this.globalSnapshot = undefined;
|
|
720
818
|
}
|
|
721
819
|
})();
|
|
@@ -32,6 +32,7 @@ export function createChat(options) {
|
|
|
32
32
|
return true;
|
|
33
33
|
});
|
|
34
34
|
},
|
|
35
|
+
loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
|
|
35
36
|
activeThreadId: options.threadId,
|
|
36
37
|
messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
37
38
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
@@ -28,7 +28,8 @@ export type SendMessageInput = {
|
|
|
28
28
|
to: string;
|
|
29
29
|
body: ChatBody;
|
|
30
30
|
recipients?: string[];
|
|
31
|
-
voiceBlob?: Blob;
|
|
31
|
+
voiceBlob?: Blob; /** SDK-owned reaction sequencing, committed with the exact wire. */
|
|
32
|
+
reactionRevisionFloor?: number;
|
|
32
33
|
};
|
|
33
34
|
export type SenderScopeAccess = {
|
|
34
35
|
version: number;
|
|
@@ -48,6 +49,7 @@ export declare class DurableChatSender {
|
|
|
48
49
|
private options;
|
|
49
50
|
private db;
|
|
50
51
|
private outbox;
|
|
52
|
+
private controlRevisions;
|
|
51
53
|
private attachments;
|
|
52
54
|
private schemaReady;
|
|
53
55
|
private attachmentBytes;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { validMessageIdentifier } from "./outgoingContent.js";
|
|
2
3
|
import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
|
|
3
4
|
import { blake3 } from "@noble/hashes/blake3.js";
|
|
4
5
|
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
@@ -11,6 +12,7 @@ export class DurableChatSender {
|
|
|
11
12
|
options;
|
|
12
13
|
db;
|
|
13
14
|
outbox;
|
|
15
|
+
controlRevisions;
|
|
14
16
|
attachments;
|
|
15
17
|
schemaReady;
|
|
16
18
|
attachmentBytes = 0;
|
|
@@ -62,6 +64,7 @@ export class DurableChatSender {
|
|
|
62
64
|
this.db = outboxDatabase(factory, name);
|
|
63
65
|
this.outbox = this.db.table("outgoingMessages");
|
|
64
66
|
this.attachments = this.db.table("outgoingAttachments");
|
|
67
|
+
this.controlRevisions = this.db.table("outgoingControls");
|
|
65
68
|
this.epochs = this.db.table("scopeEpochs");
|
|
66
69
|
this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
|
|
67
70
|
this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
|
|
@@ -110,7 +113,7 @@ export class DurableChatSender {
|
|
|
110
113
|
async restore() {
|
|
111
114
|
await this.schemaReady;
|
|
112
115
|
const access = await this.readAccess();
|
|
113
|
-
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
116
|
+
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
114
117
|
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
115
118
|
const version = access?.version ?? current;
|
|
116
119
|
if (version < current)
|
|
@@ -136,7 +139,7 @@ export class DurableChatSender {
|
|
|
136
139
|
let after;
|
|
137
140
|
const restored = new Map();
|
|
138
141
|
while (!this.closed && !this.withdrawn) {
|
|
139
|
-
const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
142
|
+
const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
140
143
|
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
141
144
|
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
142
145
|
const keys = await (loaded.legacy
|
|
@@ -213,7 +216,7 @@ export class DurableChatSender {
|
|
|
213
216
|
async withEpoch(mode, operation) {
|
|
214
217
|
await this.checkAccess();
|
|
215
218
|
try {
|
|
216
|
-
return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, async () => {
|
|
219
|
+
return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
217
220
|
if (this.closed || this.withdrawn)
|
|
218
221
|
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
219
222
|
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
@@ -230,6 +233,7 @@ export class DurableChatSender {
|
|
|
230
233
|
async deleteRows() {
|
|
231
234
|
await this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
232
235
|
await this.attachments.where("scope").equals(this.scope).delete();
|
|
236
|
+
await this.controlRevisions.where("scope").equals(this.scope).delete();
|
|
233
237
|
}
|
|
234
238
|
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
235
239
|
withdraw() {
|
|
@@ -253,7 +257,7 @@ export class DurableChatSender {
|
|
|
253
257
|
return this.revoking = (async () => {
|
|
254
258
|
await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
255
259
|
throw error; });
|
|
256
|
-
const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
260
|
+
const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
257
261
|
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
258
262
|
const target = targetVersion ?? this.version + 1;
|
|
259
263
|
if (!Number.isSafeInteger(target) || target < 0)
|
|
@@ -298,6 +302,11 @@ export class DurableChatSender {
|
|
|
298
302
|
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
299
303
|
if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
|
|
300
304
|
return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
|
|
305
|
+
const reactionFloor = input.reactionRevisionFloor;
|
|
306
|
+
if (reactionFloor !== undefined && input.voiceBlob)
|
|
307
|
+
return Promise.reject(new Error("INVALID_MESSAGE_REACTION"));
|
|
308
|
+
if (reactionFloor !== undefined && (!Number.isSafeInteger(reactionFloor) || reactionFloor < 0 || reactionFloor >= Number.MAX_SAFE_INTEGER))
|
|
309
|
+
return Promise.reject(new Error("INVALID_REACTION_REVISION"));
|
|
301
310
|
let bytes;
|
|
302
311
|
try {
|
|
303
312
|
bytes = JSON.stringify([input.to, input.body, input.recipients, this.identity]).length * 3 + 1024 + (input.voiceBlob?.size ?? 0);
|
|
@@ -317,6 +326,12 @@ export class DurableChatSender {
|
|
|
317
326
|
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
318
327
|
if (message.from !== this.identity.profileId)
|
|
319
328
|
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
329
|
+
if (reactionFloor !== undefined) {
|
|
330
|
+
const body = message.body;
|
|
331
|
+
const reaction = body.kind === "message" ? body.content.reaction : undefined;
|
|
332
|
+
if (body.kind !== "message" || body.content.appId !== this.identity.appId || !reaction || reaction.version !== 1 || !validMessageIdentifier(reaction.messageId) || !validMessageIdentifier(reaction.emojiId) || typeof reaction.active !== "boolean")
|
|
333
|
+
throw new Error("INVALID_MESSAGE_REACTION");
|
|
334
|
+
}
|
|
320
335
|
const command = statefulGroup(message);
|
|
321
336
|
if (command)
|
|
322
337
|
message.hash = commandId(this.scope, this.version, threadId, message);
|
|
@@ -327,7 +342,7 @@ export class DurableChatSender {
|
|
|
327
342
|
}
|
|
328
343
|
const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
|
|
329
344
|
const durable = await new Promise((resolve, reject) => {
|
|
330
|
-
this.acceptanceQueue.push({ record, bytes, resolve, reject });
|
|
345
|
+
this.acceptanceQueue.push({ record, reactionFloor, bytes, resolve, reject });
|
|
331
346
|
this.scheduleAcceptances();
|
|
332
347
|
});
|
|
333
348
|
return durable.message;
|
|
@@ -351,11 +366,11 @@ export class DurableChatSender {
|
|
|
351
366
|
let bytes = 0, count = 0, operations = 0;
|
|
352
367
|
for (const item of this.acceptanceQueue) {
|
|
353
368
|
const size = item.bytes;
|
|
354
|
-
if (operations + (hasVoice(item.record) ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
|
|
369
|
+
if (operations + (hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
|
|
355
370
|
break;
|
|
356
371
|
bytes += size;
|
|
357
372
|
count++;
|
|
358
|
-
operations += hasVoice(item.record) ? 2 : 1;
|
|
373
|
+
operations += hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1;
|
|
359
374
|
}
|
|
360
375
|
const batch = this.acceptanceQueue.splice(0, count);
|
|
361
376
|
try {
|
|
@@ -363,8 +378,20 @@ export class DurableChatSender {
|
|
|
363
378
|
const commands = batch.filter(item => statefulGroup(item.record.message));
|
|
364
379
|
const previous = await this.outbox.bulkGet(commands.map(item => [this.scope, item.record.hash]));
|
|
365
380
|
const known = new Map(previous.filter((row) => Boolean(row && (row.accessVersion ?? 0) === this.version)).map(row => [row.hash, row]));
|
|
366
|
-
const writes = [], records = [];
|
|
367
|
-
for (const
|
|
381
|
+
const writes = [], records = [], revisions = new Map();
|
|
382
|
+
for (const item of batch) {
|
|
383
|
+
let record = item.record;
|
|
384
|
+
if (item.reactionFloor !== undefined) {
|
|
385
|
+
const body = record.message.body;
|
|
386
|
+
const reaction = body.content.reaction;
|
|
387
|
+
const key = [this.scope, record.threadId, reaction.messageId, reaction.emojiId], encoded = JSON.stringify(key);
|
|
388
|
+
const previous = revisions.get(encoded) ?? await this.controlRevisions.get(key);
|
|
389
|
+
const revision = Math.max(item.reactionFloor, previous?.version === this.version ? previous.value : 0) + 1;
|
|
390
|
+
if (!Number.isSafeInteger(revision))
|
|
391
|
+
throw new Error("INVALID_REACTION_REVISION");
|
|
392
|
+
revisions.set(encoded, { scope: this.scope, threadId: record.threadId, target: reaction.messageId, emoji: reaction.emojiId, version: this.version, value: revision });
|
|
393
|
+
record = { ...record, message: { ...record.message, body: { ...body, content: { ...body.content, reaction: { ...reaction, revision } } } } };
|
|
394
|
+
}
|
|
368
395
|
const command = statefulGroup(record.message), old = command ? known.get(record.hash) : undefined;
|
|
369
396
|
if (old) {
|
|
370
397
|
if (old.message.from !== this.identity.profileId || old.message.hash !== record.hash || commandId(this.scope, this.version, record.threadId, old.message) !== record.hash)
|
|
@@ -381,6 +408,8 @@ export class DurableChatSender {
|
|
|
381
408
|
const split = writes.map(splitAttachment), payloads = split.flatMap(item => item.attachment ? [item.attachment] : []);
|
|
382
409
|
if (payloads.length)
|
|
383
410
|
await this.attachments.bulkPut(payloads);
|
|
411
|
+
if (revisions.size)
|
|
412
|
+
await this.controlRevisions.bulkPut([...revisions.values()]);
|
|
384
413
|
if (split.length)
|
|
385
414
|
await this.outbox.bulkPut(split.map(item => item.record));
|
|
386
415
|
const metadata = new Map(split.map(item => [item.record.hash, item.record]));
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
3
|
+
import { type ChatConversation } from "./outgoingContent.js";
|
|
4
|
+
import type { SocialGroup } from "./socialReadClient.js";
|
|
5
|
+
export type ChatGroup = {
|
|
6
|
+
groupId: string;
|
|
7
|
+
teamId: string;
|
|
8
|
+
ownerId: string;
|
|
9
|
+
title: string;
|
|
10
|
+
avatar?: string;
|
|
11
|
+
memberIds: readonly string[];
|
|
12
|
+
revision: string;
|
|
13
|
+
versionNumber: number;
|
|
14
|
+
};
|
|
15
|
+
export type GroupSnapshot = {
|
|
16
|
+
data?: ChatGroup;
|
|
17
|
+
loading: boolean;
|
|
18
|
+
error?: unknown;
|
|
19
|
+
};
|
|
20
|
+
export type GroupClientOptions = {
|
|
21
|
+
scope: MessageScope;
|
|
22
|
+
access: ResponseCacheAccess;
|
|
23
|
+
load: (groupId: string, signal: AbortSignal) => Promise<SocialGroup>;
|
|
24
|
+
onError: (error: unknown) => void;
|
|
25
|
+
onDenied?: (error: unknown) => Promise<void> | void;
|
|
26
|
+
indexedDB?: IDBFactory;
|
|
27
|
+
name?: string;
|
|
28
|
+
};
|
|
29
|
+
/** SDK-owned roster cache. Read sources, concurrency and reconciliation stay private. */
|
|
30
|
+
export declare class GroupClient {
|
|
31
|
+
private options;
|
|
32
|
+
private cache;
|
|
33
|
+
private entries;
|
|
34
|
+
private pins;
|
|
35
|
+
private listeners;
|
|
36
|
+
private localQueue;
|
|
37
|
+
private localScheduled;
|
|
38
|
+
private localRunning;
|
|
39
|
+
private tasks;
|
|
40
|
+
private authority?;
|
|
41
|
+
private version?;
|
|
42
|
+
private clock;
|
|
43
|
+
private active;
|
|
44
|
+
private queue;
|
|
45
|
+
private controllers;
|
|
46
|
+
private channel?;
|
|
47
|
+
private withdrawn;
|
|
48
|
+
private closed;
|
|
49
|
+
private closing?;
|
|
50
|
+
constructor(options: GroupClientOptions);
|
|
51
|
+
private assertOpen;
|
|
52
|
+
private checkAuthority;
|
|
53
|
+
private entry;
|
|
54
|
+
snapshot(threadId: string): GroupSnapshot;
|
|
55
|
+
subscribe(listener: (threadId: string) => void): () => void;
|
|
56
|
+
pin(threadId: string): () => void;
|
|
57
|
+
private publish;
|
|
58
|
+
private normalize;
|
|
59
|
+
private cached;
|
|
60
|
+
private local;
|
|
61
|
+
private flushLocal;
|
|
62
|
+
private reload;
|
|
63
|
+
get(thread: ChatConversation, options?: {
|
|
64
|
+
refresh?: boolean;
|
|
65
|
+
}): Promise<ChatGroup>;
|
|
66
|
+
private schedule;
|
|
67
|
+
wake(): void;
|
|
68
|
+
hint(thread: ChatConversation, versionNumber?: number): void;
|
|
69
|
+
private slot;
|
|
70
|
+
private drain;
|
|
71
|
+
private request;
|
|
72
|
+
after(thread: ChatConversation, minimumVersion: number): Promise<ChatGroup>;
|
|
73
|
+
withdraw(): void;
|
|
74
|
+
retire(version: number): Promise<void>;
|
|
75
|
+
close(): Promise<void>;
|
|
76
|
+
}
|