@cjhyy/code-shell-chat 0.8.12 → 0.8.20

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.
@@ -47,12 +47,16 @@ export declare class DeliveryQueue {
47
47
  private readonly deliver;
48
48
  private readonly onError;
49
49
  private readonly pending;
50
+ /** Enqueues that reserved capacity while attachments are materialized outside the mutation lock. */
51
+ private readonly preparing;
50
52
  private readonly completed;
51
53
  private readonly inFlight;
52
54
  private readonly targetInFlight;
53
55
  private mutation;
54
56
  private retryTimer?;
55
57
  private persistRetryTimer?;
58
+ /** Terminal records whose spool may be deleted after a successful state retry. */
59
+ private readonly persistRetryCleanups;
56
60
  private stopped;
57
61
  constructor(config: DeliveryQueueConfig, deliver: (adapterId: string, message: ChannelMessage) => Promise<void>, onError: (error: unknown, message: ChannelMessage) => void);
58
62
  start(): Promise<void>;
@@ -34,12 +34,16 @@ export class DeliveryQueue {
34
34
  deliver;
35
35
  onError;
36
36
  pending = [];
37
+ /** Enqueues that reserved capacity while attachments are materialized outside the mutation lock. */
38
+ preparing = new Map();
37
39
  completed = new Map();
38
40
  inFlight = new Set();
39
41
  targetInFlight = new Map();
40
42
  mutation = Promise.resolve();
41
43
  retryTimer;
42
44
  persistRetryTimer;
45
+ /** Terminal records whose spool may be deleted after a successful state retry. */
46
+ persistRetryCleanups = new Map();
43
47
  stopped = true;
44
48
  constructor(config, deliver, onError) {
45
49
  this.config = config;
@@ -76,65 +80,78 @@ export class DeliveryQueue {
76
80
  if (this.stopped)
77
81
  throw new Error("Chat Gateway inbox is stopped");
78
82
  const dedupeKey = deliveryDedupeKey(message);
79
- let result = "queued";
80
- await this.withMutation(async () => {
83
+ const id = randomUUID();
84
+ const reserved = await this.withMutation(async () => {
85
+ if (this.stopped)
86
+ throw new Error("Chat Gateway inbox is stopped");
81
87
  this.pruneCompleted();
82
88
  if (dedupeKey &&
83
89
  (this.completed.has(dedupeKey) ||
84
- this.pending.some((entry) => entry.dedupeKey === dedupeKey))) {
85
- result = "duplicate";
86
- return;
90
+ this.pending.some((entry) => entry.dedupeKey === dedupeKey) ||
91
+ [...this.preparing.values()].some((candidate) => candidate === dedupeKey))) {
92
+ return false;
87
93
  }
88
- if (this.pending.length >= this.config.maxPending) {
94
+ if (this.pending.length + this.preparing.size >= this.config.maxPending) {
89
95
  throw new DeliveryBackpressureError(this.config.maxPending);
90
96
  }
91
- const id = randomUUID();
92
- // Attachments used to make a record non-persistent outright, because
93
- // `load()` cannot be serialized — so the message lived in memory only while
94
- // the adapter had already acked it. Spool the bytes instead: the record
95
- // then persists like any other, carrying file paths in place of closures.
96
- const spooled = await this.spoolAttachments(id, message);
97
- let record;
98
- try {
99
- const persistent = Boolean(this.config.path && (!message.attachments?.length || spooled !== undefined));
100
- const safeMessage = persistent ? serializableMessage(message, spooled) : message;
101
- record = {
102
- id,
103
- ...(dedupeKey ? { dedupeKey } : {}),
104
- adapterId,
105
- message: safeMessage,
106
- attempts: 0,
107
- nextAttemptAt: Date.now(),
108
- persistent,
109
- ...(spooled ? { spooled } : {}),
110
- };
111
- this.pending.push(record);
112
- if (persistent)
113
- await this.persist();
114
- }
115
- catch (error) {
116
- if (record) {
117
- const index = this.pending.findIndex((candidate) => candidate.id === record?.id);
118
- if (index >= 0)
119
- this.pending.splice(index, 1);
120
- await this.discardSpool(record);
121
- }
122
- else if (spooled) {
123
- await this.discardSpool({
97
+ this.preparing.set(id, dedupeKey);
98
+ return true;
99
+ });
100
+ if (!reserved)
101
+ return "duplicate";
102
+ // attachment.load() may perform an unbounded network read. The reservation
103
+ // above preserves dedupe/backpressure, while doing the I/O here prevents one
104
+ // slow attachment from blocking every enqueue and terminal state write.
105
+ const spooled = await this.spoolAttachments(id, message);
106
+ let record;
107
+ try {
108
+ await this.withMutation(async () => {
109
+ try {
110
+ if (this.stopped)
111
+ throw new Error("Chat Gateway inbox is stopped");
112
+ const persistent = Boolean(this.config.path && (!message.attachments?.length || spooled !== undefined));
113
+ const safeMessage = persistent ? serializableMessage(message, spooled) : message;
114
+ record = {
124
115
  id,
116
+ ...(dedupeKey ? { dedupeKey } : {}),
125
117
  adapterId,
126
- message,
118
+ message: safeMessage,
127
119
  attempts: 0,
128
- nextAttemptAt: 0,
129
- persistent: false,
130
- spooled,
131
- });
120
+ nextAttemptAt: Date.now(),
121
+ persistent,
122
+ ...(spooled ? { spooled } : {}),
123
+ };
124
+ this.pending.push(record);
125
+ if (persistent)
126
+ await this.persist();
132
127
  }
133
- throw error;
134
- }
135
- });
128
+ catch (error) {
129
+ if (record) {
130
+ const index = this.pending.findIndex((candidate) => candidate.id === record?.id);
131
+ if (index >= 0)
132
+ this.pending.splice(index, 1);
133
+ }
134
+ throw error;
135
+ }
136
+ finally {
137
+ this.preparing.delete(id);
138
+ }
139
+ });
140
+ }
141
+ catch (error) {
142
+ await this.discardSpool(record ?? {
143
+ id,
144
+ adapterId,
145
+ message,
146
+ attempts: 0,
147
+ nextAttemptAt: 0,
148
+ persistent: false,
149
+ ...(spooled ? { spooled } : {}),
150
+ });
151
+ throw error;
152
+ }
136
153
  this.pump();
137
- return result;
154
+ return "queued";
138
155
  }
139
156
  pump() {
140
157
  if (this.stopped)
@@ -215,6 +232,8 @@ export class DeliveryQueue {
215
232
  this.pump();
216
233
  }
217
234
  schedulePersistRetry(message, cleanup) {
235
+ if (cleanup)
236
+ this.persistRetryCleanups.set(cleanup.id, { entry: cleanup, message });
218
237
  if (this.stopped || !this.config.path || this.persistRetryTimer)
219
238
  return;
220
239
  const delay = Math.max(10, Math.min(this.config.retryBaseMs, this.config.retryMaxMs));
@@ -224,11 +243,14 @@ export class DeliveryQueue {
224
243
  return;
225
244
  void this.withMutation(async () => {
226
245
  await this.persist();
227
- if (cleanup)
228
- await this.discardSpool(cleanup);
246
+ for (const [id, pendingCleanup] of this.persistRetryCleanups) {
247
+ await this.discardSpool(pendingCleanup.entry);
248
+ this.persistRetryCleanups.delete(id);
249
+ }
229
250
  }).catch((error) => {
230
- this.onError(error, message);
231
- this.schedulePersistRetry(message, cleanup);
251
+ const relatedMessage = this.persistRetryCleanups.values().next().value?.message ?? message;
252
+ this.onError(error, relatedMessage);
253
+ this.schedulePersistRetry(relatedMessage);
232
254
  });
233
255
  }, delay);
234
256
  this.persistRetryTimer.unref?.();
@@ -527,7 +549,8 @@ function validStoredEntry(entry, maxAttachmentBytes) {
527
549
  value.message.attachments) {
528
550
  return false;
529
551
  }
530
- if (value.dedupeKey !== undefined && (typeof value.dedupeKey !== "string" || value.dedupeKey.length > 16_384)) {
552
+ if (value.dedupeKey !== undefined &&
553
+ (typeof value.dedupeKey !== "string" || value.dedupeKey.length > 16_384)) {
531
554
  return false;
532
555
  }
533
556
  if (value.spooled === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-chat",
3
- "version": "0.8.12",
3
+ "version": "0.8.20",
4
4
  "description": "A standalone, transport-agnostic multi-channel chat gateway for Node.js and Bun.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",