1
40
41import type { OutboxItem, OutboxResult, OutboxStore } from "./types.js";
42
43
44export type { OutboxItem, OutboxResult, OutboxStore } from "./types.js";
45
46
47export const OUTBOX_MAX_ITEMS = 500;
48
49
50export const OUTBOX_GC_MS = 24 * 60 * 60 * 1000;
51
52
53export const OUTBOX_BACKOFF_MIN_MS = 1000;
54export const OUTBOX_BACKOFF_MAX_MS = 30_000;
55
56
57export const OUTBOX_DEFAULT_CONCURRENCY = 3;
58
59
60export function generateIdempotencyKey(): string {
61 const c = (globalThis as Record<string, unknown>).crypto as
62 | { getRandomValues?: (arr: Uint8Array) => Uint8Array }
63 | undefined;
64 if (c !== undefined && typeof c.getRandomValues === "function") {
65 const buf = new Uint8Array(16);
66 c.getRandomValues(buf);
67 return (
68 "key_" +
69 Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("")
70 );
71 }
72 let out = "";
73 for (let i = 0; i < 32; i += 1) {
74 out += Math.floor(Math.random() * 16).toString(16);
75 }
76 return "key_" + out;
77}
78
79
80export function resourceKey(resource: { type: string; id: string }): string {
81 return `${resource.type}:${resource.id}`;
82}
83
84
85export function backoffForAttempt(attempts: number): number {
86 const n = Math.max(0, attempts);
87 return Math.min(OUTBOX_BACKOFF_MIN_MS * 2 ** n, OUTBOX_BACKOFF_MAX_MS);
88}
89
90export interface OutboxQueueOptions {
91
92 readonly store: OutboxStore;
93
94 readonly send: (item: OutboxItem) => Promise<OutboxResult>;
95
96 readonly now?: () => number;
97
98 readonly maxConcurrentResources?: number;
99
100 readonly onCountChange?: (info: { pending: number; failed: number }) => void;
101
102 readonly onEvicted?: (item: OutboxItem) => void;
103
104 readonly onDrain?: (result: DrainResult) => void;
105
106 readonly initial?: readonly OutboxItem[];
107}
108
109export interface DrainResult {
110 readonly sent: number;
111 readonly failed: number;
112 readonly retrying: number;
113 readonly pending: number;
114 readonly failedCount: number;
115}
116
117
121export class OutboxQueue {
122 private readonly store: OutboxStore;
123 private readonly send: (item: OutboxItem) => Promise<OutboxResult>;
124 private readonly now: () => number;
125 private readonly maxConcurrentResources: number;
126
127
128 private readonly onCountChange: ((info: { pending: number; failed: number }) => void) | undefined;
129 private readonly onEvicted: ((item: OutboxItem) => void) | undefined;
130 private readonly onDrain: ((result: DrainResult) => void) | undefined;
131
132 private pending: OutboxItem[] = [];
133 private failed: OutboxItem[] = [];
134 private draining = false;
135 private loaded = false;
136
137 constructor(opts: OutboxQueueOptions) {
138 this.store = opts.store;
139 this.send = opts.send;
140 this.now = opts.now ?? (() => Date.now());
141 this.maxConcurrentResources = opts.maxConcurrentResources ?? OUTBOX_DEFAULT_CONCURRENCY;
142 this.onCountChange = opts.onCountChange;
143 this.onEvicted = opts.onEvicted;
144 this.onDrain = opts.onDrain;
145 if (opts.initial !== undefined) {
146 for (const item of opts.initial) {
147 (item.lastError !== undefined ? this.failed : this.pending).push(item);
148 }
149 this.loaded = true;
150 }
151 }
152
153
154 async init(): Promise<void> {
155 if (this.loaded) return;
156 const items = await this.store.load();
157 for (const item of items) {
158 (item.lastError !== undefined ? this.failed : this.pending).push(item);
159 }
160 this.loaded = true;
161 this.persist();
162 }
163
164 get pendingItems(): readonly OutboxItem[] {
165 return this.pending;
166 }
167
168 get failedItems(): readonly OutboxItem[] {
169 return this.failed;
170 }
171
172
173 count(): number {
174 return this.pending.length;
175 }
176
177 failedCount(): number {
178 return this.failed.length;
179 }
180
181
186 enqueue(
187 method: OutboxItem["method"],
188 path: string,
189 body: Record<string, unknown> | undefined,
190 resource: { type: string; id: string },
191 ): OutboxItem {
192
193 this.gc();
194 const item: OutboxItem = {
195 key: generateIdempotencyKey(),
196 method,
197 path,
198 ...(body !== undefined ? { body } : {}),
199 queuedAt: this.now(),
200 resource,
201 attempts: 0,
202 };
203 this.pending.push(item);
204 while (this.pending.length > OUTBOX_MAX_ITEMS) {
205 const evicted = this.pending.shift();
206 if (evicted !== undefined) this.onEvicted?.(evicted);
207 }
208 this.persist();
209 this.emitCount();
210 return item;
211 }
212
213
218 gc(): readonly OutboxItem[] {
219 const cutoff = this.now() - OUTBOX_GC_MS;
220 const kept: OutboxItem[] = [];
221 const removed: OutboxItem[] = [];
222 for (const item of this.pending) {
223 (item.queuedAt < cutoff ? removed : kept).push(item);
224 }
225 if (removed.length > 0) {
226 this.pending = kept;
227 this.persist();
228 this.emitCount();
229 }
230 return removed;
231 }
232
233
238 retry(item: OutboxItem): OutboxItem {
239 const idx = this.failed.findIndex((i) => i.key === item.key);
240 if (idx < 0) return item;
241 this.failed.splice(idx, 1);
242 const fresh: OutboxItem = {
243 ...item,
244 key: generateIdempotencyKey(),
245 attempts: 0,
246 queuedAt: this.now(),
247 };
248 delete (fresh as { nextAttemptAt?: number }).nextAttemptAt;
249 delete (fresh as { lastError?: string }).lastError;
250 this.pending.push(fresh);
251 this.persist();
252 this.emitCount();
253 return fresh;
254 }
255
256
257 discard(item: OutboxItem): void {
258 const fIdx = this.failed.findIndex((i) => i.key === item.key);
259 if (fIdx >= 0) {
260 this.failed.splice(fIdx, 1);
261 } else {
262 const pIdx = this.pending.findIndex((i) => i.key === item.key);
263 if (pIdx >= 0) this.pending.splice(pIdx, 1);
264 }
265 this.persist();
266 this.emitCount();
267 }
268
269
276 async drain(): Promise<DrainResult> {
277 if (this.draining) {
278 return {
279 sent: 0,
280 failed: 0,
281 retrying: 0,
282 pending: this.pending.length,
283 failedCount: this.failed.length,
284 };
285 }
286 this.draining = true;
287 try {
288 const sent = { count: 0 };
289 const failedMoves = { count: 0 };
290 const retrying = { count: 0 };
291
292
293 const chains = new Map<string, OutboxItem[]>();
294 const sorted = [...this.pending].sort((a, b) => a.queuedAt - b.queuedAt);
295 for (const item of sorted) {
296 const key = resourceKey(item.resource);
297 const list = chains.get(key) ?? [];
298 list.push(item);
299 chains.set(key, list);
300 }
301 const keys = [...chains.keys()];
302
303
304 const runChain = async (key: string): Promise<void> => {
305 const items = chains.get(key);
306 if (items === undefined) return;
307 for (const item of items) {
308
309 if (item.nextAttemptAt !== undefined && this.now() < item.nextAttemptAt) {
310 retrying.count += 1;
311 continue;
312 }
313 let result: OutboxResult;
314 try {
315 result = await this.send(item);
316 } catch (err) {
317
318 result = { ok: false, error: err instanceof Error ? err.message : String(err) };
319 }
320 if (result.ok) {
321 this.removePending(item);
322 sent.count += 1;
323 } else if (result.status !== undefined && result.status >= 400 && result.status < 500) {
324
325 this.moveToFailed(item, result.error ?? `HTTP ${result.status}`);
326 failedMoves.count += 1;
327 } else {
328
329
330
331 this.pending = this.pending.map((p) =>
332 p.key === item.key
333 ? {
334 ...p,
335 attempts: p.attempts + 1,
336 nextAttemptAt: this.now() + backoffForAttempt(p.attempts + 1),
337 ...(result.error !== undefined ? { lastError: result.error } : {}),
338 }
339 : p,
340 );
341 retrying.count += 1;
342 }
343 }
344 };
345
346
347 const queue = [...keys];
348 const workers = Array.from(
349 { length: Math.min(this.maxConcurrentResources, queue.length) },
350 async () => {
351 for (;;) {
352 const key = queue.shift();
353 if (key === undefined) return;
354 await runChain(key);
355 }
356 },
357 );
358 await Promise.all(workers);
359
360 this.persist();
361 this.emitCount();
362 const result: DrainResult = {
363 sent: sent.count,
364 failed: failedMoves.count,
365 retrying: retrying.count,
366 pending: this.pending.length,
367 failedCount: this.failed.length,
368 };
369 this.onDrain?.(result);
370 return result;
371 } finally {
372 this.draining = false;
373 }
374 }
375
376
377 async clear(): Promise<void> {
378 this.pending = [];
379 this.failed = [];
380 await this.persist();
381 this.emitCount();
382 }
383
384 private removePending(item: OutboxItem): void {
385 const idx = this.pending.findIndex((i) => i.key === item.key);
386 if (idx >= 0) this.pending.splice(idx, 1);
387 }
388
389 private moveToFailed(item: OutboxItem, error: string): void {
390 this.removePending(item);
391 this.failed = this.failed.filter((i) => i.key !== item.key);
392 this.failed.push({ ...item, lastError: error });
393 }
394
395 private async persist(): Promise<void> {
396 try {
397 await this.store.save([...this.pending, ...this.failed]);
398 } catch {
399
400
401
402 }
403 }
404
405 private emitCount(): void {
406 this.onCountChange?.({ pending: this.pending.length, failed: this.failed.length });
407 }
408}
409
410
411export class MemoryOutboxStore implements OutboxStore {
412 items: OutboxItem[] = [];
413 async load(): Promise<readonly OutboxItem[]> {
414 return this.items;
415 }
416 async save(items: readonly OutboxItem[]): Promise<void> {
417 this.items = [...items];
418 }
419}
420