@pmate/chat-sdk 0.12.0-next.57 → 0.12.0-next.59

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
@@ -254,3 +254,29 @@ permission change in another team does not discard this team's observations.
254
254
  Ordinary shutdown waits for accepted local reading writes, while access withdrawal
255
255
  rejects late results. Retired asynchronous reading entries reject through promises;
256
256
  refresh/subscription teardown remains safe.
257
+
258
+
259
+ ### Reading storage recovery
260
+
261
+ Large reading snapshots use a persisted journal. The full plan becomes durable
262
+ before canonical rows change; bounded transactions apply it, and the sync cursor
263
+ commits last. A reader recovers an active plan before returning a snapshot. Failed
264
+ staging leaves the previous snapshot authoritative; failure after activation keeps
265
+ the old cursor until recovery completes. Pending reads retain their operation IDs.
266
+
267
+ Writes use at most 128 records per transaction, including journal/lease records,
268
+ and conservatively budget one MiB per batch. Message order is stored per row so a
269
+ source no longer needs a growing array of every message ID. Ordinary observations
270
+ continue to update only positions and pending operations. Locks serialize each
271
+ owner across documents; a fenced IndexedDB lease supports contexts without Web Locks.
272
+
273
+ The private reading database format is now 60. Existing stores are renamed by
274
+ IndexedDB metadata operations, preserving keys and records. Older SDKs cannot
275
+ write through the former namespace and need a page reload to use the new format.
276
+ The message-body cache is separate and unchanged. The public reading state remains
277
+ schema version 1; applications do not select storage formats or recovery steps.
278
+
279
+
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
+
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.
@@ -6,7 +6,7 @@ import { type ReadCountsResult } from "./readCounts.js";
6
6
  import type { OnlineReachEvent } from "./reach.js";
7
7
  import { type MessageReadersQuery } from "./messageReaders.js";
8
8
  import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
9
- import { DurableChatSender, type SendMessageInput, type DurableSenderOptions } from "./durableSender.js";
9
+ import { DurableChatSender, type SendMessageInput, type DurableSenderOptions, type VoiceAttachmentLease } from "./durableSender.js";
10
10
  import { ReadingClient, type ReadingClientOptions } from "./readingClient.js";
11
11
  import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
12
12
  import { unreadInConversation } from "./readIndex.js";
@@ -124,6 +124,8 @@ export declare class ChatSession<T extends StoredMessage> {
124
124
  sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
125
125
  sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
126
126
  getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
127
+ /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
128
+ getVoiceAttachment(threadId: string, messageId: string): Promise<VoiceAttachmentLease>;
127
129
  retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
128
130
  observeMessages(threadId: string, ids: string[], event: "visible" | "played"): ReturnType<ReadingClient["recordVisible"]>;
129
131
  observeMessages(threadId: string | undefined, ids: string[], event: "receipts", clientInstanceId?: string): Promise<void>;
@@ -380,6 +380,37 @@ export class ChatSession {
380
380
  }
381
381
  return result;
382
382
  }
383
+ /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
384
+ async getVoiceAttachment(threadId, messageId) {
385
+ this.assertOpen();
386
+ const item = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
387
+ if (!item || item.recalled)
388
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
389
+ const local = await this.sender.acquireVoice(messageId, threadId);
390
+ try {
391
+ this.assertOpen();
392
+ }
393
+ catch (error) {
394
+ local?.release();
395
+ throw error;
396
+ }
397
+ const stillVisible = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
398
+ if (!stillVisible || stillVisible.recalled) {
399
+ local?.release();
400
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
401
+ }
402
+ if (local)
403
+ return local;
404
+ const current = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
405
+ const body = current?.message?.body;
406
+ const value = body?.content?.voice?.url;
407
+ if (!current || current.recalled || typeof value !== "string")
408
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
409
+ const url = new URL(value);
410
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
411
+ throw new Error("INVALID_VOICE_ATTACHMENT_URL");
412
+ return { url: value, release: () => { } };
413
+ }
383
414
  retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
