@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 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
- sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
125
- sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
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
- sendMessage(thread, input) {
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
- return this.sender.enqueue(threadId, wire).then(next => { if (this.withdrawn)
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 { record } of batch) {
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
+ }