@pmate/chat-sdk 0.12.0-next.55 → 0.12.0-next.56

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.
@@ -46,6 +46,11 @@ export declare class DurableChatSender {
46
46
  private uploads;
47
47
  private reconciliations;
48
48
  private firstDelivery;
49
+ private ioQueue;
50
+ private ioTimer?;
51
+ private ioRunning?;
52
+ private ioCount;
53
+ private ioBytes;
49
54
  private acceptanceQueue;
50
55
  private acceptanceTimer?;
51
56
  private acceptanceRunning?;
@@ -104,6 +109,10 @@ export declare class DurableChatSender {
104
109
  private localView;
105
110
  private prepareVoice;
106
111
  private save;
112
+ private queueIO;
113
+ private scheduleIO;
114
+ private failIO;
115
+ private drainIO;
107
116
  /** Confirm only after the authoritative message has been durably merged.
108
117
  * A tombstone prevents another tab's late failure from recreating the outbox. */
109
118
  confirm(hashes: string[]): Promise<void>;
@@ -16,6 +16,11 @@ export class DurableChatSender {
16
16
  uploads = new Set();
17
17
  reconciliations = new Set();
18
18
  firstDelivery = new Set();
19
+ ioQueue = [];
20
+ ioTimer;
21
+ ioRunning;
22
+ ioCount = 0;
23
+ ioBytes = 0;
19
24
  acceptanceQueue = [];
20
25
  acceptanceTimer;
21
26
  acceptanceRunning;
@@ -194,6 +199,7 @@ export class DurableChatSender {
194
199
  /** Stop disclosure immediately; durable invalidation is coordinated separately. */
195
200
  withdraw() {
196
201
  this.withdrawn = true;
202
+ this.failIO(new Error("OUTBOX_ACCESS_REVOKED"));
197
203
  this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
198
204
  this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
199
205
  this.uploads.forEach(controller => controller.abort());
@@ -257,7 +263,15 @@ export class DurableChatSender {
257
263
  return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
258
264
  if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
259
265
  return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
260
- const bytes = input.voiceBlob?.size ?? 0;
266
+ let bytes;
267
+ try {
268
+ bytes = JSON.stringify([input.to, input.body, input.recipients, this.identity]).length * 3 + 1024 + (input.voiceBlob?.size ?? 0);
269
+ }
270
+ catch (error) {
271
+ return Promise.reject(error);
272
+ }
273
+ if (bytes > 20 * 1024 * 1024)
274
+ return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
261
275
  if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
262
276
  return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
263
277
  this.acceptanceCount++;
@@ -278,7 +292,7 @@ export class DurableChatSender {
278
292
  }
279
293
  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 };
280
294
  const durable = await new Promise((resolve, reject) => {
281
- this.acceptanceQueue.push({ record, resolve, reject });
295
+ this.acceptanceQueue.push({ record, bytes, resolve, reject });
282
296
  this.scheduleAcceptances();
283
297
  });
284
298
  return durable.message;
@@ -301,8 +315,8 @@ export class DurableChatSender {
301
315
  const task = (async () => {
302
316
  let bytes = 0, count = 0;
303
317
  for (const item of this.acceptanceQueue) {
304
- const size = item.record.voiceData?.bytes.byteLength ?? 0;
305
- if (count >= 128 || (count > 0 && bytes + size > 16 * 1024 * 1024))
318
+ const size = item.bytes;
319
+ if (count >= 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
306
320
  break;
307
321
  bytes += size;
308
322
  count++;
@@ -373,7 +387,7 @@ export class DurableChatSender {
373
387
  }
374
388
  async transmit(hash) {
375
389
  await this.ready;
376
- let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
390
+ let record = await this.queueIO(hash);
377
391
  if (!record || this.closed || this.withdrawn)
378
392
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
379
393
  if (record.status === "sent" || record.confirmed) {
@@ -411,14 +425,7 @@ export class DurableChatSender {
411
425
  transmitting = true;
412
426
  const message = await this.client.sendPrepared(record.message);
413
427
  const sent = { ...record, message, status: "sent" };
414
- const committed = await this.save(sent);
415
- if (!this.closed && !this.withdrawn) {
416
- if (committed?.confirmed || !committed)
417
- this.values.delete(hash);
418
- else
419
- this.values.set(hash, committed);
420
- this.emit();
421
- }
428
+ await this.save(sent);
422
429
  return message;
423
430
  }
424
431
  catch (error) {
@@ -438,16 +445,8 @@ export class DurableChatSender {
438
445
  if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
439
446
  const failed = { ...record, status: "failed" };
440
447
  const committed = await this.save(failed);
441
- if (committed?.confirmed || committed?.status === "sent") {
442
- this.values.delete(hash);
443
- this.emit();
448
+ if (committed?.confirmed || committed?.status === "sent")
444
449
  return committed.message;
445
- }
446
- if (committed)
447
- this.values.set(hash, committed);
448
- else
449
- this.values.delete(hash);
450
- this.emit();
451
450
  }
452
451
  throw error;
453
452
  }
@@ -511,7 +510,7 @@ export class DurableChatSender {
511
510
  await this.checkAccess();
512
511
  if (this.closing)
513
512
  throw new Error("CHAT_SENDER_CLOSED");
514
- const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
513
+ const latest = await this.queueIO(record.hash);
515
514
  if (!latest)
516
515
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
517
516
  if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
@@ -536,18 +535,9 @@ export class DurableChatSender {
536
535
  if (this.closing)
537
536
  throw new Error("CHAT_SENDER_CLOSED");
538
537
  const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
539
- const committed = await this.withEpoch("rw", async () => {
540
- const current = await this.outbox.get([this.scope, record.hash]);
541
- if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
542
- return current;
543
- const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
544
- await this.outbox.put(next);
545
- return next;
546
- });
538
+ const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
547
539
  if (!committed)
548
540
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
549
- this.values.set(record.hash, committed);
550
- this.emit();
551
541
  return committed;
552
542
  }
553
543
  finally {
@@ -555,17 +545,88 @@ export class DurableChatSender {
555
545
  release();
556
546
  }
557
547
  }
558
- async save(record) {
559
- return this.withEpoch("rw", async () => {
560
- const previous = await this.outbox.get([this.scope, record.hash]);
561
- if (!previous || previous.confirmed || previous.status === "sent")
562
- return previous;
563
- // A second document may already have durably replaced the Blob with its URL.
564
- if (hasVoice(record) && !hasVoice(previous))
565
- return previous;
566
- await this.outbox.put(record);
567
- return record;
568
- });
548
+ save(record) { return this.queueIO(record.hash, record); }
549
+ queueIO(hash, record, uploaded = false) {
550
+ if (this.closed || this.withdrawn)
551
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
552
+ const value = record ?? this.values.get(hash);
553
+ const bytes = value ? JSON.stringify(value.message).length * 3 + (value.voiceData?.bytes.byteLength ?? value.voiceBlob?.size ?? 0) : 0;
554
+ if (bytes > 20 * 1024 * 1024)
555
+ return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
556
+ if (this.ioCount >= 4096 || this.ioBytes + bytes > 64 * 1024 * 1024)
557
+ return Promise.reject(new Error("OUTBOX_IO_BACKPRESSURE"));
558
+ this.ioCount++;
559
+ this.ioBytes += bytes;
560
+ return new Promise((resolve, reject) => {
561
+ this.ioQueue.push({ hash, record, uploaded, bytes, resolve, reject });
562
+ this.scheduleIO();
563
+ }).finally(() => { this.ioCount--; this.ioBytes -= bytes; });
564
+ }
565
+ scheduleIO() {
566
+ if (this.ioTimer || this.ioRunning || !this.ioQueue.length)
567
+ return;
568
+ this.ioTimer = setTimeout(() => { this.ioTimer = undefined; void this.drainIO(); }, 16);
569
+ }
570
+ failIO(error) {
571
+ clearTimeout(this.ioTimer);
572
+ this.ioTimer = undefined;
573
+ for (const item of this.ioQueue.splice(0))
574
+ item.reject(error);
575
+ }
576
+ drainIO() {
577
+ const task = (async () => {
578
+ let count = 0, bytes = 0;
579
+ for (const item of this.ioQueue) {
580
+ if (count >= 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
581
+ break;
582
+ count++;
583
+ bytes += item.bytes;
584
+ }
585
+ const batch = this.ioQueue.splice(0, count);
586
+ try {
587
+ const records = await this.withEpoch(batch.some(item => item.record) ? "rw" : "r", async () => {
588
+ const hashes = [...new Set(batch.map(item => item.hash))];
589
+ const rows = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
590
+ const current = new Map(hashes.map((hash, index) => [hash, rows[index]]));
591
+ const writes = new Map(), result = [];
592
+ for (const item of batch) {
593
+ const previous = current.get(item.hash);
594
+ // Preserve another document's confirmation, sent status or uploaded URL.
595
+ if (item.record && previous && !previous.confirmed && previous.status !== "sent" && !(hasVoice(item.record) && !hasVoice(previous)) && !(item.uploaded && !hasVoice(previous))) {
596
+ current.set(item.hash, item.record);
597
+ writes.set(item.hash, item.record);
598
+ }
599
+ result.push(current.get(item.hash));
600
+ }
601
+ if (writes.size)
602
+ await this.outbox.bulkPut([...writes.values()]);
603
+ return result;
604
+ });
605
+ await this.checkAccess();
606
+ let changed = false;
607
+ batch.forEach((item, index) => {
608
+ if (!item.record)
609
+ return;
610
+ const record = records[index], old = this.values.get(item.hash);
611
+ if (!record || record.confirmed) {
612
+ changed = this.values.delete(item.hash) || changed;
613
+ this.releaseUrl(item.hash);
614
+ }
615
+ else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
616
+ this.values.set(item.hash, record);
617
+ changed = true;
618
+ }
619
+ });
620
+ if (changed)
621
+ this.emit();
622
+ batch.forEach((item, index) => item.resolve(records[index]));
623
+ }
624
+ catch (error) {
625
+ batch.forEach(item => item.reject(error));
626
+ }
627
+ })().finally(() => { this.ioRunning = undefined; this.scheduleIO(); });
628
+ this.ioRunning = task;
629
+ return task;
569
630
  }
570
631
  /** Confirm only after the authoritative message has been durably merged.
571
632
  * A tombstone prevents another tab's late failure from recreating the outbox. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.55",
3
+ "version": "0.12.0-next.56",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",