@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.70

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.
Files changed (96) hide show
  1. package/README.md +152 -81
  2. package/lib/advanced.d.ts +10 -0
  3. package/lib/advanced.js +10 -0
  4. package/lib/chat.d.ts +5 -0
  5. package/lib/chat.js +11 -0
  6. package/lib/chatContract.d.ts +148 -0
  7. package/lib/chatContract.js +1 -0
  8. package/lib/chatError.d.ts +11 -0
  9. package/lib/chatError.js +13 -0
  10. package/lib/chatTypes.d.ts +121 -0
  11. package/lib/chatTypes.js +1 -0
  12. package/lib/credentials.d.ts +26 -0
  13. package/lib/credentials.js +110 -0
  14. package/lib/fileMetadata.d.ts +9 -0
  15. package/lib/fileMetadata.js +18 -0
  16. package/lib/framework.d.ts +11 -0
  17. package/lib/framework.js +12 -0
  18. package/lib/history.d.ts +4 -0
  19. package/lib/history.js +8 -0
  20. package/lib/hooks/useChatSnapshot.d.ts +4 -0
  21. package/lib/hooks/useChatSnapshot.js +9 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.js +2 -0
  24. package/lib/internal/chatEngine.d.ts +131 -0
  25. package/lib/internal/chatEngine.js +280 -0
  26. package/lib/internal/chatRegistry.d.ts +4 -0
  27. package/lib/internal/chatRegistry.js +4 -0
  28. package/lib/internal/createPublicChat.d.ts +4 -0
  29. package/lib/internal/createPublicChat.js +52 -0
  30. package/lib/messageView.d.ts +6 -0
  31. package/lib/messageView.js +103 -0
  32. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  33. package/lib/runtime/batchedReadIndex.js +50 -4
  34. package/lib/runtime/cachedMessage.d.ts +12 -0
  35. package/lib/runtime/cachedMessage.js +18 -0
  36. package/lib/runtime/chatClient.d.ts +2 -0
  37. package/lib/runtime/chatClient.js +8 -3
  38. package/lib/runtime/chatError.d.ts +3 -0
  39. package/lib/runtime/chatError.js +15 -0
  40. package/lib/runtime/chatSession.d.ts +127 -13
  41. package/lib/runtime/chatSession.js +727 -40
  42. package/lib/runtime/connectivity.d.ts +4 -0
  43. package/lib/runtime/connectivity.js +8 -0
  44. package/lib/runtime/conversationDirectory.d.ts +37 -0
  45. package/lib/runtime/conversationDirectory.js +116 -0
  46. package/lib/runtime/conversationRead.d.ts +13 -0
  47. package/lib/runtime/conversationRead.js +34 -0
  48. package/lib/runtime/conversationReading.d.ts +28 -0
  49. package/lib/runtime/conversationReading.js +47 -0
  50. package/lib/runtime/createChat.d.ts +11 -7
  51. package/lib/runtime/createChat.js +46 -23
  52. package/lib/runtime/durableSender.d.ts +97 -14
  53. package/lib/runtime/durableSender.js +786 -71
  54. package/lib/runtime/groupClient.d.ts +76 -0
  55. package/lib/runtime/groupClient.js +325 -0
  56. package/lib/runtime/index.d.ts +7 -0
  57. package/lib/runtime/index.js +5 -0
  58. package/lib/runtime/messageClient.d.ts +35 -8
  59. package/lib/runtime/messageClient.js +364 -66
  60. package/lib/runtime/messageControls.d.ts +19 -0
  61. package/lib/runtime/messageControls.js +44 -0
  62. package/lib/runtime/messageLaunch.d.ts +44 -0
  63. package/lib/runtime/messageLaunch.js +137 -0
  64. package/lib/runtime/messageQuotes.d.ts +3 -0
  65. package/lib/runtime/messageQuotes.js +17 -0
  66. package/lib/runtime/messageReaders.d.ts +12 -1
  67. package/lib/runtime/messageReaders.js +67 -3
  68. package/lib/runtime/messageStore.d.ts +40 -2
  69. package/lib/runtime/messageStore.js +227 -11
  70. package/lib/runtime/messageTimeline.d.ts +50 -0
  71. package/lib/runtime/messageTimeline.js +120 -0
  72. package/lib/runtime/outboxSchema.d.ts +5 -0
  73. package/lib/runtime/outboxSchema.js +59 -0
  74. package/lib/runtime/outgoingContent.d.ts +89 -0
  75. package/lib/runtime/outgoingContent.js +113 -0
  76. package/lib/runtime/readBatchJournal.d.ts +35 -0
  77. package/lib/runtime/readBatchJournal.js +210 -0
  78. package/lib/runtime/readCounts.d.ts +15 -1
  79. package/lib/runtime/readCounts.js +97 -9
  80. package/lib/runtime/readIndex.d.ts +68 -7
  81. package/lib/runtime/readIndex.js +460 -22
  82. package/lib/runtime/readSchema.d.ts +2 -0
  83. package/lib/runtime/readSchema.js +58 -0
  84. package/lib/runtime/readSync.d.ts +1 -0
  85. package/lib/runtime/readSync.js +26 -9
  86. package/lib/runtime/readingClient.d.ts +19 -0
  87. package/lib/runtime/readingClient.js +131 -19
  88. package/lib/runtime/readingContent.d.ts +5 -0
  89. package/lib/runtime/readingContent.js +23 -0
  90. package/lib/runtime/responseCache.d.ts +59 -0
  91. package/lib/runtime/responseCache.js +232 -0
  92. package/lib/runtime/socialReadClient.d.ts +4 -2
  93. package/lib/runtime/socialReadClient.js +12 -8
  94. package/lib/runtime/voiceUpload.d.ts +2 -0
  95. package/lib/runtime/voiceUpload.js +27 -0
  96. package/package.json +27 -2
