@agent-relay/storage 0.1.0

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.
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Dead Letter Queue Storage Adapter
3
+ *
4
+ * Abstract interface for DLQ storage with implementations for:
5
+ * - SQLite (local daemon)
6
+ * - PostgreSQL (cloud deployment)
7
+ * - In-memory (testing)
8
+ *
9
+ * Follows the adapter pattern used by the main storage layer.
10
+ */
11
+ // =============================================================================
12
+ // Default Configuration
13
+ // =============================================================================
14
+ export const DEFAULT_DLQ_CONFIG = {
15
+ enabled: true,
16
+ retentionHours: 168, // 7 days
17
+ maxEntries: 10000,
18
+ autoCleanup: true,
19
+ cleanupIntervalMinutes: 60,
20
+ alertThreshold: 1000,
21
+ };
22
+ const DLQ_SQLITE_SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS dead_letters (
24
+ id TEXT PRIMARY KEY,
25
+ message_id TEXT NOT NULL,
26
+ from_agent TEXT NOT NULL,
27
+ to_agent TEXT NOT NULL,
28
+ topic TEXT,
29
+ kind TEXT NOT NULL,
30
+ body TEXT NOT NULL,
31
+ data TEXT,
32
+ thread TEXT,
33
+ original_ts INTEGER NOT NULL,
34
+ dlq_ts INTEGER NOT NULL,
35
+ reason TEXT NOT NULL,
36
+ error_message TEXT,
37
+ attempt_count INTEGER NOT NULL DEFAULT 0,
38
+ last_attempt_ts INTEGER,
39
+ dlq_retry_count INTEGER NOT NULL DEFAULT 0,
40
+ acknowledged INTEGER NOT NULL DEFAULT 0,
41
+ acknowledged_ts INTEGER,
42
+ acknowledged_by TEXT
43
+ );
44
+
45
+ CREATE INDEX IF NOT EXISTS idx_dlq_to ON dead_letters(to_agent);
46
+ CREATE INDEX IF NOT EXISTS idx_dlq_from ON dead_letters(from_agent);
47
+ CREATE INDEX IF NOT EXISTS idx_dlq_reason ON dead_letters(reason);
48
+ CREATE INDEX IF NOT EXISTS idx_dlq_ts ON dead_letters(dlq_ts);
49
+ CREATE INDEX IF NOT EXISTS idx_dlq_acknowledged ON dead_letters(acknowledged);
50
+ `;
51
+ export class SQLiteDLQAdapter {
52
+ db;
53
+ constructor(db) {
54
+ this.db = db;
55
+ }
56
+ async init() {
57
+ this.db.exec(DLQ_SQLITE_SCHEMA);
58
+ }
59
+ async add(messageId, envelope, reason, attemptCount, errorMessage) {
60
+ const id = `dlq_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
61
+ const now = Date.now();
62
+ const deadLetter = {
63
+ id,
64
+ messageId,
65
+ from: envelope.from,
66
+ to: envelope.to,
67
+ topic: envelope.topic,
68
+ kind: envelope.kind,
69
+ body: envelope.body,
70
+ data: envelope.data,
71
+ thread: envelope.thread,
72
+ originalTs: envelope.ts,
73
+ dlqTs: now,
74
+ reason,
75
+ errorMessage,
76
+ attemptCount,
77
+ lastAttemptTs: now,
78
+ dlqRetryCount: 0,
79
+ acknowledged: false,
80
+ };
81
+ const stmt = this.db.prepare(`
82
+ INSERT INTO dead_letters (
83
+ id, message_id, from_agent, to_agent, topic, kind, body, data, thread,
84
+ original_ts, dlq_ts, reason, error_message, attempt_count, last_attempt_ts,
85
+ dlq_retry_count, acknowledged
86
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ `);
88
+ stmt.run(deadLetter.id, deadLetter.messageId, deadLetter.from, deadLetter.to, deadLetter.topic ?? null, deadLetter.kind, deadLetter.body, deadLetter.data ? JSON.stringify(deadLetter.data) : null, deadLetter.thread ?? null, deadLetter.originalTs, deadLetter.dlqTs, deadLetter.reason, deadLetter.errorMessage ?? null, deadLetter.attemptCount, deadLetter.lastAttemptTs ?? null, deadLetter.dlqRetryCount, deadLetter.acknowledged ? 1 : 0);
89
+ return deadLetter;
90
+ }
91
+ async get(id) {
92
+ const stmt = this.db.prepare('SELECT * FROM dead_letters WHERE id = ?');
93
+ const row = stmt.get(id);
94
+ return row ? this.rowToDeadLetter(row) : null;
95
+ }
96
+ async query(query = {}) {
97
+ const conditions = [];
98
+ const params = [];
99
+ if (query.to) {
100
+ conditions.push('to_agent = ?');
101
+ params.push(query.to);
102
+ }
103
+ if (query.from) {
104
+ conditions.push('from_agent = ?');
105
+ params.push(query.from);
106
+ }
107
+ if (query.reason) {
108
+ conditions.push('reason = ?');
109
+ params.push(query.reason);
110
+ }
111
+ if (query.acknowledged !== undefined) {
112
+ conditions.push('acknowledged = ?');
113
+ params.push(query.acknowledged ? 1 : 0);
114
+ }
115
+ if (query.afterTs) {
116
+ conditions.push('dlq_ts > ?');
117
+ params.push(query.afterTs);
118
+ }
119
+ if (query.beforeTs) {
120
+ conditions.push('dlq_ts < ?');
121
+ params.push(query.beforeTs);
122
+ }
123
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
124
+ const orderColumn = query.orderBy === 'originalTs' ? 'original_ts' :
125
+ query.orderBy === 'attemptCount' ? 'attempt_count' : 'dlq_ts';
126
+ const orderDir = query.orderDir ?? 'DESC';
127
+ const limit = query.limit ?? 100;
128
+ const offset = query.offset ?? 0;
129
+ const sql = `
130
+ SELECT * FROM dead_letters ${whereClause}
131
+ ORDER BY ${orderColumn} ${orderDir}
132
+ LIMIT ? OFFSET ?
133
+ `;
134
+ params.push(limit, offset);
135
+ const stmt = this.db.prepare(sql);
136
+ const rows = stmt.all(...params);
137
+ return rows.map(row => this.rowToDeadLetter(row));
138
+ }
139
+ async acknowledge(id, acknowledgedBy = 'system') {
140
+ const stmt = this.db.prepare(`
141
+ UPDATE dead_letters SET acknowledged = 1, acknowledged_ts = ?, acknowledged_by = ?
142
+ WHERE id = ? AND acknowledged = 0
143
+ `);
144
+ const result = stmt.run(Date.now(), acknowledgedBy, id);
145
+ return result.changes > 0;
146
+ }
147
+ async acknowledgeMany(ids, acknowledgedBy = 'system') {
148
+ const placeholders = ids.map(() => '?').join(',');
149
+ const stmt = this.db.prepare(`
150
+ UPDATE dead_letters SET acknowledged = 1, acknowledged_ts = ?, acknowledged_by = ?
151
+ WHERE id IN (${placeholders}) AND acknowledged = 0
152
+ `);
153
+ const result = stmt.run(Date.now(), acknowledgedBy, ...ids);
154
+ return result.changes;
155
+ }
156
+ async incrementRetry(id) {
157
+ const stmt = this.db.prepare(`
158
+ UPDATE dead_letters SET dlq_retry_count = dlq_retry_count + 1, last_attempt_ts = ?
159
+ WHERE id = ?
160
+ `);
161
+ const result = stmt.run(Date.now(), id);
162
+ return result.changes > 0;
163
+ }
164
+ async remove(id) {
165
+ const stmt = this.db.prepare('DELETE FROM dead_letters WHERE id = ?');
166
+ const result = stmt.run(id);
167
+ return result.changes > 0;
168
+ }
169
+ async getStats() {
170
+ const totalRow = this.db.prepare('SELECT COUNT(*) as count FROM dead_letters').get();
171
+ const unackRow = this.db.prepare('SELECT COUNT(*) as count FROM dead_letters WHERE acknowledged = 0').get();
172
+ const reasonRows = this.db.prepare('SELECT reason, COUNT(*) as count FROM dead_letters GROUP BY reason').all();
173
+ const byReason = {
174
+ max_retries_exceeded: 0, ttl_expired: 0, connection_lost: 0, target_not_found: 0,
175
+ signature_invalid: 0, payload_too_large: 0, rate_limited: 0, unknown: 0,
176
+ };
177
+ for (const row of reasonRows) {
178
+ byReason[row.reason] = row.count;
179
+ }
180
+ const targetRows = this.db.prepare('SELECT to_agent, COUNT(*) as count FROM dead_letters GROUP BY to_agent ORDER BY count DESC LIMIT 10').all();
181
+ const byTarget = {};
182
+ for (const row of targetRows) {
183
+ byTarget[row.to_agent] = row.count;
184
+ }
185
+ const oldestRow = this.db.prepare('SELECT MIN(dlq_ts) as ts FROM dead_letters WHERE acknowledged = 0').get();
186
+ const newestRow = this.db.prepare('SELECT MAX(dlq_ts) as ts FROM dead_letters WHERE acknowledged = 0').get();
187
+ const avgRow = this.db.prepare('SELECT AVG(dlq_retry_count) as avg FROM dead_letters').get();
188
+ return {
189
+ totalEntries: totalRow.count,
190
+ unacknowledged: unackRow.count,
191
+ byReason,
192
+ byTarget,
193
+ oldestEntryTs: oldestRow.ts,
194
+ newestEntryTs: newestRow.ts,
195
+ avgRetryCount: avgRow.avg ?? 0,
196
+ };
197
+ }
198
+ async cleanup(retentionHours, maxEntries) {
199
+ const cutoffTs = Date.now() - retentionHours * 3600 * 1000;
200
+ const retentionResult = this.db.prepare('DELETE FROM dead_letters WHERE dlq_ts < ?').run(cutoffTs);
201
+ const countRow = this.db.prepare('SELECT COUNT(*) as count FROM dead_letters').get();
202
+ let maxEntriesRemoved = 0;
203
+ if (countRow.count > maxEntries) {
204
+ const excess = countRow.count - maxEntries;
205
+ const trimResult = this.db.prepare(`
206
+ DELETE FROM dead_letters WHERE id IN (
207
+ SELECT id FROM dead_letters WHERE acknowledged = 1 ORDER BY dlq_ts ASC LIMIT ?
208
+ )
209
+ `).run(excess);
210
+ maxEntriesRemoved = trimResult.changes;
211
+ }
212
+ return { removed: retentionResult.changes + maxEntriesRemoved };
213
+ }
214
+ async getRetryable(maxRetries = 3, limit = 10) {
215
+ const stmt = this.db.prepare(`
216
+ SELECT * FROM dead_letters
217
+ WHERE acknowledged = 0 AND dlq_retry_count < ?
218
+ ORDER BY dlq_ts ASC LIMIT ?
219
+ `);
220
+ const rows = stmt.all(maxRetries, limit);
221
+ return rows.map(row => this.rowToDeadLetter(row));
222
+ }
223
+ async close() {
224
+ // SQLite connection managed externally
225
+ }
226
+ rowToDeadLetter(row) {
227
+ let data;
228
+ if (row.data) {
229
+ try {
230
+ data = JSON.parse(row.data);
231
+ }
232
+ catch {
233
+ // Invalid JSON data, leave as undefined
234
+ console.warn(`[dlq] Failed to parse data for dead letter ${row.id}`);
235
+ }
236
+ }
237
+ return {
238
+ id: row.id,
239
+ messageId: row.message_id,
240
+ from: row.from_agent,
241
+ to: row.to_agent,
242
+ topic: row.topic,
243
+ kind: row.kind,
244
+ body: row.body,
245
+ data,
246
+ thread: row.thread,
247
+ originalTs: row.original_ts,
248
+ dlqTs: row.dlq_ts,
249
+ reason: row.reason,
250
+ errorMessage: row.error_message,
251
+ attemptCount: row.attempt_count,
252
+ lastAttemptTs: row.last_attempt_ts,
253
+ dlqRetryCount: row.dlq_retry_count,
254
+ acknowledged: row.acknowledged === 1,
255
+ acknowledgedTs: row.acknowledged_ts,
256
+ acknowledgedBy: row.acknowledged_by,
257
+ };
258
+ }
259
+ }
260
+ const DLQ_POSTGRES_SCHEMA = `
261
+ CREATE TABLE IF NOT EXISTS dead_letters (
262
+ id TEXT PRIMARY KEY,
263
+ message_id TEXT NOT NULL,
264
+ from_agent TEXT NOT NULL,
265
+ to_agent TEXT NOT NULL,
266
+ topic TEXT,
267
+ kind TEXT NOT NULL,
268
+ body TEXT NOT NULL,
269
+ data JSONB,
270
+ thread TEXT,
271
+ original_ts BIGINT NOT NULL,
272
+ dlq_ts BIGINT NOT NULL,
273
+ reason TEXT NOT NULL,
274
+ error_message TEXT,
275
+ attempt_count INTEGER NOT NULL DEFAULT 0,
276
+ last_attempt_ts BIGINT,
277
+ dlq_retry_count INTEGER NOT NULL DEFAULT 0,
278
+ acknowledged BOOLEAN NOT NULL DEFAULT FALSE,
279
+ acknowledged_ts BIGINT,
280
+ acknowledged_by TEXT
281
+ );
282
+
283
+ CREATE INDEX IF NOT EXISTS idx_dlq_to ON dead_letters(to_agent);
284
+ CREATE INDEX IF NOT EXISTS idx_dlq_from ON dead_letters(from_agent);
285
+ CREATE INDEX IF NOT EXISTS idx_dlq_reason ON dead_letters(reason);
286
+ CREATE INDEX IF NOT EXISTS idx_dlq_ts ON dead_letters(dlq_ts);
287
+ CREATE INDEX IF NOT EXISTS idx_dlq_acknowledged ON dead_letters(acknowledged);
288
+ `;
289
+ export class PostgresDLQAdapter {
290
+ pool;
291
+ constructor(pool) {
292
+ this.pool = pool;
293
+ }
294
+ async init() {
295
+ const client = await this.pool.connect();
296
+ try {
297
+ await client.query(DLQ_POSTGRES_SCHEMA);
298
+ }
299
+ finally {
300
+ client.release();
301
+ }
302
+ }
303
+ async add(messageId, envelope, reason, attemptCount, errorMessage) {
304
+ const id = `dlq_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
305
+ const now = Date.now();
306
+ const deadLetter = {
307
+ id,
308
+ messageId,
309
+ from: envelope.from,
310
+ to: envelope.to,
311
+ topic: envelope.topic,
312
+ kind: envelope.kind,
313
+ body: envelope.body,
314
+ data: envelope.data,
315
+ thread: envelope.thread,
316
+ originalTs: envelope.ts,
317
+ dlqTs: now,
318
+ reason,
319
+ errorMessage,
320
+ attemptCount,
321
+ lastAttemptTs: now,
322
+ dlqRetryCount: 0,
323
+ acknowledged: false,
324
+ };
325
+ await this.pool.query(`
326
+ INSERT INTO dead_letters (
327
+ id, message_id, from_agent, to_agent, topic, kind, body, data, thread,
328
+ original_ts, dlq_ts, reason, error_message, attempt_count, last_attempt_ts,
329
+ dlq_retry_count, acknowledged
330
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
331
+ `, [
332
+ deadLetter.id, deadLetter.messageId, deadLetter.from, deadLetter.to,
333
+ deadLetter.topic ?? null, deadLetter.kind, deadLetter.body,
334
+ deadLetter.data ? JSON.stringify(deadLetter.data) : null,
335
+ deadLetter.thread ?? null, deadLetter.originalTs, deadLetter.dlqTs,
336
+ deadLetter.reason, deadLetter.errorMessage ?? null, deadLetter.attemptCount,
337
+ deadLetter.lastAttemptTs ?? null, deadLetter.dlqRetryCount, deadLetter.acknowledged
338
+ ]);
339
+ return deadLetter;
340
+ }
341
+ async get(id) {
342
+ const result = await this.pool.query('SELECT * FROM dead_letters WHERE id = $1', [id]);
343
+ return result.rows[0] ? this.rowToDeadLetter(result.rows[0]) : null;
344
+ }
345
+ async query(query = {}) {
346
+ const conditions = [];
347
+ const params = [];
348
+ let paramIndex = 1;
349
+ if (query.to) {
350
+ conditions.push(`to_agent = $${paramIndex++}`);
351
+ params.push(query.to);
352
+ }
353
+ if (query.from) {
354
+ conditions.push(`from_agent = $${paramIndex++}`);
355
+ params.push(query.from);
356
+ }
357
+ if (query.reason) {
358
+ conditions.push(`reason = $${paramIndex++}`);
359
+ params.push(query.reason);
360
+ }
361
+ if (query.acknowledged !== undefined) {
362
+ conditions.push(`acknowledged = $${paramIndex++}`);
363
+ params.push(query.acknowledged);
364
+ }
365
+ if (query.afterTs) {
366
+ conditions.push(`dlq_ts > $${paramIndex++}`);
367
+ params.push(query.afterTs);
368
+ }
369
+ if (query.beforeTs) {
370
+ conditions.push(`dlq_ts < $${paramIndex++}`);
371
+ params.push(query.beforeTs);
372
+ }
373
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
374
+ const orderColumn = query.orderBy === 'originalTs' ? 'original_ts' :
375
+ query.orderBy === 'attemptCount' ? 'attempt_count' : 'dlq_ts';
376
+ const orderDir = query.orderDir ?? 'DESC';
377
+ const limit = query.limit ?? 100;
378
+ const offset = query.offset ?? 0;
379
+ params.push(limit, offset);
380
+ const sql = `
381
+ SELECT * FROM dead_letters ${whereClause}
382
+ ORDER BY ${orderColumn} ${orderDir}
383
+ LIMIT $${paramIndex++} OFFSET $${paramIndex++}
384
+ `;
385
+ const result = await this.pool.query(sql, params);
386
+ return result.rows.map(row => this.rowToDeadLetter(row));
387
+ }
388
+ async acknowledge(id, acknowledgedBy = 'system') {
389
+ const result = await this.pool.query(`
390
+ UPDATE dead_letters SET acknowledged = TRUE, acknowledged_ts = $1, acknowledged_by = $2
391
+ WHERE id = $3 AND acknowledged = FALSE
392
+ `, [Date.now(), acknowledgedBy, id]);
393
+ return (result.rowCount ?? 0) > 0;
394
+ }
395
+ async acknowledgeMany(ids, acknowledgedBy = 'system') {
396
+ const result = await this.pool.query(`
397
+ UPDATE dead_letters SET acknowledged = TRUE, acknowledged_ts = $1, acknowledged_by = $2
398
+ WHERE id = ANY($3) AND acknowledged = FALSE
399
+ `, [Date.now(), acknowledgedBy, ids]);
400
+ return result.rowCount ?? 0;
401
+ }
402
+ async incrementRetry(id) {
403
+ const result = await this.pool.query(`
404
+ UPDATE dead_letters SET dlq_retry_count = dlq_retry_count + 1, last_attempt_ts = $1
405
+ WHERE id = $2
406
+ `, [Date.now(), id]);
407
+ return (result.rowCount ?? 0) > 0;
408
+ }
409
+ async remove(id) {
410
+ const result = await this.pool.query('DELETE FROM dead_letters WHERE id = $1', [id]);
411
+ return (result.rowCount ?? 0) > 0;
412
+ }
413
+ async getStats() {
414
+ const [totalResult, unackResult, reasonResult, targetResult, oldestResult, newestResult, avgResult] = await Promise.all([
415
+ this.pool.query('SELECT COUNT(*) as count FROM dead_letters'),
416
+ this.pool.query('SELECT COUNT(*) as count FROM dead_letters WHERE acknowledged = FALSE'),
417
+ this.pool.query('SELECT reason, COUNT(*) as count FROM dead_letters GROUP BY reason'),
418
+ this.pool.query('SELECT to_agent, COUNT(*) as count FROM dead_letters GROUP BY to_agent ORDER BY count DESC LIMIT 10'),
419
+ this.pool.query('SELECT MIN(dlq_ts) as ts FROM dead_letters WHERE acknowledged = FALSE'),
420
+ this.pool.query('SELECT MAX(dlq_ts) as ts FROM dead_letters WHERE acknowledged = FALSE'),
421
+ this.pool.query('SELECT AVG(dlq_retry_count) as avg FROM dead_letters'),
422
+ ]);
423
+ const byReason = {
424
+ max_retries_exceeded: 0, ttl_expired: 0, connection_lost: 0, target_not_found: 0,
425
+ signature_invalid: 0, payload_too_large: 0, rate_limited: 0, unknown: 0,
426
+ };
427
+ for (const row of reasonResult.rows) {
428
+ byReason[row.reason] = parseInt(row.count, 10);
429
+ }
430
+ const byTarget = {};
431
+ for (const row of targetResult.rows) {
432
+ byTarget[row.to_agent] = parseInt(row.count, 10);
433
+ }
434
+ return {
435
+ totalEntries: parseInt(totalResult.rows[0]?.count ?? '0', 10),
436
+ unacknowledged: parseInt(unackResult.rows[0]?.count ?? '0', 10),
437
+ byReason,
438
+ byTarget,
439
+ oldestEntryTs: oldestResult.rows[0]?.ts ? parseInt(oldestResult.rows[0].ts, 10) : null,
440
+ newestEntryTs: newestResult.rows[0]?.ts ? parseInt(newestResult.rows[0].ts, 10) : null,
441
+ avgRetryCount: parseFloat(avgResult.rows[0]?.avg ?? '0'),
442
+ };
443
+ }
444
+ async cleanup(retentionHours, maxEntries) {
445
+ const cutoffTs = Date.now() - retentionHours * 3600 * 1000;
446
+ const retentionResult = await this.pool.query('DELETE FROM dead_letters WHERE dlq_ts < $1', [cutoffTs]);
447
+ const countResult = await this.pool.query('SELECT COUNT(*) as count FROM dead_letters');
448
+ const count = parseInt(countResult.rows[0]?.count ?? '0', 10);
449
+ let maxEntriesRemoved = 0;
450
+ if (count > maxEntries) {
451
+ const excess = count - maxEntries;
452
+ const trimResult = await this.pool.query(`
453
+ DELETE FROM dead_letters WHERE id IN (
454
+ SELECT id FROM dead_letters WHERE acknowledged = TRUE ORDER BY dlq_ts ASC LIMIT $1
455
+ )
456
+ `, [excess]);
457
+ maxEntriesRemoved = trimResult.rowCount ?? 0;
458
+ }
459
+ return { removed: (retentionResult.rowCount ?? 0) + maxEntriesRemoved };
460
+ }
461
+ async getRetryable(maxRetries = 3, limit = 10) {
462
+ const result = await this.pool.query(`
463
+ SELECT * FROM dead_letters
464
+ WHERE acknowledged = FALSE AND dlq_retry_count < $1
465
+ ORDER BY dlq_ts ASC LIMIT $2
466
+ `, [maxRetries, limit]);
467
+ return result.rows.map(row => this.rowToDeadLetter(row));
468
+ }
469
+ async close() {
470
+ // Pool managed externally
471
+ }
472
+ rowToDeadLetter(row) {
473
+ return {
474
+ id: row.id,
475
+ messageId: row.message_id,
476
+ from: row.from_agent,
477
+ to: row.to_agent,
478
+ topic: row.topic,
479
+ kind: row.kind,
480
+ body: row.body,
481
+ data: row.data,
482
+ thread: row.thread,
483
+ originalTs: parseInt(row.original_ts, 10),
484
+ dlqTs: parseInt(row.dlq_ts, 10),
485
+ reason: row.reason,
486
+ errorMessage: row.error_message,
487
+ attemptCount: row.attempt_count,
488
+ lastAttemptTs: row.last_attempt_ts ? parseInt(row.last_attempt_ts, 10) : undefined,
489
+ dlqRetryCount: row.dlq_retry_count,
490
+ acknowledged: row.acknowledged,
491
+ acknowledgedTs: row.acknowledged_ts ? parseInt(row.acknowledged_ts, 10) : undefined,
492
+ acknowledgedBy: row.acknowledged_by,
493
+ };
494
+ }
495
+ }
496
+ // =============================================================================
497
+ // In-Memory Adapter (for testing)
498
+ // =============================================================================
499
+ export class InMemoryDLQAdapter {
500
+ letters = new Map();
501
+ async init() {
502
+ // No-op
503
+ }
504
+ async add(messageId, envelope, reason, attemptCount, errorMessage) {
505
+ const id = `dlq_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
506
+ const now = Date.now();
507
+ const deadLetter = {
508
+ id,
509
+ messageId,
510
+ from: envelope.from,
511
+ to: envelope.to,
512
+ topic: envelope.topic,
513
+ kind: envelope.kind,
514
+ body: envelope.body,
515
+ data: envelope.data,
516
+ thread: envelope.thread,
517
+ originalTs: envelope.ts,
518
+ dlqTs: now,
519
+ reason,
520
+ errorMessage,
521
+ attemptCount,
522
+ lastAttemptTs: now,
523
+ dlqRetryCount: 0,
524
+ acknowledged: false,
525
+ };
526
+ this.letters.set(id, deadLetter);
527
+ return deadLetter;
528
+ }
529
+ async get(id) {
530
+ return this.letters.get(id) ?? null;
531
+ }
532
+ async query(query = {}) {
533
+ let results = Array.from(this.letters.values());
534
+ if (query.to)
535
+ results = results.filter(l => l.to === query.to);
536
+ if (query.from)
537
+ results = results.filter(l => l.from === query.from);
538
+ if (query.reason)
539
+ results = results.filter(l => l.reason === query.reason);
540
+ if (query.acknowledged !== undefined)
541
+ results = results.filter(l => l.acknowledged === query.acknowledged);
542
+ if (query.afterTs)
543
+ results = results.filter(l => l.dlqTs > query.afterTs);
544
+ if (query.beforeTs)
545
+ results = results.filter(l => l.dlqTs < query.beforeTs);
546
+ const orderDir = query.orderDir ?? 'DESC';
547
+ const orderBy = query.orderBy ?? 'dlqTs';
548
+ results.sort((a, b) => {
549
+ const aVal = orderBy === 'originalTs' ? a.originalTs : orderBy === 'attemptCount' ? a.attemptCount : a.dlqTs;
550
+ const bVal = orderBy === 'originalTs' ? b.originalTs : orderBy === 'attemptCount' ? b.attemptCount : b.dlqTs;
551
+ return orderDir === 'ASC' ? aVal - bVal : bVal - aVal;
552
+ });
553
+ const offset = query.offset ?? 0;
554
+ const limit = query.limit ?? 100;
555
+ return results.slice(offset, offset + limit);
556
+ }
557
+ async acknowledge(id, acknowledgedBy = 'system') {
558
+ const letter = this.letters.get(id);
559
+ if (!letter || letter.acknowledged)
560
+ return false;
561
+ letter.acknowledged = true;
562
+ letter.acknowledgedTs = Date.now();
563
+ letter.acknowledgedBy = acknowledgedBy;
564
+ return true;
565
+ }
566
+ async acknowledgeMany(ids, acknowledgedBy = 'system') {
567
+ let count = 0;
568
+ for (const id of ids) {
569
+ if (await this.acknowledge(id, acknowledgedBy))
570
+ count++;
571
+ }
572
+ return count;
573
+ }
574
+ async incrementRetry(id) {
575
+ const letter = this.letters.get(id);
576
+ if (!letter)
577
+ return false;
578
+ letter.dlqRetryCount++;
579
+ letter.lastAttemptTs = Date.now();
580
+ return true;
581
+ }
582
+ async remove(id) {
583
+ return this.letters.delete(id);
584
+ }
585
+ async getStats() {
586
+ const letters = Array.from(this.letters.values());
587
+ const byReason = {
588
+ max_retries_exceeded: 0, ttl_expired: 0, connection_lost: 0, target_not_found: 0,
589
+ signature_invalid: 0, payload_too_large: 0, rate_limited: 0, unknown: 0,
590
+ };
591
+ const byTarget = {};
592
+ let unacknowledged = 0;
593
+ let totalRetry = 0;
594
+ for (const l of letters) {
595
+ byReason[l.reason]++;
596
+ byTarget[l.to] = (byTarget[l.to] ?? 0) + 1;
597
+ if (!l.acknowledged)
598
+ unacknowledged++;
599
+ totalRetry += l.dlqRetryCount;
600
+ }
601
+ const unackLetters = letters.filter(l => !l.acknowledged);
602
+ const timestamps = unackLetters.map(l => l.dlqTs);
603
+ return {
604
+ totalEntries: letters.length,
605
+ unacknowledged,
606
+ byReason,
607
+ byTarget,
608
+ oldestEntryTs: timestamps.length > 0 ? Math.min(...timestamps) : null,
609
+ newestEntryTs: timestamps.length > 0 ? Math.max(...timestamps) : null,
610
+ avgRetryCount: letters.length > 0 ? totalRetry / letters.length : 0,
611
+ };
612
+ }
613
+ async cleanup(retentionHours, maxEntries) {
614
+ const cutoffTs = Date.now() - retentionHours * 3600 * 1000;
615
+ let removed = 0;
616
+ for (const [id, letter] of this.letters) {
617
+ if (letter.dlqTs < cutoffTs) {
618
+ this.letters.delete(id);
619
+ removed++;
620
+ }
621
+ }
622
+ // Enforce max entries
623
+ if (this.letters.size > maxEntries) {
624
+ const sorted = Array.from(this.letters.entries())
625
+ .filter(([, l]) => l.acknowledged)
626
+ .sort((a, b) => a[1].dlqTs - b[1].dlqTs);
627
+ const excess = this.letters.size - maxEntries;
628
+ for (let i = 0; i < excess && i < sorted.length; i++) {
629
+ this.letters.delete(sorted[i][0]);
630
+ removed++;
631
+ }
632
+ }
633
+ return { removed };
634
+ }
635
+ async getRetryable(maxRetries = 3, limit = 10) {
636
+ return Array.from(this.letters.values())
637
+ .filter(l => !l.acknowledged && l.dlqRetryCount < maxRetries)
638
+ .sort((a, b) => a.dlqTs - b.dlqTs)
639
+ .slice(0, limit);
640
+ }
641
+ async close() {
642
+ this.letters.clear();
643
+ }
644
+ }
645
+ /**
646
+ * Create a DLQ adapter based on configuration.
647
+ */
648
+ export function createDLQAdapter(options) {
649
+ switch (options.type) {
650
+ case 'sqlite':
651
+ if (!options.sqlite)
652
+ throw new Error('SQLite database required');
653
+ return new SQLiteDLQAdapter(options.sqlite);
654
+ case 'postgres':
655
+ if (!options.postgres)
656
+ throw new Error('PostgreSQL pool required');
657
+ return new PostgresDLQAdapter(options.postgres);
658
+ case 'memory':
659
+ return new InMemoryDLQAdapter();
660
+ default:
661
+ throw new Error(`Unknown DLQ adapter type: ${options.type}`);
662
+ }
663
+ }
664
+ //# sourceMappingURL=dlq-adapter.js.map