384
415
  observeMessages(threadId, ids, event, clientInstanceId) {
385
416
  if (this.closed || this.withdrawn) {
@@ -15,6 +15,14 @@ export type OutgoingMessage = {
15
15
  bytes: ArrayBuffer;
16
16
  type: string;
17
17
  };
18
+ voiceAttachment?: {
19
+ size: number;
20
+ type: string;
21
+ };
22
+ };
23
+ export type VoiceAttachmentLease = {
24
+ url: string;
25
+ release: () => void;
18
26
  };
19
27
  export type SendMessageInput = {
20
28
  to: string;
@@ -40,9 +48,11 @@ export declare class DurableChatSender {
40
48
  private options;
41
49
  private db;
42
50
  private outbox;
51
+ private attachments;
52
+ private schemaReady;
53
+ private attachmentBytes;
54
+ private attachmentLeases;
43
55
  private epochs;
44
- private localUrls;
45
- private localViews;
46
56
  private uploads;
47
57
  private reconciliations;
48
58
  private firstDelivery;
@@ -104,9 +114,10 @@ export declare class DurableChatSender {
104
114
  retry(hash: string): Promise<ChatRuntimeMessage>;
105
115
  private transmit;
106
116
  private reconcile;
107
- private releaseUrl;
108
- private releaseUrls;
109
- private localView;
117
+ /** Only explicit playback/transcription acquires a local payload, never snapshots. */
118
+ acquireVoice(hash: string, threadId: string): Promise<VoiceAttachmentLease | undefined>;
119
+ private reserveAttachment;
120
+ private readVoice;
110
121
  private prepareVoice;
111
122
  private save;
112
123
  private queueIO;
@@ -1,4 +1,5 @@
1
1
  import { Dexie } from "dexie";
2
+ import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
2
3
  import { blake3 } from "@noble/hashes/blake3.js";
3
4
  import { bytesToHex } from "@noble/hashes/utils.js";
4
5
  import { withChatTeam } from "./teamScope.js";
@@ -10,9 +11,11 @@ export class DurableChatSender {
10
11
  options;
11
12
  db;
12
13
  outbox;
14
+ attachments;
15
+ schemaReady;
16
+ attachmentBytes = 0;
17
+ attachmentLeases = new Map();
13
18
  epochs;
14
- localUrls = new Map();
15
- localViews = new WeakMap();
16
19
  uploads = new Set();
17
20
  reconciliations = new Set();
18
21
  firstDelivery = new Set();
@@ -54,11 +57,11 @@ export class DurableChatSender {
54
57
  this.options = options;
55
58
  this.scope = messageScopeKey(scope);
56
59
  this.identity = { ...scope };
57
- this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
58
- this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
59
- this.db.version(2).stores({ scopeEpochs: "scope" });
60
- this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
61
- this.outbox = this.db.table("outbox");
60
+ const factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
61
+ this.schemaReady = prepareOutboxSchema(factory, name);
62
+ this.db = outboxDatabase(factory, name);
63
+ this.outbox = this.db.table("outgoingMessages");
64
+ this.attachments = this.db.table("outgoingAttachments");
62
65
  this.epochs = this.db.table("scopeEpochs");
63
66
  this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
64
67
  this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
@@ -79,16 +82,19 @@ export class DurableChatSender {
79
82
  static async readSelected(scope, threadId, ids, version, options = {}) {
80
83
  if (ids.length > 100)
81
84
  throw new Error("INVALID_OUTBOX_SELECTION");
82
- const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
83
- db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
84
- db.version(2).stores({ scopeEpochs: "scope" });
85
- db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
85
+ const key = messageScopeKey(scope), factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
86
+ await prepareOutboxSchema(factory, name);
87
+ const db = outboxDatabase(factory, name);
86
88
  try {
87
- return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
89
+ return await db.transaction("r", db.table("outgoingMessages"), db.table("scopeEpochs"), async () => {
88
90
  if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
89
91
  return [];
90
- const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
91
- return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
92
+ const rows = [];
93
+ for (const id of ids) {
94
+ const row = await db.table("outgoingMessages").get([key, id]);
95
+ rows.push(row ? splitAttachment(row).record : undefined);
96
+ }
97
+ return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [splitAttachment(row).record] : []);
92
98
  });
93
99
  }
94
100
  finally {
@@ -102,8 +108,9 @@ export class DurableChatSender {
102
108
  return access;
103
109
  }
104
110
  async restore() {
111
+ await this.schemaReady;
105
112
  const access = await this.readAccess();
106
- const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
113
+ const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
107
114
  const current = (await this.epochs.get(this.scope))?.version ?? 0;
108
115
  const version = access?.version ?? current;
109
116
  if (version < current)
@@ -129,12 +136,12 @@ export class DurableChatSender {
129
136
  let after;
130
137
  const restored = new Map();
131
138
  while (!this.closed && !this.withdrawn) {
132
- const batch = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
139
+ const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
133
140
  if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
134
141
  throw new Error("OUTBOX_SCOPE_CHANGED");
135
142
  const keys = await (loaded.legacy
136
143
  ? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
137
- : this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(128).primaryKeys();
144
+ : this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(64).primaryKeys();
138
145
  if (!keys.length)
139
146
  return { last: undefined, records: [] };
140
147
  const last = keys.at(-1)[1];
@@ -143,11 +150,36 @@ export class DurableChatSender {
143
150
  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() : [];
144
151
  const confirmedHashes = new Set(confirmed.map(key => key[1]));
145
152
  const needed = keys.filter(key => !confirmedHashes.has(key[1]));
146
- const rows = (await this.outbox.bulkGet(needed)).filter((row) => Boolean(row));
147
- const repairs = rows.filter(row => row.pendingState !== (row.confirmed ? 0 : 1));
148
- if (repairs.length)
149
- await this.outbox.bulkPut(repairs.map(row => ({ ...row, pendingState: row.confirmed ? 0 : 1 })));
150
- return { last, records: rows.filter(row => !row.confirmed && (row.accessVersion ?? 0) === this.version) };
153
+ const records = [], writes = [], payloads = [];
154
+ let bytes = 0, processed = last;
155
+ for (const key of needed) {
156
+ const row = await this.outbox.get(key);
157
+ if (!row)
158
+ continue;
159
+ const size = row.voiceData?.bytes.byteLength ?? row.voiceBlob?.size ?? 0;
160
+ if (records.length && bytes + 2 * size > 16 * 1024 * 1024) {
161
+ processed = records.at(-1).hash;
162
+ break;
163
+ }
164
+ if (!row.confirmed && (row.accessVersion ?? 0) === this.version) {
165
+ const split = splitAttachment(row);
166
+ records.push(split.record);
167
+ bytes += 2 * size;
168
+ if (split.attachment) {
169
+ payloads.push(split.attachment);
170
+ writes.push(split.record);
171
+ }
172
+ else if (row.pendingState !== (row.confirmed ? 0 : 1))
173
+ writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
174
+ }
175
+ else if (row.pendingState !== (row.confirmed ? 0 : 1))
176
+ writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
177
+ }
178
+ if (payloads.length)
179
+ await this.attachments.bulkPut(payloads);
180
+ if (writes.length)
181
+ await this.outbox.bulkPut(writes);
182
+ return { last: processed, records };
151
183
  });
152
184
  for (const row of batch.records)
153
185
  restored.set(row.hash, row);
@@ -181,7 +213,7 @@ export class DurableChatSender {
181
213
  async withEpoch(mode, operation) {
182
214
  await this.checkAccess();
183
215
  try {
184
- return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
216
+ return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, async () => {
185
217
  if (this.closed || this.withdrawn)
186
218
  throw new Error("OUTBOX_ACCESS_REVOKED");
187
219
  if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
@@ -195,7 +227,10 @@ export class DurableChatSender {
195
227
  throw error;
196
228
  }
197
229
  }
198
- deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
230
+ async deleteRows() {
231
+ await this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
232
+ await this.attachments.where("scope").equals(this.scope).delete();
233
+ }
199
234
  /** Stop disclosure immediately; durable invalidation is coordinated separately. */
200
235
  withdraw() {
201
236
  this.withdrawn = true;
@@ -205,7 +240,7 @@ export class DurableChatSender {
205
240
  this.uploads.forEach(controller => controller.abort());
206
241
  this.reconciliations.forEach(controller => controller.abort());
207
242
  this.firstDelivery.clear();
208
- this.releaseUrls();
243
+ this.attachmentLeases.forEach(release => release());
209
244
  this.values.clear();
210
245
  this.emit();
211
246
  }
@@ -218,7 +253,7 @@ export class DurableChatSender {
218
253
  return this.revoking = (async () => {
219
254
  await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
220
255
  throw error; });
221
- const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
256
+ const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
222
257
  const current = (await this.epochs.get(this.scope))?.version ?? 0;
223
258
  const target = targetVersion ?? this.version + 1;
224
259
  if (!Number.isSafeInteger(target) || target < 0)
@@ -239,7 +274,7 @@ export class DurableChatSender {
239
274
  return this.empty;
240
275
  let snapshot = this.snapshots.get(threadId);
241
276
  if (!snapshot) {
242
- snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId).map(record => this.localView(record));
277
+ snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
243
278
  this.snapshots.set(threadId, snapshot);
244
279
  }
245
280
  return snapshot;
@@ -313,13 +348,14 @@ export class DurableChatSender {
313
348
  }
314
349
  drainAcceptances() {
315
350
  const task = (async () => {
316
- let bytes = 0, count = 0;
351
+ let bytes = 0, count = 0, operations = 0;
317
352
  for (const item of this.acceptanceQueue) {
318
353
  const size = item.bytes;
319
- if (count >= 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
354
+ if (operations + (hasVoice(item.record) ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
320
355
  break;
321
356
  bytes += size;
322
357
  count++;
358
+ operations += hasVoice(item.record) ? 2 : 1;
323
359
  }
324
360
  const batch = this.acceptanceQueue.splice(0, count);
325
361
  try {
@@ -342,9 +378,13 @@ export class DurableChatSender {
342
378
  known.set(record.hash, record);
343
379
  }
344
380
  }
345
- if (writes.length)
346
- await this.outbox.bulkPut(writes);
347
- return { writes, records };
381
+ const split = writes.map(splitAttachment), payloads = split.flatMap(item => item.attachment ? [item.attachment] : []);
382
+ if (payloads.length)
383
+ await this.attachments.bulkPut(payloads);
384
+ if (split.length)
385
+ await this.outbox.bulkPut(split.map(item => item.record));
386
+ const metadata = new Map(split.map(item => [item.record.hash, item.record]));
387
+ return { writes: split.map(item => item.record), records: records.map(record => metadata.get(record.hash) ?? record) };
348
388
  });
349
389
  await this.checkAccess();
350
390
  for (const record of result.writes)
@@ -475,28 +515,53 @@ export class DurableChatSender {
475
515
  this.reconciliations.delete(controller);
476
516
  }
477
517
  }
478
- releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
479
- URL.revokeObjectURL(value.url);
480
- this.localUrls.delete(hash);
481
- } }
482
- releaseUrls() { for (const hash of this.localUrls.keys())
483
- this.releaseUrl(hash); }
484
- localView(record) {
485
- if (!hasVoice(record) || typeof URL.createObjectURL !== "function")
486
- return record;
487
- let cached = this.localViews.get(record);
488
- if (cached)
489
- return cached;
490
- const blob = voiceBlob(record);
491
- let local = this.localUrls.get(record.hash);
492
- if (!local || local.blob !== blob) {
493
- this.releaseUrl(record.hash);
494
- local = { blob, url: URL.createObjectURL(blob) };
495
- this.localUrls.set(record.hash, local);
518
+ /** Only explicit playback/transcription acquires a local payload, never snapshots. */
519
+ async acquireVoice(hash, threadId) {
520
+ await this.ready;
521
+ await this.checkAccess();
522
+ const record = await this.queueIO(hash);
523
+ if (!record || record.threadId !== threadId || !hasVoice(record))
524
+ return undefined;
525
+ const releaseBudget = this.reserveAttachment(record), key = Symbol(hash);
526
+ let url, released = false;
527
+ const release = () => { if (released)
528
+ return; released = true; if (url)
529
+ URL.revokeObjectURL(url); releaseBudget(); this.attachmentLeases.delete(key); };
530
+ this.attachmentLeases.set(key, release);
531
+ try {
532
+ const blob = await this.readVoice(record);
533
+ await this.checkAccess();
534
+ if (released || this.closing)
535
+ throw new Error("CHAT_SENDER_CLOSED");
536
+ url = URL.createObjectURL(blob);
537
+ return { url, release };
496
538
  }
497
- cached = { ...record, voiceBlob: blob, message: withVoiceUrl(record.message, local.url) };
498
- this.localViews.set(record, cached);
499
- return cached;
539
+ catch (error) {
540
+ release();
541
+ throw error;
542
+ }
543
+ }
544
+ reserveAttachment(record) {
545
+ const size = record.voiceAttachment?.size ?? record.voiceData?.bytes.byteLength ?? record.voiceBlob?.size ?? 0;
546
+ if (!Number.isSafeInteger(size) || size <= 0 || size > 16 * 1024 * 1024)
547
+ throw new Error("INVALID_VOICE_ATTACHMENT");
548
+ // Charge both the native read buffer and Blob construction, including active leases.
549
+ const bytes = 2 * size;
550
+ if (this.attachmentBytes + bytes > 64 * 1024 * 1024)
551
+ throw new Error("VOICE_ATTACHMENT_BACKPRESSURE");
552
+ this.attachmentBytes += bytes;
553
+ let released = false;
554
+ return () => { if (!released) {
555
+ released = true;
556
+ this.attachmentBytes -= bytes;
557
+ } };
558
+ }
559
+ async readVoice(record) {
560
+ const row = await this.withEpoch("r", () => this.attachments.get([this.scope, record.hash]));
561
+ await this.checkAccess();
562
+ if (!row || row.accessVersion !== this.version || row.size !== record.voiceAttachment?.size || row.type !== record.voiceAttachment.type || (row.bytes?.byteLength ?? row.blob?.size) !== row.size)
563
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
564
+ return row.blob ?? new Blob([row.bytes], { type: row.type });
500
565
  }
501
566
  async prepareVoice(record) {
502
567
  if (this.uploadWaiting >= 32)
@@ -517,14 +582,19 @@ export class DurableChatSender {
517
582
  return latest;
518
583
  if (!this.options.uploadVoice)
519
584
  throw new Error("VOICE_UPLOAD_UNAVAILABLE");
585
+ const release = this.reserveAttachment(latest);
520
586
  const controller = new AbortController();
521
587
  this.uploads.add(controller);
522
588
  const timeout = setTimeout(() => controller.abort(), 30_000);
523
589
  let url;
524
590
  try {
525
- url = await this.options.uploadVoice(voiceBlob(latest), controller.signal);
591
+ const blob = await this.readVoice(latest);
592
+ if (controller.signal.aborted || this.closing)
593
+ throw new Error("CHAT_SENDER_CLOSED");
594
+ url = await this.options.uploadVoice(blob, controller.signal);
526
595
  }
527
596
  finally {
597
+ release();
528
598
  clearTimeout(timeout);
529
599
  this.uploads.delete(controller);
530
600
  }
@@ -534,7 +604,7 @@ export class DurableChatSender {
534
604
  await this.checkAccess();
535
605
  if (this.closing)
536
606
  throw new Error("CHAT_SENDER_CLOSED");
537
- const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
607
+ const { voiceBlob: _blob, voiceData: _data, voiceAttachment: _attachment, ...uploaded } = latest;
538
608
  const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
539
609
  if (!committed)
540
610
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
@@ -575,12 +645,13 @@ export class DurableChatSender {
575
645
  }
576
646
  drainIO() {
577
647
  const task = (async () => {
578
- let count = 0, bytes = 0;
648
+ let count = 0, bytes = 0, operations = 0;
579
649
  for (const item of this.ioQueue) {
580
- if (count >= 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
650
+ if (operations + (item.uploaded ? 2 : 1) > 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
581
651
  break;
582
652
  count++;
583
653
  bytes += item.bytes;
654
+ operations += item.uploaded ? 2 : 1;
584
655
  }
585
656
  const batch = this.ioQueue.splice(0, count);
586
657
  try {
@@ -598,8 +669,12 @@ export class DurableChatSender {
598
669
  }
599
670
  result.push(current.get(item.hash));
600
671
  }
601
- if (writes.size)
672
+ if (writes.size) {
602
673
  await this.outbox.bulkPut([...writes.values()]);
674
+ const released = [...writes.values()].filter(record => !hasVoice(record) && hasVoice(rows[hashes.indexOf(record.hash)]));
675
+ if (released.length)
676
+ await this.attachments.bulkDelete(released.map(record => [this.scope, record.hash]));
677
+ }
603
678
  return result;
604
679
  });
605
680
  await this.checkAccess();
@@ -610,7 +685,6 @@ export class DurableChatSender {
610
685
  const record = records[index], old = this.values.get(item.hash);
611
686
  if (!record || record.confirmed) {
612
687
  changed = this.values.delete(item.hash) || changed;
613
- this.releaseUrl(item.hash);
614
688
  }
615
689
  else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
616
690
  this.values.set(item.hash, record);
@@ -694,21 +768,33 @@ export class DurableChatSender {
694
768
  await this.ready;
695
769
  let changed = false;
696
770
  try {
697
- for (let offset = 0; offset < hashes.length; offset += 128) {
698
- const batch = hashes.slice(offset, offset + 128);
771
+ for (let offset = 0; offset < hashes.length;) {
772
+ let end = offset, operations = 0;
773
+ while (end < hashes.length) {
774
+ const known = this.values.get(hashes[end]), cost = known && !hasVoice(known) ? 1 : 2;
775
+ if (operations + cost > 128)
776
+ break;
777
+ operations += cost;
778
+ end++;
779
+ }
780
+ const batch = hashes.slice(offset, end);
699
781
  await this.withEpoch("rw", async () => {
700
782
  const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
701
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
702
- if (confirmed.length)
783
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, voiceAttachment: undefined, status: "sent", confirmed: true }));
784
+ if (confirmed.length) {
703
785
  await this.outbox.bulkPut(confirmed);
786
+ const voices = previous.filter((record) => Boolean(record && hasVoice(record)));
787
+ if (voices.length)
788
+ await this.attachments.bulkDelete(voices.map(record => [this.scope, record.hash]));
789
+ }
704
790
  });
705
791
  // Retire only committed rows. A later failed transaction leaves the
706
792
  // remaining sends retryable, including after a browser restart.
707
793
  for (const hash of batch) {
708
794
  changed = this.values.delete(hash) || changed;
709
- this.releaseUrl(hash);
710
795
  }
711
- if (offset + batch.length < hashes.length)
796
+ offset = end;
797
+ if (offset < hashes.length)
712
798
  await new Promise(resolve => setTimeout(resolve, 0));
713
799
  }
714
800
  }
@@ -738,6 +824,7 @@ export class DurableChatSender {
738
824
  }
739
825
  async close() {
740
826
  this.closing = true;
827
+ this.attachmentLeases.forEach(release => release());
741
828
  this.uploads.forEach(controller => controller.abort());
742
829
  this.reconciliations.forEach(controller => controller.abort());
743
830
  this.offConnection();
@@ -753,7 +840,7 @@ export class DurableChatSender {
753
840
  this.closed = true;
754
841
  this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
755
842
  this.firstDelivery.clear();
756
- this.releaseUrls();
843
+ this.attachmentLeases.forEach(release => release());
757
844
  this.values.clear();
758
845
  this.snapshots.clear();
759
846
  this.db.close();
@@ -776,5 +863,11 @@ function withVoiceUrl(message, url) {
776
863
  const body = message.body;
777
864
  return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
778
865
  }
779
- function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
780
- function voiceBlob(record) { return record.voiceBlob ?? new Blob([record.voiceData.bytes], { type: record.voiceData.type }); }
866
+ function hasVoice(record) { return Boolean(record && (record.voiceBlob || record.voiceData || record.voiceAttachment)); }
867
+ function splitAttachment(record) {
868
+ const { voiceBlob, voiceData, ...metadata } = record;
869
+ if (!voiceBlob && !voiceData)
870
+ return { record: metadata };
871
+ const size = voiceData?.bytes.byteLength ?? voiceBlob.size, type = voiceData?.type ?? voiceBlob.type;
872
+ 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 }) } };
873
+ }
@@ -0,0 +1,5 @@
1
+ import { Dexie } from "dexie";
2
+ /** Rename canonical rows without copying attachments in a versionchange transaction.
3
+ * An old sender must fail closed, rather than send a metadata-only voice wire. */
4
+ export declare function prepareOutboxSchema(factory: IDBFactory, name: string): Promise<void>;
5
+ export declare function outboxDatabase(factory: IDBFactory, name: string): Dexie;
@@ -0,0 +1,58 @@
1
+ import { Dexie } from "dexie";
2
+ const messageSchema = "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]";
3
+ /** Rename canonical rows without copying attachments in a versionchange transaction.
4
+ * An old sender must fail closed, rather than send a metadata-only voice wire. */
5
+ export async function prepareOutboxSchema(factory, name) {
6
+ const existing = await new Promise((resolve, reject) => {
7
+ const request = factory.open(name);
8
+ let fresh = false;
9
+ request.onupgradeneeded = event => { if (event.oldVersion === 0) {
10
+ fresh = true;
11
+ request.transaction.abort();
12
+ } };
13
+ request.onerror = event => { event.preventDefault(); if (fresh)
14
+ resolve(undefined);
15
+ else
16
+ reject(request.error); };
17
+ request.onsuccess = () => resolve(request.result);
18
+ });
19
+ if (!existing)
20
+ return;
21
+ const version = existing.version, prepared = existing.objectStoreNames.contains("outgoingMessages") && existing.objectStoreNames.contains("outgoingAttachments");
22
+ existing.close();
23
+ if (version >= 40 && prepared)
24
+ return;
25
+ await new Promise((resolve, reject) => {
26
+ const request = factory.open(name, Math.max(40, version + 1));
27
+ request.onerror = () => reject(request.error);
28
+ request.onupgradeneeded = () => {
29
+ const db = request.result, tx = request.transaction;
30
+ if (db.objectStoreNames.contains("outbox")) {
31
+ if (db.objectStoreNames.contains("outgoingMessages")) {
32
+ tx.abort();
33
+ return;
34
+ }
35
+ tx.objectStore("outbox").name = "outgoingMessages";
36
+ }
37
+ db.createObjectStore("outbox", { keyPath: "unsupportedOutboxFormat" });
38
+ const define = (name, keyPath, indexes) => {
39
+ const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
40
+ for (const [index, path] of Object.entries(indexes))
41
+ if (!store.indexNames.contains(index))
42
+ store.createIndex(index, path);
43
+ };
44
+ define("outgoingMessages", ["scope", "hash"], { scope: "scope", "[scope+threadId]": ["scope", "threadId"], "[scope+pendingState+hash]": ["scope", "pendingState", "hash"] });
45
+ define("outgoingAttachments", ["scope", "hash"], { scope: "scope" });
46
+ define("scopeEpochs", "scope", {});
47
+ };
48
+ request.onsuccess = () => { request.result.close(); resolve(); };
49
+ });
50
+ }
51
+ export function outboxDatabase(factory, name) {
52
+ const db = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
53
+ db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
54
+ db.version(2).stores({ scopeEpochs: "scope" });
55
+ db.version(3).stores({ outbox: messageSchema });
56
+ db.version(4).stores({ outbox: "unsupportedOutboxFormat", outgoingMessages: messageSchema, outgoingAttachments: "[scope+hash],scope" });
57
+ return db;
58
+ }