@@ -1,12 +1,47 @@
1
1
  import { Dexie } from "dexie";
2
+ import { validMessageIdentifier } from "./outgoingContent.js";
3
+ import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
4
+ import { blake3 } from "@noble/hashes/blake3.js";
5
+ import { bytesToHex } from "@noble/hashes/utils.js";
2
6
  import { withChatTeam } from "./teamScope.js";
3
7
  import { messageScopeKey } from "./messageStore.js";
4
8
  /** Persists the exact wire message before sending; retries preserve its hash. */
5
9
  export class DurableChatSender {
6
10
  client;
7
11
  onError;
12
+ options;
8
13
  db;
9
14
  outbox;
15
+ controlRevisions;
16
+ attachments;
17
+ schemaReady;
18
+ attachmentBytes = 0;
19
+ attachmentLeases = new Map();
20
+ epochs;
21
+ uploads = new Set();
22
+ reconciliations = new Set();
23
+ firstDelivery = new Set();
24
+ ioQueue = [];
25
+ ioTimer;
26
+ ioRunning;
27
+ ioCount = 0;
28
+ ioBytes = 0;
29
+ acceptanceQueue = [];
30
+ acceptanceTimer;
31
+ acceptanceRunning;
32
+ acceptanceCount = 0;
33
+ acceptanceBytes = 0;
34
+ uploadTail = Promise.resolve();
35
+ uploadWaiting = 0;
36
+ confirmationQueue = [];
37
+ confirmationTimer;
38
+ confirmationRunning;
39
+ confirmationSize = 0;
40
+ version = 0;
41
+ withdrawn = false;
42
+ revoking;
43
+ channel;
44
+ accepted = new Set();
10
45
  scope;
11
46
  identity;
12
47
  values = new Map();
@@ -14,30 +49,233 @@ export class DurableChatSender {
14
49
  listeners = new Set();
15
50
  running = new Map();
16
51
  closed = false;
52
+ closing = false;
53
+ empty = [];
17
54
  offConnection;
18
55
  ready;
19
56
  constructor(client, scope, onError, options = {}) {
20
57
  this.client = client;
21
58
  this.onError = onError;
59
+ this.options = options;
22
60
  this.scope = messageScopeKey(scope);
23
61
  this.identity = { ...scope };
24
- this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
- this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
26
- this.outbox = this.db.table("outbox");
27
- this.ready = this.outbox.where("scope").equals(this.scope).toArray().then(records => {
28
- if (this.closed)
29
- return;
30
- records.filter(record => !record.confirmed).forEach(record => this.values.set(record.hash, record));
31
- this.emit();
32
- });
62
+ const factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
63
+ this.schemaReady = prepareOutboxSchema(factory, name);
64
+ this.db = outboxDatabase(factory, name);
65
+ this.outbox = this.db.table("outgoingMessages");
66
+ this.attachments = this.db.table("outgoingAttachments");
67
+ this.controlRevisions = this.db.table("outgoingControls");
68
+ this.epochs = this.db.table("scopeEpochs");
69
+ this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
70
+ this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
71
+ this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
72
+ this.ready = this.restore();
73
+ void this.ready.catch(() => { });
74
+ if (typeof BroadcastChannel !== "undefined") {
75
+ this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
76
+ this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
77
+ this.withdraw(); };
78
+ }
33
79
  this.offConnection = client.watchConnection(state => {
34
80
  if (state === "connected")
35
81
  void this.resume().catch(onError);
36
82
  });
37
83
  }
84
+ /** Read only the selected durable rows, without restoring or delivering an outbox. */
85
+ static async readSelected(scope, threadId, ids, version, options = {}) {
86
+ if (ids.length > 100)
87
+ throw new Error("INVALID_OUTBOX_SELECTION");
88
+ const key = messageScopeKey(scope), factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
89
+ await prepareOutboxSchema(factory, name);
90
+ const db = outboxDatabase(factory, name);
91
+ try {
92
+ return await db.transaction("r", db.table("outgoingMessages"), db.table("scopeEpochs"), async () => {
93
+ if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
94
+ return [];
95
+ const rows = [];
96
+ for (const id of ids) {
97
+ const row = await db.table("outgoingMessages").get([key, id]);
98
+ rows.push(row ? splitAttachment(row).record : undefined);
99
+ }
100
+ return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [splitAttachment(row).record] : []);
101
+ });
102
+ }
103
+ finally {
104
+ db.close();
105
+ }
106
+ }
107
+ async readAccess() {
108
+ const access = await this.options.access?.();
109
+ if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
110
+ throw new Error("INVALID_OUTBOX_ACCESS");
111
+ return access;
112
+ }
113
+ async restore() {
114
+ await this.schemaReady;
115
+ const access = await this.readAccess();
116
+ const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
117
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
118
+ const version = access?.version ?? current;
119
+ if (version < current)
120
+ throw new Error("OUTBOX_SCOPE_CHANGED");
121
+ if (version > current) {
122
+ await this.epochs.put({ scope: this.scope, version });
123
+ await this.deleteRows();
124
+ }
125
+ const total = await this.outbox.where("scope").equals(this.scope).count();
126
+ const indexed = await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, Dexie.minKey], [this.scope, 1, Dexie.maxKey], true, true).count();
127
+ return { version, advanced: version > current, legacy: total !== indexed };
128
+ });
129
+ this.version = loaded.version;
130
+ if (loaded.advanced)
131
+ this.channel?.postMessage({ version: this.version });
132
+ if (this.closed || this.withdrawn)
133
+ return;
134
+ const current = await this.readAccess();
135
+ if (current && (!current.allowed || current.version !== this.version))
136
+ return;
137
+ if (access?.allowed === false)
138
+ return;
139
+ let after;
140
+ const restored = new Map();
141
+ while (!this.closed && !this.withdrawn) {
142
+ const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
143
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
144
+ throw new Error("OUTBOX_SCOPE_CHANGED");
145
+ const keys = await (loaded.legacy
146
+ ? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
147
+ : this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(64).primaryKeys();
148
+ if (!keys.length)
149
+ return { last: undefined, records: [] };
150
+ const last = keys.at(-1)[1];
151
+ // Key-only metadata covers confirmed history without reading wire bodies.
152
+ // Missing metadata identifies rows written by an older SDK after upgrade.
153
+ const confirmed = loaded.legacy ? await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, keys[0][1]], [this.scope, 0, last], true, true).primaryKeys() : [];
154
+ const confirmedHashes = new Set(confirmed.map(key => key[1]));
155
+ const needed = keys.filter(key => !confirmedHashes.has(key[1]));
156
+ const records = [], writes = [], payloads = [];
157
+ let bytes = 0, processed = last;
158
+ for (const key of needed) {
159
+ const row = await this.outbox.get(key);
160
+ if (!row)
161
+ continue;
162
+ const size = row.voiceData?.bytes.byteLength ?? row.voiceBlob?.size ?? 0;
163
+ if (records.length && bytes + 2 * size > 16 * 1024 * 1024) {
164
+ processed = records.at(-1).hash;
165
+ break;
166
+ }
167
+ if (!row.confirmed && (row.accessVersion ?? 0) === this.version) {
168
+ const split = splitAttachment(row);
169
+ records.push(split.record);
170
+ bytes += 2 * size;
171
+ if (split.attachment) {
172
+ payloads.push(split.attachment);
173
+ writes.push(split.record);
174
+ }
175
+ else if (row.pendingState !== (row.confirmed ? 0 : 1))
176
+ writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
177
+ }
178
+ else if (row.pendingState !== (row.confirmed ? 0 : 1))
179
+ writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
180
+ }
181
+ if (payloads.length)
182
+ await this.attachments.bulkPut(payloads);
183
+ if (writes.length)
184
+ await this.outbox.bulkPut(writes);
185
+ return { last: processed, records };
186
+ });
187
+ for (const row of batch.records)
188
+ restored.set(row.hash, row);
189
+ if (batch.last === undefined)
190
+ break;
191
+ after = batch.last;
192
+ // Leave the database transaction before yielding to input/rendering work.
193
+ await new Promise(resolve => setTimeout(resolve, 0));
194
+ }
195
+ if (this.closed || this.withdrawn)
196
+ return;
197
+ const latest = await this.readAccess();
198
+ if (latest && (!latest.allowed || latest.version !== this.version))
199
+ return;
200
+ this.values = restored;
201
+ this.emit();
202
+ }
203
+ async checkAccess() {
204
+ if (this.closed)
205
+ throw new Error("CHAT_SENDER_CLOSED");
206
+ if (this.withdrawn)
207
+ throw new Error("OUTBOX_ACCESS_REVOKED");
208
+ const access = await this.readAccess();
209
+ if (access && access.version !== this.version) {
210
+ this.withdraw();
211
+ throw new Error("OUTBOX_SCOPE_CHANGED");
212
+ }
213
+ if (access && !access.allowed)
214
+ throw new Error("OUTBOX_ACCESS_REVOKED");
215
+ }
216
+ async withEpoch(mode, operation) {
217
+ await this.checkAccess();
218
+ try {
219
+ return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
220
+ if (this.closed || this.withdrawn)
221
+ throw new Error("OUTBOX_ACCESS_REVOKED");
222
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
223
+ throw new Error("OUTBOX_SCOPE_CHANGED");
224
+ return operation();
225
+ });
226
+ }
227
+ catch (error) {
228
+ if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
229
+ this.withdraw();
230
+ throw error;
231
+ }
232
+ }
233
+ async deleteRows() {
234
+ await this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
235
+ await this.attachments.where("scope").equals(this.scope).delete();
236
+ await this.controlRevisions.where("scope").equals(this.scope).delete();
237
+ }
238
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
239
+ withdraw() {
240
+ this.withdrawn = true;
241
+ this.failIO(new Error("OUTBOX_ACCESS_REVOKED"));
242
+ this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
243
+ this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
244
+ this.uploads.forEach(controller => controller.abort());
245
+ this.reconciliations.forEach(controller => controller.abort());
246
+ this.firstDelivery.clear();
247
+ this.attachmentLeases.forEach(release => release());
248
+ this.values.clear();
249
+ this.emit();
250
+ }
251
+ revoke(targetVersion) {
252
+ if (this.closed || this.closing)
253
+ return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
254
+ if (this.revoking)
255
+ return this.revoking;
256
+ this.withdraw();
257
+ return this.revoking = (async () => {
258
+ await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
259
+ throw error; });
260
+ const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
261
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
262
+ const target = targetVersion ?? this.version + 1;
263
+ if (!Number.isSafeInteger(target) || target < 0)
264
+ throw new Error("INVALID_OUTBOX_EPOCH");
265
+ if (current >= target)
266
+ return current;
267
+ await this.epochs.put({ scope: this.scope, version: target });
268
+ await this.deleteRows();
269
+ return target;
270
+ });
271
+ this.channel?.postMessage({ version });
272
+ })();
273
+ }
38
274
  initialize() { return this.ready; }
