@pmate/chat-sdk 0.11.9 → 0.12.0-next.1

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
@@ -127,3 +127,45 @@ The handler leaves text-only pastes and events already handled by the host untou
127
127
  `useImageAttachments({ uploadImage, disabled })` provides `onPaste`, `addFiles`, `attachments`, `pending`, `remove` and `retry`. Render `ImageAttachmentTray` inside the composer's surface with these attachments and the input tokens. Local previews appear immediately; uploads happen in the background. Removal/unmount revokes local object URLs, and late upload results cannot reinsert removed drafts. Upload errors remain visible and retryable.
128
128
 
129
129
  Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
130
+
131
+ ## Browser message and reading data
132
+
133
+ ### Background
134
+
135
+ Message hosts previously coordinated remote history, offline snapshots, optimistic
136
+ sends and reading synchronization independently. The runtime now provides local
137
+ IndexedDB storage and fixed-window scheduling primitives so those operations can
138
+ share bounded I/O, durable confirmation and scoped subscriptions.
139
+
140
+ `MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
141
+ subscribes per conversation, and accepts incremental event batches. Its transport
142
+ supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
143
+ uncached older history independently of the latest synchronization checkpoint.
144
+ A projection reset removes obsolete rows before installing a new window.
145
+ `SocialReadClient.messagePage` implements the authenticated Hub page protocol;
146
+ request cancellation is supported through its optional second signal argument.
147
+
148
+ `ReadingClient` owns persisted visible/explicit read intents, windowed network
149
+ synchronization and retry. A host reports actual visibility with `recordVisible`
150
+ or explicit actions with `recordExplicit`; loading or subscribing never marks
151
+ messages read. `getSnapshot` plus `subscribe` provide stable reading snapshots.
152
+ `BatchedReadIndex` resolves queued writes only after their transaction commits.
153
+
154
+ `IndexedDbRecordCache` is a version-aware control-event index. Call and await
155
+ `open` before using it. An optional legacy reader runs during initialization only;
156
+ legacy cleanup happens after durable migration. Normal reads use memory and writes
157
+ are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
158
+ the host, and pending operations are not reported as saved before their promise
159
+ resolves.
160
+
161
+ `DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
162
+ before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
163
+ and payload. Failed sends require an explicit retry; interrupted sends can resume
164
+ when the connection returns. Call `confirm(hashes)` only after authoritative
165
+ messages have been durably merged. Confirmation tombstones prevent another tab's
166
+ late transport failure from recreating an outgoing message.
167
+
168
+ These APIs are runtime primitives; applications must initialize them with the
169
+ same authenticated scope, dispose them on identity changes, and wire their
170
+ transport to an authorized server. The Merry migration and production validation
171
+ are tracked in the unified message layer proposal.
@@ -0,0 +1,23 @@
1
+ import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
2
+ type Change = {
3
+ transform: (state: ReadIndexState) => ReadIndexState;
4
+ resolve: (state: ReadIndexState) => void;
5
+ reject: (error: unknown) => void;
6
+ };
7
+ /** Coalesces viewport observations into bounded, atomic read-index transactions.
8
+ * Each caller resolves only after its batch commits, never when merely queued. */
9
+ export declare class BatchedReadIndex implements ReadIndexPersistence {
10
+ private storage;
11
+ private windowMs;
12
+ private batchSize;
13
+ private queueLimit;
14
+ private queues;
15
+ private closed;
16
+ constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
17
+ load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
18
+ update(appId: string, profileId: string, transform: Change["transform"]): Promise<ReadIndexState>;
19
+ private drain;
20
+ flush(appId: string, profileId: string): Promise<void>;
21
+ close(): Promise<void>;
22
+ }
23
+ export {};
@@ -0,0 +1,81 @@
1
+ import {} from "./readIndex.js";
2
+ /** Coalesces viewport observations into bounded, atomic read-index transactions.
3
+ * Each caller resolves only after its batch commits, never when merely queued. */
4
+ export class BatchedReadIndex {
5
+ storage;
6
+ windowMs;
7
+ batchSize;
8
+ queueLimit;
9
+ queues = new Map();
10
+ closed = false;
11
+ constructor(storage, windowMs = 16, batchSize = 128, queueLimit = 4096) {
12
+ this.storage = storage;
13
+ this.windowMs = windowMs;
14
+ this.batchSize = batchSize;
15
+ this.queueLimit = queueLimit;
16
+ if (windowMs < 0 || !Number.isSafeInteger(batchSize) || batchSize < 1 || !Number.isSafeInteger(queueLimit) || queueLimit < batchSize)
17
+ throw new Error("INVALID_READ_BATCH_OPTIONS");
18
+ }
19
+ async load(appId, profileId) {
20
+ await this.flush(appId, profileId);
21
+ if (this.closed)
22
+ throw new Error("READ_STORAGE_CLOSED");
23
+ return this.storage.load(appId, profileId);
24
+ }
25
+ update(appId, profileId, transform) {
26
+ if (this.closed)
27
+ return Promise.reject(new Error("READ_STORAGE_CLOSED"));
28
+ const key = JSON.stringify([appId, profileId]);
29
+ let queue = this.queues.get(key);
30
+ if (!queue) {
31
+ queue = { appId, profileId, pending: [] };
32
+ this.queues.set(key, queue);
33
+ }
34
+ if (queue.pending.length >= this.queueLimit)
35
+ return Promise.reject(new Error("READ_STORAGE_BACKPRESSURE"));
36
+ const promise = new Promise((resolve, reject) => queue.pending.push({ transform, resolve, reject }));
37
+ if (!queue.running && queue.timer === undefined)
38
+ queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, this.windowMs);
39
+ return promise;
40
+ }
41
+ async drain(key, queue) {
42
+ if (queue.running)
43
+ return queue.running;
44
+ clearTimeout(queue.timer);
45
+ queue.timer = undefined;
46
+ const batch = queue.pending.splice(0, this.batchSize);
47
+ if (!batch.length)
48
+ return;
49
+ const run = (async () => {
50
+ try {
51
+ const next = await this.storage.update(queue.appId, queue.profileId, state => batch.reduce((current, change) => change.transform(current), state));
52
+ batch.forEach(change => change.resolve(next));
53
+ }
54
+ catch (error) {
55
+ batch.forEach(change => change.reject(error));
56
+ }
57
+ })();
58
+ queue.running = run;
59
+ await run;
60
+ queue.running = undefined;
61
+ if (queue.pending.length)
62
+ queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, 0);
63
+ else
64
+ this.queues.delete(key);
65
+ }
66
+ async flush(appId, profileId) {
67
+ const key = JSON.stringify([appId, profileId]);
68
+ let queue = this.queues.get(key);
69
+ while (queue) {
70
+ if (queue.running)
71
+ await queue.running;
72
+ else
73
+ await this.drain(key, queue);
74
+ queue = this.queues.get(key);
75
+ }
76
+ }
77
+ async close() {
78
+ this.closed = true;
79
+ await Promise.all([...this.queues.values()].map(queue => this.flush(queue.appId, queue.profileId)));
80
+ }
81
+ }
@@ -46,6 +46,7 @@ export declare class ChatClient {
46
46
  private botKey;
47
47
  private readonly activities;
48
48
  private readonly publishers;
49
+ private readonly preparedSends;
49
50
  private readonly pendingAcks;
50
51
  private readonly messageHandlers;
51
52
  private readonly updateHandlers;
@@ -120,6 +121,9 @@ export declare class ChatClient {
120
121
  recipients?: string[];
121
122
  mentions?: ChatMention[];
122
123
  }): Promise<ChatRuntimeMessage>;
