@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.
- package/lib/runtime/durableSender.d.ts +17 -0
- package/lib/runtime/durableSender.js +193 -67
- package/package.json +1 -1
|
@@ -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 (
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
return
|
|
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