@pmate/chat-sdk 0.12.0-next.54 → 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,16 @@ 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;
54
+ private acceptanceQueue;
55
+ private acceptanceTimer?;
56
+ private acceptanceRunning?;
57
+ private acceptanceCount;
58
+ private acceptanceBytes;
49
59
  private uploadTail;
50
60
  private uploadWaiting;
51
61
  private confirmationQueue;
@@ -88,6 +98,9 @@ export declare class DurableChatSender {
88
98
  /** Compatibility API: resolves only after transport delivery. */
89
99
  send(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
90
100
  private persistOutgoing;
101
+ private scheduleAcceptances;
102
+ private failAcceptances;
103
+ private drainAcceptances;
91
104
  retry(hash: string): Promise<ChatRuntimeMessage>;
92
105
  private transmit;
93
106
  private reconcile;
@@ -96,6 +109,10 @@ export declare class DurableChatSender {
96
109
  private localView;
97
110
  private prepareVoice;
98
111
  private save;
112
+ private queueIO;
113
+ private scheduleIO;
114
+ private failIO;
115
+ private drainIO;
99
116
  /** Confirm only after the authoritative message has been durably merged.
100
117
  * A tombstone prevents another tab's late failure from recreating the outbox. */
101
118
  confirm(hashes: string[]): Promise<void>;
@@ -16,6 +16,16 @@ 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;
24
+ acceptanceQueue = [];
25
+ acceptanceTimer;
26
+ acceptanceRunning;
27
+ acceptanceCount = 0;
28
+ acceptanceBytes = 0;
19
29
  uploadTail = Promise.resolve();
20
30
  uploadWaiting = 0;
21
31
  confirmationQueue = [];
@@ -189,6 +199,8 @@ export class DurableChatSender {
189
199
  /** Stop disclosure immediately; durable invalidation is coordinated separately. */
190
200
  withdraw() {
191
201
  this.withdrawn = true;
202
+ this.failIO(new Error("OUTBOX_ACCESS_REVOKED"));
203
+ this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
192
204
  this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
193
205
  this.uploads.forEach(controller => controller.abort());
194
206
  this.reconciliations.forEach(controller => controller.abort());
@@ -249,6 +261,21 @@ export class DurableChatSender {
249
261
  persistOutgoing(threadId, input) {
250
262
  if (this.closed || this.closing || this.withdrawn)
251
263
  return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
264
+ if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
265
+ return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
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"));
275
+ if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
276
+ return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
277
+ this.acceptanceCount++;
278
+ this.acceptanceBytes += bytes;
252
279
  const pending = (async () => {
253
280
  await this.ready;
254
281
  await this.checkAccess();
@@ -260,41 +287,93 @@ export class DurableChatSender {
260
287
  message.hash = commandId(this.scope, this.version, threadId, message);
261
288
  if (input.voiceBlob) {
262
289
  const body = message.body;
263
- if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
290
+ if (body.kind !== "message" || !validVoice(body.content.voice))
264
291
  throw new Error("INVALID_VOICE_ATTACHMENT");
265
292
  }
266
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 };
267
- const durable = await this.withEpoch("rw", async () => {
268
- if (command) {
269
- const previous = await this.outbox.get([this.scope, message.hash]);
270
- if (previous && (previous.accessVersion ?? 0) === this.version) {
271
- if (previous.message.from !== this.identity.profileId || previous.message.hash !== message.hash || commandId(this.scope, this.version, threadId, previous.message) !== message.hash)
272
- throw new Error("OUTGOING_COMMAND_CONFLICT");
273
- return previous;
294
+ const durable = await new Promise((resolve, reject) => {
295
+ this.acceptanceQueue.push({ record, bytes, resolve, reject });
296
+ this.scheduleAcceptances();
297
+ });
298
+ return durable.message;
299
+ })().finally(() => { this.accepted.delete(pending); this.acceptanceCount--; this.acceptanceBytes -= bytes; });
300
+ this.accepted.add(pending);
301
+ return pending;
302
+ }
303
+ scheduleAcceptances() {
304
+ if (this.acceptanceTimer || this.acceptanceRunning || !this.acceptanceQueue.length)
305
+ return;
306
+ this.acceptanceTimer = setTimeout(() => { this.acceptanceTimer = undefined; void this.drainAcceptances(); }, 16);
307
+ }
308
+ failAcceptances(error) {
309
+ clearTimeout(this.acceptanceTimer);
310
+ this.acceptanceTimer = undefined;
311
+ for (const item of this.acceptanceQueue.splice(0))
312
+ item.reject(error);
313
+ }
314
+ drainAcceptances() {
315
+ const task = (async () => {
316
+ let bytes = 0, count = 0;
317
+ for (const item of this.acceptanceQueue) {
318
+ const size = item.bytes;
319
+ if (count >= 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
320
+ break;
321
+ bytes += size;
322
+ count++;
323
+ }
324
+ const batch = this.acceptanceQueue.splice(0, count);
325
+ try {
326
+ const result = await this.withEpoch("rw", async () => {
327
+ const commands = batch.filter(item => statefulGroup(item.record.message));
328
+ const previous = await this.outbox.bulkGet(commands.map(item => [this.scope, item.record.hash]));
329
+ const known = new Map(previous.filter((row) => Boolean(row && (row.accessVersion ?? 0) === this.version)).map(row => [row.hash, row]));
330
+ const writes = [], records = [];
331
+ for (const { record } of batch) {
332
+ const command = statefulGroup(record.message), old = command ? known.get(record.hash) : undefined;
333
+ if (old) {
334
+ if (old.message.from !== this.identity.profileId || old.message.hash !== record.hash || commandId(this.scope, this.version, record.threadId, old.message) !== record.hash)
335
+ throw new Error("OUTGOING_COMMAND_CONFLICT");
336
+ records.push(old);
337
+ }
338
+ else {
339
+ writes.push(record);
340
+ records.push(record);
341
+ if (command)
342
+ known.set(record.hash, record);
343
+ }
344
+ }
345
+ if (writes.length)
346
+ await this.outbox.bulkPut(writes);
347
+ return { writes, records };
348
+ });
349
+ await this.checkAccess();
350
+ for (const record of result.writes)
351
+ if (statefulGroup(record.message))
352
+ this.firstDelivery.add(record.hash);
353
+ let changed = false;
354
+ for (const record of result.records) {
355
+ if (record.confirmed) {
356
+ this.firstDelivery.delete(record.hash);
357
+ changed = this.values.delete(record.hash) || changed;
358
+ }
359
+ else {
360
+ const current = this.values.get(record.hash);
361
+ if (!current || current.status !== record.status || current.accessVersion !== record.accessVersion) {
362
+ this.values.set(record.hash, record);
363
+ changed = true;
364
+ }
274
365
  }
275
366
  }
276
- await this.outbox.put(record);
277
- return record;
278
- });
279
- await this.checkAccess();
280
- if (command && durable === record)
281
- this.firstDelivery.add(record.hash);
282
- if (durable.confirmed) {
283
- this.firstDelivery.delete(durable.hash);
284
- if (this.values.delete(durable.hash))
367
+ if (changed)
285
368
  this.emit();
369
+ batch.forEach((item, index) => item.resolve(result.records[index]));
286
370
  }
287
- else {
288
- const current = this.values.get(durable.hash);
289
- if (!current || current.status !== durable.status || current.accessVersion !== durable.accessVersion) {
290
- this.values.set(durable.hash, durable);
291
- this.emit();
292
- }
371
+ catch (error) {
372
+ batch.forEach(item => item.reject(error));
293
373
  }
294
- return durable.message;
295
- })().finally(() => this.accepted.delete(pending));
296
- this.accepted.add(pending);
297
- return pending;
374
+ })().finally(() => { this.acceptanceRunning = undefined; this.scheduleAcceptances(); });
375
+ this.acceptanceRunning = task;
376
+ return task;
298
377
  }
299
378
  retry(hash) {
300
379
  if (this.closed || this.closing || this.withdrawn)
@@ -308,7 +387,7 @@ export class DurableChatSender {
308
387
  }
309
388
  async transmit(hash) {
310
389
  await this.ready;
311
- let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
390
+ let record = await this.queueIO(hash);
312
391
  if (!record || this.closed || this.withdrawn)
313
392
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
314
393
  if (record.status === "sent" || record.confirmed) {
@@ -346,14 +425,7 @@ export class DurableChatSender {
346
425
  transmitting = true;
347
426
  const message = await this.client.sendPrepared(record.message);
348
427
  const sent = { ...record, message, status: "sent" };
349
- const committed = await this.save(sent);
350
- if (!this.closed && !this.withdrawn) {
351
- if (committed?.confirmed || !committed)
352
- this.values.delete(hash);
353
- else
354
- this.values.set(hash, committed);
355
- this.emit();
356
- }
428
+ await this.save(sent);
357
429
  return message;
358
430
  }
359
431
  catch (error) {
@@ -373,16 +445,8 @@ export class DurableChatSender {
373
445
  if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
374
446
  const failed = { ...record, status: "failed" };
375
447
  const committed = await this.save(failed);
376
- if (committed?.confirmed || committed?.status === "sent") {
377
- this.values.delete(hash);
378
- this.emit();
448
+ if (committed?.confirmed || committed?.status === "sent")
379
449
  return committed.message;
380
- }
381
- if (committed)
382
- this.values.set(hash, committed);
383
- else
384
- this.values.delete(hash);
385
- this.emit();
386
450
  }
387
451
  throw error;
388
452
  }
@@ -446,7 +510,7 @@ export class DurableChatSender {
446
510
  await this.checkAccess();
447
511
  if (this.closing)
448
512
  throw new Error("CHAT_SENDER_CLOSED");
449
- const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
513
+ const latest = await this.queueIO(record.hash);
450
514
  if (!latest)
451
515
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
452
516
  if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
@@ -471,18 +535,9 @@ export class DurableChatSender {
471
535
  if (this.closing)
472
536
  throw new Error("CHAT_SENDER_CLOSED");
473
537
  const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
474
- const committed = await this.withEpoch("rw", async () => {
475
- const current = await this.outbox.get([this.scope, record.hash]);
476
- if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
477
- return current;
478
- const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
479
- await this.outbox.put(next);
480
- return next;
481
- });
538
+ const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
482
539
  if (!committed)
483
540
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
484
- this.values.set(record.hash, committed);
485
- this.emit();
486
541
  return committed;
487
542
  }
488
543
  finally {
@@ -490,17 +545,88 @@ export class DurableChatSender {
490
545
  release();
491
546
  }
492
547
  }
493
- async save(record) {
494
- return this.withEpoch("rw", async () => {
495
- const previous = await this.outbox.get([this.scope, record.hash]);
496
- if (!previous || previous.confirmed || previous.status === "sent")
497
- return previous;
498
- // A second document may already have durably replaced the Blob with its URL.
499
- if (hasVoice(record) && !hasVoice(previous))
500
- return previous;
501
- await this.outbox.put(record);
502
- return record;
503
- });
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;
504
630
  }
505
631
  /** Confirm only after the authoritative message has been durably merged.
506
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.54",
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",