124
+ prepareChat(to: string, body: ChatBody, recipients?: string[]): ChatRuntimeMessage;
125
+ /** Reuses a durably saved hash and payload when retrying an uncertain send. */
126
+ sendPrepared(msg: ChatRuntimeMessage): Promise<ChatRuntimeMessage>;
123
127
  sendChat(to: string, body: ChatBody, recipients?: string[]): Promise<ChatRuntimeMessage>;
124
128
  private sendContactAction;
125
129
  private sendAuth;
@@ -29,6 +29,7 @@ export class ChatClient {
29
29
  botKey = null;
30
30
  activities = new ChatActivityStore();
31
31
  publishers = new Set();
32
+ preparedSends = new Map();
32
33
  pendingAcks = new Map();
33
34
  messageHandlers = new Set();
34
35
  updateHandlers = new Set();
@@ -254,11 +255,29 @@ export class ChatClient {
254
255
  content: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
255
256
  }, input.recipients);
256
257
  }
257
- async sendChat(to, body, recipients) {
258
+ prepareChat(to, body, recipients) {
258
259
  const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, withChatTeam(body, this.options.teamId), ChatMsgKind.DM);
259
- if (recipients) {
260
- msg.recipients = recipients;
260
+ if (recipients)
261
+ msg.recipients = [...recipients];
262
+ return msg;
263
+ }
264
+ /** Reuses a durably saved hash and payload when retrying an uncertain send. */
265
+ sendPrepared(msg) {
266
+ const body = msg.body;
267
+ if (msg.opcode !== ChatMsgOp.CHAT || msg.from !== this.profileId() || !msg.hash || (body?.content?.appId ?? body?.payload?.appId) !== this.options.appId) {
268
+ return Promise.reject(new Error("PREPARED_MESSAGE_IDENTITY_MISMATCH"));
261
269
  }
270
+ const existing = this.preparedSends.get(msg.hash);
271
+ if (existing)
272
+ return existing;
273
+ const saved = structuredClone(msg);
274
+ const task = this.sendAndWaitForAck(saved).then(() => saved).finally(() => this.preparedSends.delete(saved.hash));
275
+ this.preparedSends.set(saved.hash, task);
276
+ return task;
277
+ }
278
+ async sendChat(to, body, recipients) {
279
+ const msg = this.prepareChat(to, body, recipients);
280
+ // Existing control/activity envelopes retain their transport behavior.
262
281
  await this.sendAndWaitForAck(msg);
263
282
  return msg;
264
283
  }
@@ -0,0 +1,59 @@
1
+ import { type MessageReadersQuery } from "./messageReaders.js";
2
+ import { type MessageClientOptions } from "./messageClient.js";
3
+ import { DurableChatSender } from "./durableSender.js";
4
+ import { type ReadingClientOptions } from "./readingClient.js";
5
+ import { type ChatClientOptions } from "./chatClient.js";
6
+ import { unreadInConversation } from "./readIndex.js";
7
+ import type { StoredMessage } from "./messageStore.js";
8
+ import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
9
+ export type ChatSessionOptions<T extends StoredMessage> = {
10
+ messages: MessageClientOptions<T>;
11
+ connection: ChatClientOptions;
12
+ reading: ReadingClientOptions;
13
+ decode: (message: ChatRuntimeMessage) => T | undefined;
14
+ onError: (error: unknown) => void;
15
+ };
16
+ export type ChatThreadSnapshot<T> = {
17
+ messages: readonly T[];
18
+ outgoing: ReturnType<DurableChatSender["snapshot"]>;
19
+ hasEarlier: boolean;
20
+ unread: ReturnType<typeof unreadInConversation>;
21
+ };
22
+ /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
23
+ export declare class ChatSession<T extends StoredMessage> {
24
+ private options;
25
+ private messages;
26
+ private connection;
27
+ private reading;
28
+ private readers;
29
+ private sender;
30
+ private listeners;
31
+ private snapshots;
32
+ private off;
33
+ private closed;
34
+ private starting?;
35
+ private closing?;
36
+ constructor(options: ChatSessionOptions<T>);
37
+ start(): Promise<void>;
38
+ private assertOpen;
39
+ getMessages(threadId: string, options?: {
40
+ limit?: number;
41
+ before?: {
42
+ timestamp: number;
43
+ id: string;
44
+ };
45
+ }): Promise<import("./messageClient.js").MessageSnapshot<T>>;
46
+ sendMessage(threadId: string, input: {
47
+ to: string;
48
+ body: ChatBody;
49
+ recipients?: string[];
50
+ }): Promise<ChatRuntimeMessage>;
51
+ getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
52
+ retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
53
+ observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./readIndex.js").ReadIndexState>;
54
+ getSnapshot(threadId: string): ChatThreadSnapshot<T>;
55
+ subscribe(threadId: string, listener: () => void): () => void;
56
+ private publishAll;
57
+ private publish;
58
+ close(): Promise<void>;
59
+ }
@@ -0,0 +1,130 @@
1
+ import { MessageReadersClient } from "./messageReaders.js";
2
+ import { MessageClient } from "./messageClient.js";
3
+ import { DurableChatSender } from "./durableSender.js";
4
+ import { ReadingClient } from "./readingClient.js";
5
+ import { ChatClient } from "./chatClient.js";
6
+ import { unreadInConversation } from "./readIndex.js";
7
+ /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
8
+ export class ChatSession {
9
+ options;
10
+ messages;
11
+ connection;
12
+ reading;
13
+ readers;
14
+ sender;
15
+ listeners = new Map();
16
+ snapshots = new Map();
17
+ off = [];
18
+ closed = false;
19
+ starting;
20
+ closing;
21
+ constructor(options) {
22
+ this.options = options;
23
+ const scope = options.messages.scope;
24
+ if (options.connection.appId !== scope.appId || options.reading.appId !== scope.appId || options.reading.profileId !== scope.profileId || options.connection.profileId !== scope.profileId || options.connection.teamId !== scope.teamId)
25
+ throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
26
+ this.messages = new MessageClient(options.messages);
27
+ this.connection = new ChatClient(options.connection);
28
+ this.reading = new ReadingClient(options.reading);
29
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
30
+ this.sender = new DurableChatSender(this.connection, scope, options.onError);
31
+ this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
32
+ this.off.push(this.connection.watchMessages(message => {
33
+ try {
34
+ const decoded = options.decode(message);
35
+ if (decoded)
36
+ void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
37
+ }
38
+ catch (error) {
39
+ options.onError(error);
40
+ }
41
+ }));
42
+ this.off.push(this.connection.watchConnection(state => {
43
+ if (state === "connected") {
44
+ for (const thread of this.listeners.keys())
45
+ this.messages.invalidate(thread);
46
+ this.reading.refresh();
47
+ }
48
+ }));
49
+ }
50
+ start() {
51
+ this.assertOpen();
52
+ return this.starting ??= (async () => {
53
+ await this.sender.initialize();
54
+ await this.reading.start();
55
+ this.assertOpen();
56
+ await this.connection.connect();
57
+ })().catch(error => { this.starting = undefined; throw error; });
58
+ }
59
+ assertOpen() { if (this.closed)
60
+ throw new Error("CHAT_SESSION_CLOSED"); }
61
+ async getMessages(threadId, options = {}) {
62
+ this.assertOpen();
63
+ const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
64
+ if (!options.before)
65
+ this.publish(threadId);
66
+ return page;
67
+ }
68
+ sendMessage(threadId, input) {
69
+ this.assertOpen();
70
+ return this.sender.enqueue(threadId, input);
71
+ }
72
+ getReaders(threadId, messageId, page = {}, signal) {
73
+ this.assertOpen();
74
+ return this.readers.getReaders({ ...page, threadId, messageId }, signal);
75
+ }
76
+ retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
77
+ observeMessages(threadId, ids, event) {
78
+ this.assertOpen();
79
+ return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
+ }
81
+ getSnapshot(threadId) {
82
+ let snapshot = this.snapshots.get(threadId);
83
+ if (!snapshot) {
84
+ const messages = this.messages.getSnapshot(threadId);
85
+ const state = this.reading.getSnapshot();
86
+ snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
87
+ this.snapshots.set(threadId, snapshot);
88
+ }
89
+ return snapshot;
90
+ }
91
+ subscribe(threadId, listener) {
92
+ this.assertOpen();
93
+ let listeners = this.listeners.get(threadId);
94
+ if (!listeners) {
95
+ listeners = new Set();
96
+ this.listeners.set(threadId, listeners);
97
+ }
98
+ listeners.add(listener);
99
+ const off = this.messages.subscribe(threadId, () => this.publish(threadId));
100
+ return () => {
101
+ off();
102
+ listeners.delete(listener);
103
+ if (!listeners.size)
104
+ this.listeners.delete(threadId);
105
+ };
106
+ }
107
+ publishAll() { for (const threadId of [...this.snapshots.keys()])
108
+ this.publish(threadId); }
109
+ publish(threadId) {
110
+ const previous = this.snapshots.get(threadId);
111
+ this.snapshots.delete(threadId);
112
+ const next = this.getSnapshot(threadId);
113
+ if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
114
+ this.snapshots.set(threadId, previous);
115
+ return;
116
+ }
117
+ this.listeners.get(threadId)?.forEach(listener => listener());
118
+ }
119
+ close() {
120
+ return this.closing ??= (async () => {
121
+ this.closed = true;
122
+ this.off.splice(0).forEach(off => off());
123
+ this.connection.close();
124
+ this.readers.close();
125
+ await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
126
+ this.listeners.clear();
127
+ this.snapshots.clear();
128
+ })();
129
+ }
130
+ }
@@ -0,0 +1,12 @@
1
+ /** Fixed windows, one in-flight task per key, and one trailing run for new work.
2
+ * Unlike trailing debounce, continuous events cannot postpone work indefinitely. */
3
+ export declare class WindowedTasks {
4
+ private windowMs;
5
+ private onError;
6
+ private tasks;
7
+ private closed;
8
+ constructor(windowMs?: number, onError?: (error: unknown, key: string) => void);
9
+ schedule(key: string, work: () => Promise<void>, delay?: number): void;
10
+ private run;
11
+ close(): void;
12
+ }
@@ -0,0 +1,56 @@
1
+ /** Fixed windows, one in-flight task per key, and one trailing run for new work.
2
+ * Unlike trailing debounce, continuous events cannot postpone work indefinitely. */
3
+ export class WindowedTasks {
4
+ windowMs;
5
+ onError;
6
+ tasks = new Map();
7
+ closed = false;
8
+ constructor(windowMs = 250, onError = () => { }) {
9
+ this.windowMs = windowMs;
10
+ this.onError = onError;
11
+ }
12
+ schedule(key, work, delay = this.windowMs) {
13
+ if (this.closed)
14
+ return;
15
+ let task = this.tasks.get(key);
16
+ if (!task) {
17
+ task = { dirty: false, work, failures: 0 };
18
+ this.tasks.set(key, task);
19
+ }
20
+ task.work = work;
21
+ task.dirty = true;
22
+ if (task.timer !== undefined || task.running)
23
+ return;
24
+ task.timer = setTimeout(() => { task.timer = undefined; void this.run(key, task); }, delay);
25
+ }
26
+ async run(key, task) {
27
+ if (this.closed || !task.dirty || task.running)
28
+ return;
29
+ task.dirty = false;
30
+ const pending = Promise.resolve().then(task.work);
31
+ task.running = pending;
32
+ try {
33
+ await pending;
34
+ task.failures = 0;
35
+ }
36
+ catch (error) {
37
+ task.failures++;
38
+ this.onError(error, key);
39
+ }
40
+ finally {
41
+ task.running = undefined;
42
+ if (!this.closed && task.dirty) {
43
+ const delay = task.failures ? Math.min(30_000, 1000 * 2 ** Math.min(task.failures - 1, 5)) : this.windowMs;
44
+ this.schedule(key, task.work, delay);
45
+ }
46
+ else
47
+ this.tasks.delete(key);
48
+ }
49
+ }
50
+ close() {
51
+ this.closed = true;
52
+ for (const task of this.tasks.values())
53
+ clearTimeout(task.timer);
54
+ this.tasks.clear();
55
+ }
56
+ }
@@ -0,0 +1,56 @@
1
+ import type { ChatClient } from "./chatClient.js";
2
+ import { type MessageScope } from "./messageStore.js";
3
+ import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
4
+ export type OutgoingMessage = {
5
+ scope: string;
6
+ threadId: string;
7
+ hash: string;
8
+ message: ChatRuntimeMessage;
9
+ status: "sending" | "failed" | "sent";
10
+ confirmed?: boolean;
11
+ };
12
+ /** Persists the exact wire message before sending; retries preserve its hash. */
13
+ export declare class DurableChatSender {
14
+ private client;
15
+ private onError;
16
+ private db;
17
+ private outbox;
18
+ private scope;
19
+ private identity;
20
+ private values;
21
+ private snapshots;
22
+ private listeners;
23
+ private running;
24
+ private closed;
25
+ private offConnection;
26
+ private ready;
27
+ constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: {
28
+ indexedDB?: IDBFactory;
29
+ name?: string;
30
+ });
31
+ initialize(): Promise<void>;
32
+ subscribe(listener: () => void): () => void;
33
+ snapshot(threadId?: string): readonly OutgoingMessage[];
34
+ private emit;
35
+ /** Resolves after the exact wire message is durable, independently of network delivery. */
36
+ enqueue(threadId: string, input: {
37
+ to: string;
38
+ body: ChatBody;
39
+ recipients?: string[];
40
+ }): Promise<ChatRuntimeMessage>;
41
+ /** Compatibility API: resolves only after transport delivery. */
42
+ send(threadId: string, input: {
43
+ to: string;
44
+ body: ChatBody;
45
+ recipients?: string[];
46
+ }): Promise<ChatRuntimeMessage>;
47
+ private persistOutgoing;
48
+ retry(hash: string): Promise<ChatRuntimeMessage>;
49
+ private transmit;
50
+ private save;
51
+ /** Confirm only after the authoritative message has been durably merged.
52
+ * A tombstone prevents another tab's late failure from recreating the outbox. */
53
+ confirm(hashes: string[]): Promise<void>;
54
+ private resume;
55
+ close(): Promise<void>;
56
+ }