39
275
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
40
276
  snapshot(threadId) {
277
+ if (this.closed || this.closing || this.withdrawn)
278
+ return this.empty;
41
279
  let snapshot = this.snapshots.get(threadId);
42
280
  if (!snapshot) {
43
281
  snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
@@ -50,7 +288,8 @@ export class DurableChatSender {
50
288
  /** Resolves after the exact wire message is durable, independently of network delivery. */
51
289
  async enqueue(threadId, input) {
52
290
  const message = await this.persistOutgoing(threadId, input);
53
- void this.retry(message.hash).catch(error => this.onError(error));
291
+ if (!this.closed && !this.closing && !this.withdrawn)
292
+ void this.retry(message.hash).catch(error => this.onError(error));
54
293
  return message;
55
294
  }
56
295
  /** Compatibility API: resolves only after transport delivery. */
@@ -58,23 +297,155 @@ export class DurableChatSender {
58
297
  const message = await this.persistOutgoing(threadId, input);
59
298
  return this.retry(message.hash);
60
299
  }
61
- async persistOutgoing(threadId, input) {
62
- await this.ready;
63
- if (this.closed)
64
- throw new Error("CHAT_SENDER_CLOSED");
65
- const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
66
- if (message.from !== this.identity.profileId)
67
- throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
68
- const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
69
- await this.outbox.put(record);
70
- if (this.closed)
71
- throw new Error("CHAT_SENDER_CLOSED");
72
- this.values.set(record.hash, record);
73
- this.emit();
74
- return message;
300
+ persistOutgoing(threadId, input) {
301
+ if (this.closed || this.closing || this.withdrawn)
302
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
303
+ if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
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"));
310
+ let bytes;
311
+ try {
312
+ bytes = JSON.stringify([input.to, input.body, input.recipients, this.identity]).length * 3 + 1024 + (input.voiceBlob?.size ?? 0);
313
+ }
314
+ catch (error) {
315
+ return Promise.reject(error);
316
+ }
317
+ if (bytes > 20 * 1024 * 1024)
318
+ return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
319
+ if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
320
+ return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
321
+ this.acceptanceCount++;
322
+ this.acceptanceBytes += bytes;
323
+ const pending = (async () => {
324
+ await this.ready;
325
+ await this.checkAccess();
326
+ const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
327
+ if (message.from !== this.identity.profileId)
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
+ }
335
+ const command = statefulGroup(message);
336
+ if (command)
337
+ message.hash = commandId(this.scope, this.version, threadId, message);
338
+ if (input.voiceBlob) {
339
+ const body = message.body;
340
+ if (body.kind !== "message" || !validVoice(body.content.voice))
341
+ throw new Error("INVALID_VOICE_ATTACHMENT");
342
+ }
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 };
344
+ const durable = await new Promise((resolve, reject) => {
345
+ this.acceptanceQueue.push({ record, reactionFloor, bytes, resolve, reject });
346
+ this.scheduleAcceptances();
347
+ });
348
+ return durable.message;
349
+ })().finally(() => { this.accepted.delete(pending); this.acceptanceCount--; this.acceptanceBytes -= bytes; });
350
+ this.accepted.add(pending);
351
+ return pending;
352
+ }
353
+ scheduleAcceptances() {
354
+ if (this.acceptanceTimer || this.acceptanceRunning || !this.acceptanceQueue.length)
355
+ return;
356
+ this.acceptanceTimer = setTimeout(() => { this.acceptanceTimer = undefined; void this.drainAcceptances(); }, 16);
357
+ }
358
+ failAcceptances(error) {
359
+ clearTimeout(this.acceptanceTimer);
360
+ this.acceptanceTimer = undefined;
361
+ for (const item of this.acceptanceQueue.splice(0))
362
+ item.reject(error);
363
+ }
364
+ drainAcceptances() {
365
+ const task = (async () => {
366
+ let bytes = 0, count = 0, operations = 0;
367
+ for (const item of this.acceptanceQueue) {
368
+ const size = item.bytes;
369
+ if (operations + (hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
370
+ break;
371
+ bytes += size;
372
+ count++;
373
+ operations += hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1;
374
+ }
375
+ const batch = this.acceptanceQueue.splice(0, count);
376
+ try {
377
+ const result = await this.withEpoch("rw", async () => {
378
+ const commands = batch.filter(item => statefulGroup(item.record.message));
379
+ const previous = await this.outbox.bulkGet(commands.map(item => [this.scope, item.record.hash]));
380
+ const known = new Map(previous.filter((row) => Boolean(row && (row.accessVersion ?? 0) === this.version)).map(row => [row.hash, row]));
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
+ }
395
+ const command = statefulGroup(record.message), old = command ? known.get(record.hash) : undefined;
396
+ if (old) {
397
+ if (old.message.from !== this.identity.profileId || old.message.hash !== record.hash || commandId(this.scope, this.version, record.threadId, old.message) !== record.hash)
398
+ throw new Error("OUTGOING_COMMAND_CONFLICT");
399
+ records.push(old);
400
+ }
401
+ else {
402
+ writes.push(record);
403
+ records.push(record);
404
+ if (command)
405
+ known.set(record.hash, record);
406
+ }
407
+ }
408
+ const split = writes.map(splitAttachment), payloads = split.flatMap(item => item.attachment ? [item.attachment] : []);
409
+ if (payloads.length)
410
+ await this.attachments.bulkPut(payloads);
411
+ if (revisions.size)
412
+ await this.controlRevisions.bulkPut([...revisions.values()]);
413
+ if (split.length)
414
+ await this.outbox.bulkPut(split.map(item => item.record));
415
+ const metadata = new Map(split.map(item => [item.record.hash, item.record]));
416
+ return { writes: split.map(item => item.record), records: records.map(record => metadata.get(record.hash) ?? record) };
417
+ });
418
+ await this.checkAccess();
419
+ for (const record of result.writes)
420
+ if (statefulGroup(record.message))
421
+ this.firstDelivery.add(record.hash);
422
+ let changed = false;
423
+ for (const record of result.records) {
424
+ if (record.confirmed) {
425
+ this.firstDelivery.delete(record.hash);
426
+ changed = this.values.delete(record.hash) || changed;
427
+ }
428
+ else {
429
+ const current = this.values.get(record.hash);
430
+ if (!current || current.status !== record.status || current.accessVersion !== record.accessVersion) {
431
+ this.values.set(record.hash, record);
432
+ changed = true;
433
+ }
434
+ }
435
+ }
436
+ if (changed)
437
+ this.emit();
438
+ batch.forEach((item, index) => item.resolve(result.records[index]));
439
+ }
440
+ catch (error) {
441
+ batch.forEach(item => item.reject(error));
442
+ }
443
+ })().finally(() => { this.acceptanceRunning = undefined; this.scheduleAcceptances(); });
444
+ this.acceptanceRunning = task;
445
+ return task;
75
446
  }
76
447
  retry(hash) {
77
- if (this.closed)
448
+ if (this.closed || this.closing || this.withdrawn)
78
449
  return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
79
450
  const running = this.running.get(hash);
80
451
  if (running)
@@ -85,88 +456,392 @@ export class DurableChatSender {
85
456
  }
86
457
  async transmit(hash) {
87
458
  await this.ready;
88
- const record = await this.outbox.get([this.scope, hash]);
89
- if (!record || this.closed)
459
+ let record = await this.queueIO(hash);
460
+ if (!record || this.closed || this.withdrawn)
90
461
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
91
- if (record.status === "sent" || record.confirmed)
462
+ if (record.status === "sent" || record.confirmed) {
463
+ this.firstDelivery.delete(hash);
92
464
  return record.message;
465
+ }
93
466
  const sending = { ...record, status: "sending" };
94
467
  if (record.status !== "sending") {
95
468
  const latest = await this.save(sending);
96
469
  if (!latest || latest.confirmed || latest.status === "sent")
97
470
  return latest?.message ?? record.message;
98
471
  }
472
+ await this.checkAccess();
99
473
  if (this.values.get(hash)?.status !== "sending") {
100
474
  this.values.set(hash, sending);
101
475
  this.emit();
102
476
  }
477
+ let transmitting = false;
103
478
  try {
479
+ if (statefulGroup(record.message) && !this.firstDelivery.delete(hash)) {
480
+ const confirmed = await this.reconcile(record);
481
+ if (confirmed) {
482
+ await this.confirm([hash]);
483
+ return record.message;
484
+ }
485
+ }
486
+ if (hasVoice(record)) {
487
+ record = await this.prepareVoice(record);
488
+ if (record.confirmed || record.status === "sent")
489
+ return record.message;
490
+ }
491
+ await this.checkAccess();
492
+ if (this.closing)
493
+ throw new Error("CHAT_SENDER_CLOSED");
494
+ transmitting = true;
104
495
  const message = await this.client.sendPrepared(record.message);
105
496
  const sent = { ...record, message, status: "sent" };
106
- const committed = await this.save(sent);
107
- if (!this.closed) {
108
- if (committed?.confirmed || !committed)
109
- this.values.delete(hash);
110
- else
111
- this.values.set(hash, committed);
112
- this.emit();
113
- }
497
+ await this.save(sent);
114
498
  return message;
115
499
  }
116
500
  catch (error) {
117
- if (!this.closed) {
501
+ // A failed delivery can already be durable remotely, including the first
502
+ // lost ACK. Proof recovery never sends a second wire in this attempt.
503
+ if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
504
+ try {
505
+ if (await this.reconcile(record)) {
506
+ await this.confirm([hash]);
507
+ return record.message;
508
+ }
509
+ }
510
+ catch (verificationError) {
511
+ error = new AggregateError([error, verificationError], "OUTBOX_RECONCILIATION_FAILED");
512
+ }
513
+ }
514
+ if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
118
515
  const failed = { ...record, status: "failed" };
119
516
  const committed = await this.save(failed);
120
- if (committed?.confirmed || committed?.status === "sent") {
121
- this.values.delete(hash);
122
- this.emit();
517
+ if (committed?.confirmed || committed?.status === "sent")
123
518
  return committed.message;
124
- }
125
- if (committed)
126
- this.values.set(hash, committed);
127
- else
128
- this.values.delete(hash);
129
- this.emit();
130
519
  }
131
520
  throw error;
132
521
  }
133
522
  }
134
- async save(record) {
135
- return this.db.transaction("rw", this.outbox, async () => {
136
- const previous = await this.outbox.get([this.scope, record.hash]);
137
- if (!previous || previous.confirmed || previous.status === "sent")
138
- return previous;
139
- await this.outbox.put(record);
140
- return record;
141
- });
523
+ async reconcile(record) {
524
+ await this.checkAccess();
525
+ if (this.closing || this.closed || this.withdrawn)
526
+ throw new Error("OUTBOX_RECONCILIATION_ABORTED");
527
+ if (!this.options.reconcile)
528
+ throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
529
+ const controller = new AbortController();
530
+ this.reconciliations.add(controller);
531
+ let abort;
532
+ const stopped = new Promise((_, reject) => { abort = () => reject(new Error("OUTBOX_RECONCILIATION_ABORTED")); controller.signal.addEventListener("abort", abort, { once: true }); });
533
+ const timer = setTimeout(() => controller.abort(), 10_000);
534
+ try {
535
+ const confirmed = await Promise.race([this.options.reconcile({ threadId: record.threadId, message: record.message }, controller.signal), stopped]);
536
+ await this.checkAccess();
537
+ if (this.closing || controller.signal.aborted)
538
+ throw new Error("OUTBOX_RECONCILIATION_ABORTED");
539
+ return confirmed;
540
+ }
541
+ finally {
542
+ clearTimeout(timer);
543
+ controller.signal.removeEventListener("abort", abort);
544
+ this.reconciliations.delete(controller);
545
+ }
546
+ }
547
+ /** Only explicit playback/transcription acquires a local payload, never snapshots. */
548
+ async acquireVoice(hash, threadId) {
549
+ await this.ready;
550
+ await this.checkAccess();
551
+ const record = await this.queueIO(hash);
552
+ if (!record || record.threadId !== threadId || !hasVoice(record))
553
+ return undefined;
554
+ const releaseBudget = this.reserveAttachment(record), key = Symbol(hash);
555
+ let url, released = false;
556
+ const release = () => { if (released)
557
+ return; released = true; if (url)
558
+ URL.revokeObjectURL(url); releaseBudget(); this.attachmentLeases.delete(key); };
559
+ this.attachmentLeases.set(key, release);
560
+ try {
561
+ const blob = await this.readVoice(record);
562
+ await this.checkAccess();
563
+ if (released || this.closing)
564
+ throw new Error("CHAT_SENDER_CLOSED");
565
+ url = URL.createObjectURL(blob);
566
+ return { url, release };
567
+ }
568
+ catch (error) {
569
+ release();
570
+ throw error;
571
+ }
572
+ }
573
+ reserveAttachment(record) {
574
+ const size = record.voiceAttachment?.size ?? record.voiceData?.bytes.byteLength ?? record.voiceBlob?.size ?? 0;
575
+ if (!Number.isSafeInteger(size) || size <= 0 || size > 16 * 1024 * 1024)
576
+ throw new Error("INVALID_VOICE_ATTACHMENT");
577
+ // Charge both the native read buffer and Blob construction, including active leases.
578
+ const bytes = 2 * size;
579
+ if (this.attachmentBytes + bytes > 64 * 1024 * 1024)
580
+ throw new Error("VOICE_ATTACHMENT_BACKPRESSURE");
581
+ this.attachmentBytes += bytes;
582
+ let released = false;
583
+ return () => { if (!released) {
584
+ released = true;
585
+ this.attachmentBytes -= bytes;
586
+ } };
587
+ }
588
+ async readVoice(record) {
589
+ const row = await this.withEpoch("r", () => this.attachments.get([this.scope, record.hash]));
590
+ await this.checkAccess();
591
+ if (!row || row.accessVersion !== this.version || row.size !== record.voiceAttachment?.size || row.type !== record.voiceAttachment.type || (row.bytes?.byteLength ?? row.blob?.size) !== row.size)
592
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
593
+ return row.blob ?? new Blob([row.bytes], { type: row.type });
594
+ }
595
+ async prepareVoice(record) {
596
+ if (this.uploadWaiting >= 32)
597
+ throw new Error("VOICE_UPLOAD_BACKPRESSURE");
598
+ this.uploadWaiting++;
599
+ const previous = this.uploadTail;
600
+ let release;
601
+ this.uploadTail = new Promise(resolve => { release = resolve; });
602
+ try {
603
+ await previous;
604
+ await this.checkAccess();
605
+ if (this.closing)
606
+ throw new Error("CHAT_SENDER_CLOSED");
607
+ const latest = await this.queueIO(record.hash);
608
+ if (!latest)
609
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
610
+ if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
611
+ return latest;
612
+ if (!this.options.uploadVoice)
613
+ throw new Error("VOICE_UPLOAD_UNAVAILABLE");
614
+ const release = this.reserveAttachment(latest);
615
+ const controller = new AbortController();
616
+ this.uploads.add(controller);
617
+ const timeout = setTimeout(() => controller.abort(), 30_000);
618
+ let url;
619
+ try {
620
+ const blob = await this.readVoice(latest);
621
+ if (controller.signal.aborted || this.closing)
622
+ throw new Error("CHAT_SENDER_CLOSED");
623
+ url = await this.options.uploadVoice(blob, controller.signal);
624
+ }
625
+ finally {
626
+ release();
627
+ clearTimeout(timeout);
628
+ this.uploads.delete(controller);
629
+ }
630
+ const parsed = new URL(url);
631
+ if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
632
+ throw new Error("INVALID_VOICE_UPLOAD_URL");
633
+ await this.checkAccess();
634
+ if (this.closing)
635
+ throw new Error("CHAT_SENDER_CLOSED");
636
+ const { voiceBlob: _blob, voiceData: _data, voiceAttachment: _attachment, ...uploaded } = latest;
637
+ const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
638
+ if (!committed)
639
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
640
+ return committed;
641
+ }
642
+ finally {
643
+ this.uploadWaiting--;
644
+ release();
645
+ }
646
+ }
647
+ save(record) { return this.queueIO(record.hash, record); }
648
+ queueIO(hash, record, uploaded = false) {
649
+ if (this.closed || this.withdrawn)
650
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
651
+ const value = record ?? this.values.get(hash);
652
+ const bytes = value ? JSON.stringify(value.message).length * 3 + (value.voiceData?.bytes.byteLength ?? value.voiceBlob?.size ?? 0) : 0;
653
+ if (bytes > 20 * 1024 * 1024)
654
+ return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
655
+ if (this.ioCount >= 4096 || this.ioBytes + bytes > 64 * 1024 * 1024)
656
+ return Promise.reject(new Error("OUTBOX_IO_BACKPRESSURE"));
657
+ this.ioCount++;
658
+ this.ioBytes += bytes;
659
+ return new Promise((resolve, reject) => {
660
+ this.ioQueue.push({ hash, record, uploaded, bytes, resolve, reject });
661
+ this.scheduleIO();
662
+ }).finally(() => { this.ioCount--; this.ioBytes -= bytes; });
663
+ }
664
+ scheduleIO() {
665
+ if (this.ioTimer || this.ioRunning || !this.ioQueue.length)
666
+ return;
667
+ this.ioTimer = setTimeout(() => { this.ioTimer = undefined; void this.drainIO(); }, 16);
668
+ }
669
+ failIO(error) {
670
+ clearTimeout(this.ioTimer);
671
+ this.ioTimer = undefined;
672
+ for (const item of this.ioQueue.splice(0))
673
+ item.reject(error);
674
+ }
675
+ drainIO() {
676
+ const task = (async () => {
677
+ let count = 0, bytes = 0, operations = 0;
678
+ for (const item of this.ioQueue) {
679
+ if (operations + (item.uploaded ? 2 : 1) > 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
680
+ break;
681
+ count++;
682
+ bytes += item.bytes;
683
+ operations += item.uploaded ? 2 : 1;
684
+ }
685
+ const batch = this.ioQueue.splice(0, count);
686
+ try {
687
+ const records = await this.withEpoch(batch.some(item => item.record) ? "rw" : "r", async () => {
688
+ const hashes = [...new Set(batch.map(item => item.hash))];
689
+ const rows = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
690
+ const current = new Map(hashes.map((hash, index) => [hash, rows[index]]));
691
+ const writes = new Map(), result = [];
692
+ for (const item of batch) {
693
+ const previous = current.get(item.hash);
694
+ // Preserve another document's confirmation, sent status or uploaded URL.
695
+ if (item.record && previous && !previous.confirmed && previous.status !== "sent" && !(hasVoice(item.record) && !hasVoice(previous)) && !(item.uploaded && !hasVoice(previous))) {
696
+ current.set(item.hash, item.record);
697
+ writes.set(item.hash, item.record);
698
+ }
699
+ result.push(current.get(item.hash));
700
+ }
701
+ if (writes.size) {
702
+ await this.outbox.bulkPut([...writes.values()]);
703
+ const released = [...writes.values()].filter(record => !hasVoice(record) && hasVoice(rows[hashes.indexOf(record.hash)]));
704
+ if (released.length)
705
+ await this.attachments.bulkDelete(released.map(record => [this.scope, record.hash]));
706
+ }
707
+ return result;
708
+ });
709
+ await this.checkAccess();
710
+ let changed = false;
711
+ batch.forEach((item, index) => {
712
+ if (!item.record)
713
+ return;
714
+ const record = records[index], old = this.values.get(item.hash);
715
+ if (!record || record.confirmed) {
716
+ changed = this.values.delete(item.hash) || changed;
717
+ }
718
+ else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
719
+ this.values.set(item.hash, record);
720
+ changed = true;
721
+ }
722
+ });
723
+ if (changed)
724
+ this.emit();
725
+ batch.forEach((item, index) => item.resolve(records[index]));
726
+ }
727
+ catch (error) {
728
+ batch.forEach(item => item.reject(error));
729
+ }
730
+ })().finally(() => { this.ioRunning = undefined; this.scheduleIO(); });
731
+ this.ioRunning = task;
732
+ return task;
142
733
  }
143
734
  /** Confirm only after the authoritative message has been durably merged.
144
735
  * A tombstone prevents another tab's late failure from recreating the outbox. */
145
- async confirm(hashes) {
736
+ confirm(hashes) {
146
737
  hashes = [...new Set(hashes)];
147
738
  if (!hashes.length)
739
+ return Promise.resolve();
740
+ if (this.closed || this.closing || this.withdrawn)
741
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
742
+ if (this.confirmationSize + hashes.length > 4096)
743
+ return Promise.reject(new Error("OUTBOX_CONFIRM_BACKPRESSURE"));
744
+ const pending = new Promise((resolve, reject) => this.confirmationQueue.push({ hashes, resolve, reject }));
745
+ this.confirmationSize += hashes.length;
746
+ this.scheduleConfirmations();
747
+ return pending;
748
+ }
749
+ scheduleConfirmations() {
750
+ if (this.confirmationTimer !== undefined || this.confirmationRunning || !this.confirmationQueue.length)
148
751
  return;
149
- await this.ready;
150
- await this.db.transaction("rw", this.outbox, async () => {
151
- const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
152
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
153
- if (confirmed.length)
154
- await this.outbox.bulkPut(confirmed);
752
+ this.confirmationTimer = setTimeout(() => { this.confirmationTimer = undefined; void this.drainConfirmations(); }, 16);
753
+ }
754
+ drainConfirmations() {
755
+ if (this.confirmationRunning)
756
+ return this.confirmationRunning;
757
+ const batch = this.confirmationQueue.splice(0);
758
+ if (!batch.length)
759
+ return Promise.resolve();
760
+ const size = batch.reduce((sum, item) => sum + item.hashes.length, 0);
761
+ return this.confirmationRunning = (async () => {
762
+ let failed = false, error;
763
+ try {
764
+ await this.confirmNow([...new Set(batch.flatMap(item => item.hashes))]);
765
+ }
766
+ catch (reason) {
767
+ failed = true;
768
+ error = reason;
769
+ }
770
+ this.confirmationSize -= size;
771
+ for (const item of batch)
772
+ if (failed)
773
+ item.reject(error);
774
+ else
775
+ item.resolve();
776
+ })().finally(() => {
777
+ this.confirmationRunning = undefined;
778
+ this.scheduleConfirmations();
155
779
  });
780
+ }
781
+ failConfirmations(error) {
782
+ clearTimeout(this.confirmationTimer);
783
+ this.confirmationTimer = undefined;
784
+ for (const item of this.confirmationQueue.splice(0)) {
785
+ this.confirmationSize -= item.hashes.length;
786
+ item.reject(error);
787
+ }
788
+ }
789
+ async flushConfirmations() {
790
+ while (this.confirmationRunning || this.confirmationQueue.length) {
791
+ clearTimeout(this.confirmationTimer);
792
+ this.confirmationTimer = undefined;
793
+ await (this.confirmationRunning ?? this.drainConfirmations());
794
+ }
795
+ }
796
+ async confirmNow(hashes) {
797
+ await this.ready;
156
798
  let changed = false;
157
- for (const hash of hashes)
158
- changed = this.values.delete(hash) || changed;
159
- if (changed)
160
- this.emit();
799
+ try {
800
+ for (let offset = 0; offset < hashes.length;) {
801
+ let end = offset, operations = 0;
802
+ while (end < hashes.length) {
803
+ const known = this.values.get(hashes[end]), cost = known && !hasVoice(known) ? 1 : 2;
804
+ if (operations + cost > 128)
805
+ break;
806
+ operations += cost;
807
+ end++;
808
+ }
809
+ const batch = hashes.slice(offset, end);
810
+ await this.withEpoch("rw", async () => {
811
+ const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
812
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, voiceAttachment: undefined, status: "sent", confirmed: true }));
813
+ if (confirmed.length) {
814
+ await this.outbox.bulkPut(confirmed);
815
+ const voices = previous.filter((record) => Boolean(record && hasVoice(record)));
816
+ if (voices.length)
817
+ await this.attachments.bulkDelete(voices.map(record => [this.scope, record.hash]));
818
+ }
819
+ });
820
+ // Retire only committed rows. A later failed transaction leaves the
821
+ // remaining sends retryable, including after a browser restart.
822
+ for (const hash of batch) {
823
+ changed = this.values.delete(hash) || changed;
824
+ }
825
+ offset = end;
826
+ if (offset < hashes.length)
827
+ await new Promise(resolve => setTimeout(resolve, 0));
828
+ }
829
+ }
830
+ finally {
831
+ // One confirmation operation publishes at most once, also on partial
832
+ // failure so consumers see exactly the progress that was committed.
833
+ if (changed)
834
+ this.emit();
835
+ }
161
836
  }
162
837
  async resume() {
163
838
  await this.ready;
164
- if (this.closed)
839
+ if (this.closed || this.closing || this.withdrawn)
165
840
  return;
166
841
  // Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
167
842
  for (const record of this.values.values())
168
843
  if (record.status === "sending") {
169
- if (this.closed)
844
+ if (this.closed || this.withdrawn)
170
845
  return;
171
846
  try {
172
847
  await this.retry(record.hash);
@@ -177,11 +852,51 @@ export class DurableChatSender {
177
852
  }
178
853
  }
179
854
  async close() {
180
- this.closed = true;
855
+ this.closing = true;
856
+ this.attachmentLeases.forEach(release => release());
857
+ this.uploads.forEach(controller => controller.abort());
858
+ this.reconciliations.forEach(controller => controller.abort());
181
859
  this.offConnection();
182
860
  this.listeners.clear();
183
- await this.ready;
184
- await Promise.allSettled([...this.running.values()]);
185
- this.db.close();
861
+ this.channel?.close();
862
+ this.channel = undefined;
863
+ try {
864
+ await this.ready;
865
+ await this.flushConfirmations();
866
+ await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
867
+ }
868
+ finally {
869
+ this.closed = true;
870
+ this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
871
+ this.firstDelivery.clear();
872
+ this.attachmentLeases.forEach(release => release());
873
+ this.values.clear();
874
+ this.snapshots.clear();
875
+ this.db.close();
876
+ }
186
877
  }
187
878
  }
879
+ function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
880
+ function commandId(scope, version, threadId, message) {
881
+ return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
882
+ }
883
+ function ordered(value) {
884
+ if (Array.isArray(value))
885
+ return value.map(ordered);
886
+ if (value && typeof value === "object")
887
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, ordered(item)]));
888
+ return value;
889
+ }
890
+ function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
891
+ function withVoiceUrl(message, url) {
892
+ const body = message.body;
893
+ return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
894
+ }
895
+ function hasVoice(record) { return Boolean(record && (record.voiceBlob || record.voiceData || record.voiceAttachment)); }
896
+ function splitAttachment(record) {
897
+ const { voiceBlob, voiceData, ...metadata } = record;
898
+ if (!voiceBlob && !voiceData)
899
+ return { record: metadata };
900
+ const size = voiceData?.bytes.byteLength ?? voiceBlob.size, type = voiceData?.type ?? voiceBlob.type;
901
+ return { record: { ...metadata, pendingState: record.confirmed ? 0 : 1, voiceAttachment: { size, type } }, attachment: { scope: record.scope, hash: record.hash, accessVersion: record.accessVersion ?? 0, size, type, ...(voiceData ? { bytes: voiceData.bytes } : { blob: voiceBlob }) } };
902
+ }