@sguild/dispatcher 2.0.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.
Files changed (66) hide show
  1. package/README.md +354 -0
  2. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
  3. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
  4. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
  5. package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
  6. package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
  7. package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
  8. package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
  9. package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
  10. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
  11. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
  12. package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
  13. package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
  14. package/contracts/event-envelope/schema/envelope-v1.json +79 -0
  15. package/contracts/event-types-registry.json +541 -0
  16. package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
  17. package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
  18. package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
  19. package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
  20. package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
  21. package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
  22. package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
  23. package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
  24. package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
  25. package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
  26. package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
  27. package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
  28. package/dist/config.d.ts +67 -0
  29. package/dist/config.js +81 -0
  30. package/dist/dispatcher-errors.d.ts +20 -0
  31. package/dist/dispatcher-errors.js +42 -0
  32. package/dist/dispatcher.d.ts +123 -0
  33. package/dist/dispatcher.js +171 -0
  34. package/dist/dlq.d.ts +173 -0
  35. package/dist/dlq.js +391 -0
  36. package/dist/fanout-drain.d.ts +11 -0
  37. package/dist/fanout-drain.js +31 -0
  38. package/dist/fanout.d.ts +144 -0
  39. package/dist/fanout.js +321 -0
  40. package/dist/inbox.d.ts +125 -0
  41. package/dist/inbox.js +120 -0
  42. package/dist/index.d.ts +36 -0
  43. package/dist/index.js +70 -0
  44. package/dist/internal/id.d.ts +38 -0
  45. package/dist/internal/id.js +78 -0
  46. package/dist/internal/pg-search-path.d.ts +34 -0
  47. package/dist/internal/pg-search-path.js +55 -0
  48. package/dist/internal/resolve-contract-path.d.ts +41 -0
  49. package/dist/internal/resolve-contract-path.js +65 -0
  50. package/dist/observability.d.ts +24 -0
  51. package/dist/observability.js +37 -0
  52. package/dist/postgres-consumer.d.ts +175 -0
  53. package/dist/postgres-consumer.js +561 -0
  54. package/dist/postgres-transport.d.ts +70 -0
  55. package/dist/postgres-transport.js +144 -0
  56. package/dist/producer-db.d.ts +80 -0
  57. package/dist/producer-db.js +115 -0
  58. package/dist/registry.d.ts +94 -0
  59. package/dist/registry.js +99 -0
  60. package/dist/signature.d.ts +44 -0
  61. package/dist/signature.js +79 -0
  62. package/dist/types.d.ts +107 -0
  63. package/dist/types.js +13 -0
  64. package/dist/validator.d.ts +60 -0
  65. package/dist/validator.js +171 -0
  66. package/package.json +48 -0
package/dist/dlq.d.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Dispatcher dead-letter queue (DLQ) read and resolve API.
3
+ *
4
+ * Phase 2 Slice 5. Operator-facing surface for inspecting and resolving
5
+ * dispatcher_dead_letter rows produced by ConsumerLoop's dead-letter
6
+ * path (handler exhausted retries; envelope snapshot persisted with the
7
+ * error trace for replay or investigation).
8
+ *
9
+ * Multi-producer aware. Per ADR-0009, each producer-domain owns its own
10
+ * dispatcher_dead_letter table in its own DB. The fanout ConsumerLoop
11
+ * writes DLQ rows into the PRODUCER's DB (because that's where the
12
+ * cursor and seq advance happen), so reading the DLQ for a given
13
+ * consumer means scanning every producer this Platform instance has a
14
+ * DSN for. We resolve the set of producers from the registered event
15
+ * types' producer field and connect via producer-db.getProducerDb.
16
+ *
17
+ * DLQ list reads can be all-consumer or narrowed to one consumer. Detail,
18
+ * resolve, and replay stay row-id + producer keyed.
19
+ *
20
+ * Two read shapes:
21
+ *
22
+ * - `listDeadLetters(consumer, { includeResolved?, producer? })` returns
23
+ * the active DLQ aggregated across every producer this Platform can
24
+ * reach. Pass a consumer to narrow to one consumer, or null/empty to
25
+ * return all consumers. Pass `producer` to narrow to one producer.
26
+ *
27
+ * - `getDeadLetter(deadLetterId, { producer? })` returns one row by its
28
+ * `dlq_<UUID>` id. With `producer` set, queries only that producer's
29
+ * DB; without, scans every producer until the id is found.
30
+ *
31
+ * One write shape:
32
+ *
33
+ * - `resolveDeadLetter(deadLetterId, { producer, resolvedBy,
34
+ * resolutionNote? })` marks the row resolved with `resolved_at =
35
+ * NOW()`, the operator's identifier, and an optional note. The
36
+ * `producer` is required so we write to the correct DB. The
37
+ * dead-letter row itself stays in place for audit; resolution does
38
+ * not delete or replay.
39
+ */
40
+ import type { Prisma } from "@prisma/client";
41
+ export type DeadLetter = {
42
+ /** Which producer DB this DLQ row lives in. */
43
+ producer: string;
44
+ dead_letter_id: string;
45
+ consumer: string;
46
+ event_id: string;
47
+ attempt_count: number;
48
+ last_error: string;
49
+ last_error_stack: string | null;
50
+ envelope_snapshot: Prisma.JsonValue;
51
+ created_at: string;
52
+ resolved_at: string | null;
53
+ resolved_by: string | null;
54
+ resolution_note: string | null;
55
+ };
56
+ export type ListDeadLettersFilter = {
57
+ /** When true, includes rows where `resolved_at` is set. Default false (active rows only). */
58
+ includeResolved?: boolean;
59
+ /** Maximum rows to return across all producers, post-merge. Default 100; capped at 500. */
60
+ limit?: number;
61
+ /** Restrict to a single producer's DB. When absent, aggregates across all producers. */
62
+ producer?: string;
63
+ };
64
+ export type GetDeadLetterOptions = {
65
+ /** When set, only this producer's DB is queried. Without it, all producers are scanned. */
66
+ producer?: string;
67
+ };
68
+ export type ResolveDeadLetterInput = {
69
+ /** Which producer DB the dead-letter lives in. Required for routing the update. */
70
+ producer: string;
71
+ /** Operator identifier (Person ID, system handle, etc.) acting on the dead-letter. */
72
+ resolvedBy: string;
73
+ /** Optional free-text note describing the resolution. Stored on the row. */
74
+ resolutionNote?: string;
75
+ };
76
+ export type ReplayDeadLetterInput = {
77
+ /** Which producer DB the dead-letter lives in. */
78
+ producer: string;
79
+ /** Operator identifier credited on the auto-resolve when replay succeeds. */
80
+ replayedBy: string;
81
+ /** Optional free-text note added to the auto-resolve note alongside the timestamp. */
82
+ resolutionNote?: string;
83
+ };
84
+ export type ReplayDeadLetterOutcome = {
85
+ /** The post-replay dead-letter row (resolved when the replay POST returned 2xx). */
86
+ dead_letter: DeadLetter;
87
+ /** Consumer-domain (e.g., "sales") parsed from the row's consumer field. */
88
+ consumer_domain: string;
89
+ /** HTTP status code returned by the consumer inbox on the replay POST. */
90
+ consumer_status: number;
91
+ };
92
+ export declare class DeadLetterNotFoundError extends Error {
93
+ readonly deadLetterId: string;
94
+ constructor(deadLetterId: string);
95
+ }
96
+ export declare class DeadLetterAlreadyResolvedError extends Error {
97
+ readonly deadLetterId: string;
98
+ readonly resolvedAt: Date;
99
+ constructor(deadLetterId: string, resolvedAt: Date);
100
+ }
101
+ export declare class ProducerDbNotConfiguredError extends Error {
102
+ readonly producer: string;
103
+ constructor(producer: string);
104
+ }
105
+ export declare class ReplayConsumerUnknownError extends Error {
106
+ readonly consumer: string;
107
+ constructor(consumer: string);
108
+ }
109
+ export declare class ReplayDeliveryFailedError extends Error {
110
+ readonly statusCode: number | null;
111
+ readonly upstreamBody: string;
112
+ constructor(statusCode: number | null, upstreamBody: string, message: string);
113
+ }
114
+ /**
115
+ * Enumerate every known producer from the event-types registry. Each
116
+ * producer is a domain that emits at least one registered event type.
117
+ * Producers are deduped via Set; order is unimportant since results are
118
+ * sorted by created_at across the merged set.
119
+ */
120
+ export declare function knownProducers(): string[];
121
+ /**
122
+ * List dead-letters aggregated across every reachable producer DB.
123
+ * Default returns only active (unresolved) rows; pass `includeResolved:
124
+ * true` to include the resolved tail. Pass `consumer` to narrow to one
125
+ * consumer, or null/empty to return all consumers. Pass `producer` to
126
+ * narrow to one producer's DB. Sorted by `created_at` descending so the
127
+ * most recent failures surface first.
128
+ *
129
+ * A producer with no configured DSN (env var missing) is silently
130
+ * skipped — the boot log already names missing DSNs and the operator
131
+ * sees an explicit "consumer was excluded" entry there. Surfacing it as
132
+ * an error per call would be noise.
133
+ */
134
+ export declare function listDeadLetters(consumer: string | null | undefined, filter?: ListDeadLettersFilter): Promise<DeadLetter[]>;
135
+ /**
136
+ * Look up a single dead-letter by its `dlq_<UUID>` id. With `producer`
137
+ * set, only that producer's DB is queried; otherwise every reachable
138
+ * producer is scanned until the id is found. Returns null when the id
139
+ * does not match any row in any scanned producer's DB.
140
+ */
141
+ export declare function getDeadLetter(deadLetterId: string, options?: GetDeadLetterOptions): Promise<DeadLetter | null>;
142
+ /**
143
+ * Mark a dead-letter as resolved. Sets `resolved_at` to now, records
144
+ * the operator and the optional resolution note. The `producer` is
145
+ * required so we connect to the correct DB; callers typically learned
146
+ * it from a prior list/get call which already includes `producer` on
147
+ * every returned DeadLetter row.
148
+ *
149
+ * Throws DeadLetterNotFoundError if the id does not exist in the
150
+ * producer's DB; throws DeadLetterAlreadyResolvedError if the row was
151
+ * already resolved.
152
+ */
153
+ export declare function resolveDeadLetter(deadLetterId: string, input: ResolveDeadLetterInput): Promise<DeadLetter>;
154
+ /**
155
+ * Re-POST the dead-letter's stored envelope_snapshot to its consumer
156
+ * inbox. If the consumer returns 2xx, auto-resolves the DLQ row with a
157
+ * note recording the replay timestamp and the operator. If the consumer
158
+ * returns non-2xx (or the POST fails for any reason), the DLQ row stays
159
+ * active and the caller receives the upstream status + body for
160
+ * operator inspection.
161
+ *
162
+ * Replay only works for fanout-style consumers (`fanout:<domain>`)
163
+ * because the replay path needs a registered consumer URL + HMAC
164
+ * secret. Non-fanout consumers (e.g., in-process subscribers from the
165
+ * pre-topology-B era) throw ReplayConsumerUnknownError; their replay
166
+ * mechanism is "fix the bug, restart the worker, and roll back the
167
+ * cursor."
168
+ *
169
+ * Idempotent against double-clicks: a row that's already resolved
170
+ * throws DeadLetterAlreadyResolvedError so the operator sees the
171
+ * existing resolution rather than triggering a duplicate POST.
172
+ */
173
+ export declare function replayDeadLetter(deadLetterId: string, input: ReplayDeadLetterInput): Promise<ReplayDeadLetterOutcome>;
package/dist/dlq.js ADDED
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ /**
3
+ * Dispatcher dead-letter queue (DLQ) read and resolve API.
4
+ *
5
+ * Phase 2 Slice 5. Operator-facing surface for inspecting and resolving
6
+ * dispatcher_dead_letter rows produced by ConsumerLoop's dead-letter
7
+ * path (handler exhausted retries; envelope snapshot persisted with the
8
+ * error trace for replay or investigation).
9
+ *
10
+ * Multi-producer aware. Per ADR-0009, each producer-domain owns its own
11
+ * dispatcher_dead_letter table in its own DB. The fanout ConsumerLoop
12
+ * writes DLQ rows into the PRODUCER's DB (because that's where the
13
+ * cursor and seq advance happen), so reading the DLQ for a given
14
+ * consumer means scanning every producer this Platform instance has a
15
+ * DSN for. We resolve the set of producers from the registered event
16
+ * types' producer field and connect via producer-db.getProducerDb.
17
+ *
18
+ * DLQ list reads can be all-consumer or narrowed to one consumer. Detail,
19
+ * resolve, and replay stay row-id + producer keyed.
20
+ *
21
+ * Two read shapes:
22
+ *
23
+ * - `listDeadLetters(consumer, { includeResolved?, producer? })` returns
24
+ * the active DLQ aggregated across every producer this Platform can
25
+ * reach. Pass a consumer to narrow to one consumer, or null/empty to
26
+ * return all consumers. Pass `producer` to narrow to one producer.
27
+ *
28
+ * - `getDeadLetter(deadLetterId, { producer? })` returns one row by its
29
+ * `dlq_<UUID>` id. With `producer` set, queries only that producer's
30
+ * DB; without, scans every producer until the id is found.
31
+ *
32
+ * One write shape:
33
+ *
34
+ * - `resolveDeadLetter(deadLetterId, { producer, resolvedBy,
35
+ * resolutionNote? })` marks the row resolved with `resolved_at =
36
+ * NOW()`, the operator's identifier, and an optional note. The
37
+ * `producer` is required so we write to the correct DB. The
38
+ * dead-letter row itself stays in place for audit; resolution does
39
+ * not delete or replay.
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.ReplayDeliveryFailedError = exports.ReplayConsumerUnknownError = exports.ProducerDbNotConfiguredError = exports.DeadLetterAlreadyResolvedError = exports.DeadLetterNotFoundError = void 0;
43
+ exports.knownProducers = knownProducers;
44
+ exports.listDeadLetters = listDeadLetters;
45
+ exports.getDeadLetter = getDeadLetter;
46
+ exports.resolveDeadLetter = resolveDeadLetter;
47
+ exports.replayDeadLetter = replayDeadLetter;
48
+ const fanout_1 = require("./fanout");
49
+ const producer_db_1 = require("./producer-db");
50
+ const registry_1 = require("./registry");
51
+ // =============================================================================
52
+ // Errors
53
+ // =============================================================================
54
+ class DeadLetterNotFoundError extends Error {
55
+ deadLetterId;
56
+ constructor(deadLetterId) {
57
+ super(`Dead-letter ${deadLetterId} not found`);
58
+ this.deadLetterId = deadLetterId;
59
+ this.name = "DeadLetterNotFoundError";
60
+ }
61
+ }
62
+ exports.DeadLetterNotFoundError = DeadLetterNotFoundError;
63
+ class DeadLetterAlreadyResolvedError extends Error {
64
+ deadLetterId;
65
+ resolvedAt;
66
+ constructor(deadLetterId, resolvedAt) {
67
+ super(`Dead-letter ${deadLetterId} was already resolved at ${resolvedAt.toISOString()}`);
68
+ this.deadLetterId = deadLetterId;
69
+ this.resolvedAt = resolvedAt;
70
+ this.name = "DeadLetterAlreadyResolvedError";
71
+ }
72
+ }
73
+ exports.DeadLetterAlreadyResolvedError = DeadLetterAlreadyResolvedError;
74
+ class ProducerDbNotConfiguredError extends Error {
75
+ producer;
76
+ constructor(producer) {
77
+ super(`Producer '${producer}' has no DSN configured (DISPATCHER_PRODUCER_DATABASE_URL_${producer.toUpperCase()} or DATABASE_URL for platform). Dead-letter operations against this producer cannot run.`);
78
+ this.producer = producer;
79
+ this.name = "ProducerDbNotConfiguredError";
80
+ }
81
+ }
82
+ exports.ProducerDbNotConfiguredError = ProducerDbNotConfiguredError;
83
+ class ReplayConsumerUnknownError extends Error {
84
+ consumer;
85
+ constructor(consumer) {
86
+ super(`Cannot replay dead-letter: consumer '${consumer}' is not a fanout consumer (expected 'fanout:<domain>' for a known consumer domain). Non-fanout consumers run in-process and have no replay endpoint.`);
87
+ this.consumer = consumer;
88
+ this.name = "ReplayConsumerUnknownError";
89
+ }
90
+ }
91
+ exports.ReplayConsumerUnknownError = ReplayConsumerUnknownError;
92
+ class ReplayDeliveryFailedError extends Error {
93
+ statusCode;
94
+ upstreamBody;
95
+ constructor(statusCode, upstreamBody, message) {
96
+ super(message);
97
+ this.statusCode = statusCode;
98
+ this.upstreamBody = upstreamBody;
99
+ this.name = "ReplayDeliveryFailedError";
100
+ }
101
+ }
102
+ exports.ReplayDeliveryFailedError = ReplayDeliveryFailedError;
103
+ // =============================================================================
104
+ // Public API
105
+ // =============================================================================
106
+ const DEFAULT_LIST_LIMIT = 100;
107
+ const MAX_LIST_LIMIT = 500;
108
+ /**
109
+ * Enumerate every known producer from the event-types registry. Each
110
+ * producer is a domain that emits at least one registered event type.
111
+ * Producers are deduped via Set; order is unimportant since results are
112
+ * sorted by created_at across the merged set.
113
+ */
114
+ function knownProducers() {
115
+ const manifest = (0, registry_1.loadRegistry)();
116
+ const set = new Set();
117
+ for (const entry of Object.values(manifest.events)) {
118
+ set.add(entry.producer);
119
+ }
120
+ return Array.from(set);
121
+ }
122
+ /**
123
+ * List dead-letters aggregated across every reachable producer DB.
124
+ * Default returns only active (unresolved) rows; pass `includeResolved:
125
+ * true` to include the resolved tail. Pass `consumer` to narrow to one
126
+ * consumer, or null/empty to return all consumers. Pass `producer` to
127
+ * narrow to one producer's DB. Sorted by `created_at` descending so the
128
+ * most recent failures surface first.
129
+ *
130
+ * A producer with no configured DSN (env var missing) is silently
131
+ * skipped — the boot log already names missing DSNs and the operator
132
+ * sees an explicit "consumer was excluded" entry there. Surfacing it as
133
+ * an error per call would be noise.
134
+ */
135
+ async function listDeadLetters(consumer, filter = {}) {
136
+ const consumerFilter = consumer?.trim() || null;
137
+ const limit = Math.min(filter.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
138
+ const producers = filter.producer ? [filter.producer] : knownProducers();
139
+ const merged = [];
140
+ const aggregateRead = !filter.producer;
141
+ for (const producer of producers) {
142
+ const db = (0, producer_db_1.getProducerDb)(producer);
143
+ if (!db)
144
+ continue;
145
+ const tablePrefix = db.schema ? `"${db.schema}".` : "";
146
+ const whereClauses = [];
147
+ const params = [];
148
+ if (consumerFilter) {
149
+ params.push(consumerFilter);
150
+ whereClauses.push(`consumer = $${params.length}`);
151
+ }
152
+ if (!filter.includeResolved) {
153
+ whereClauses.push("resolved_at IS NULL");
154
+ }
155
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
156
+ const sql = `SELECT id, consumer, event_id, attempt_count, last_error,
157
+ last_error_stack, envelope_snapshot, created_at,
158
+ resolved_at, resolved_by, resolution_note
159
+ FROM ${tablePrefix}dispatcher_dead_letter
160
+ ${whereSql}
161
+ ORDER BY created_at DESC
162
+ LIMIT ${limit}`;
163
+ let rows;
164
+ try {
165
+ const result = await db.pool.query(sql, params);
166
+ rows = result.rows;
167
+ }
168
+ catch (e) {
169
+ if (!aggregateRead)
170
+ throw e;
171
+ console.warn(`[dispatcher.dlq] producer ${producer} excluded from aggregate read: ${e instanceof Error ? e.message : String(e)}`);
172
+ continue;
173
+ }
174
+ for (const row of rows) {
175
+ merged.push(toDeadLetterFromRaw(producer, row));
176
+ }
177
+ }
178
+ merged.sort((a, b) => b.created_at.localeCompare(a.created_at));
179
+ return merged.slice(0, limit);
180
+ }
181
+ /**
182
+ * Look up a single dead-letter by its `dlq_<UUID>` id. With `producer`
183
+ * set, only that producer's DB is queried; otherwise every reachable
184
+ * producer is scanned until the id is found. Returns null when the id
185
+ * does not match any row in any scanned producer's DB.
186
+ */
187
+ async function getDeadLetter(deadLetterId, options = {}) {
188
+ if (!deadLetterId)
189
+ return null;
190
+ const producers = options.producer ? [options.producer] : knownProducers();
191
+ const aggregateRead = !options.producer;
192
+ for (const producer of producers) {
193
+ const db = (0, producer_db_1.getProducerDb)(producer);
194
+ if (!db)
195
+ continue;
196
+ const tablePrefix = db.schema ? `"${db.schema}".` : "";
197
+ const sql = `SELECT id, consumer, event_id, attempt_count, last_error,
198
+ last_error_stack, envelope_snapshot, created_at,
199
+ resolved_at, resolved_by, resolution_note
200
+ FROM ${tablePrefix}dispatcher_dead_letter
201
+ WHERE id = $1
202
+ LIMIT 1`;
203
+ let rows;
204
+ try {
205
+ const result = await db.pool.query(sql, [deadLetterId]);
206
+ rows = result.rows;
207
+ }
208
+ catch (e) {
209
+ if (!aggregateRead)
210
+ throw e;
211
+ console.warn(`[dispatcher.dlq] producer ${producer} excluded from aggregate detail read: ${e instanceof Error ? e.message : String(e)}`);
212
+ continue;
213
+ }
214
+ if (rows[0]) {
215
+ return toDeadLetterFromRaw(producer, rows[0]);
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+ /**
221
+ * Mark a dead-letter as resolved. Sets `resolved_at` to now, records
222
+ * the operator and the optional resolution note. The `producer` is
223
+ * required so we connect to the correct DB; callers typically learned
224
+ * it from a prior list/get call which already includes `producer` on
225
+ * every returned DeadLetter row.
226
+ *
227
+ * Throws DeadLetterNotFoundError if the id does not exist in the
228
+ * producer's DB; throws DeadLetterAlreadyResolvedError if the row was
229
+ * already resolved.
230
+ */
231
+ async function resolveDeadLetter(deadLetterId, input) {
232
+ if (!deadLetterId) {
233
+ throw new DeadLetterNotFoundError(deadLetterId);
234
+ }
235
+ if (!input.resolvedBy) {
236
+ throw new Error("resolveDeadLetter: resolvedBy is required");
237
+ }
238
+ if (!input.producer) {
239
+ throw new Error("resolveDeadLetter: producer is required");
240
+ }
241
+ const db = (0, producer_db_1.getProducerDb)(input.producer);
242
+ if (!db) {
243
+ throw new ProducerDbNotConfiguredError(input.producer);
244
+ }
245
+ const tablePrefix = db.schema ? `"${db.schema}".` : "";
246
+ const client = await db.pool.connect();
247
+ try {
248
+ await client.query("BEGIN");
249
+ const { rows: existingRows } = await client.query(`SELECT id, resolved_at
250
+ FROM ${tablePrefix}dispatcher_dead_letter
251
+ WHERE id = $1
252
+ FOR UPDATE`, [deadLetterId]);
253
+ const existing = existingRows[0];
254
+ if (!existing) {
255
+ await client.query("ROLLBACK");
256
+ throw new DeadLetterNotFoundError(deadLetterId);
257
+ }
258
+ if (existing.resolved_at !== null) {
259
+ await client.query("ROLLBACK");
260
+ throw new DeadLetterAlreadyResolvedError(deadLetterId, existing.resolved_at);
261
+ }
262
+ const { rows: updatedRows } = await client.query(`UPDATE ${tablePrefix}dispatcher_dead_letter
263
+ SET resolved_at = NOW(),
264
+ resolved_by = $1,
265
+ resolution_note = $2
266
+ WHERE id = $3
267
+ RETURNING id, consumer, event_id, attempt_count, last_error,
268
+ last_error_stack, envelope_snapshot, created_at,
269
+ resolved_at, resolved_by, resolution_note`, [input.resolvedBy, input.resolutionNote ?? null, deadLetterId]);
270
+ await client.query("COMMIT");
271
+ return toDeadLetterFromRaw(input.producer, updatedRows[0]);
272
+ }
273
+ catch (e) {
274
+ if (!(e instanceof DeadLetterNotFoundError) &&
275
+ !(e instanceof DeadLetterAlreadyResolvedError)) {
276
+ try {
277
+ await client.query("ROLLBACK");
278
+ }
279
+ catch { }
280
+ }
281
+ throw e;
282
+ }
283
+ finally {
284
+ client.release();
285
+ }
286
+ }
287
+ /**
288
+ * Re-POST the dead-letter's stored envelope_snapshot to its consumer
289
+ * inbox. If the consumer returns 2xx, auto-resolves the DLQ row with a
290
+ * note recording the replay timestamp and the operator. If the consumer
291
+ * returns non-2xx (or the POST fails for any reason), the DLQ row stays
292
+ * active and the caller receives the upstream status + body for
293
+ * operator inspection.
294
+ *
295
+ * Replay only works for fanout-style consumers (`fanout:<domain>`)
296
+ * because the replay path needs a registered consumer URL + HMAC
297
+ * secret. Non-fanout consumers (e.g., in-process subscribers from the
298
+ * pre-topology-B era) throw ReplayConsumerUnknownError; their replay
299
+ * mechanism is "fix the bug, restart the worker, and roll back the
300
+ * cursor."
301
+ *
302
+ * Idempotent against double-clicks: a row that's already resolved
303
+ * throws DeadLetterAlreadyResolvedError so the operator sees the
304
+ * existing resolution rather than triggering a duplicate POST.
305
+ */
306
+ async function replayDeadLetter(deadLetterId, input) {
307
+ if (!deadLetterId) {
308
+ throw new DeadLetterNotFoundError(deadLetterId);
309
+ }
310
+ if (!input.replayedBy) {
311
+ throw new Error("replayDeadLetter: replayedBy is required");
312
+ }
313
+ if (!input.producer) {
314
+ throw new Error("replayDeadLetter: producer is required");
315
+ }
316
+ // Load the dead-letter; reject early if missing or already resolved
317
+ // so we don't waste a POST.
318
+ const existing = await getDeadLetter(deadLetterId, { producer: input.producer });
319
+ if (!existing) {
320
+ throw new DeadLetterNotFoundError(deadLetterId);
321
+ }
322
+ if (existing.resolved_at !== null) {
323
+ throw new DeadLetterAlreadyResolvedError(deadLetterId, new Date(existing.resolved_at));
324
+ }
325
+ // Parse the consumer-domain from the row's consumer string. Replay
326
+ // only supports fanout:<domain> consumers because non-fanout consumers
327
+ // don't have a webhook URL to POST to.
328
+ if (!existing.consumer.startsWith(fanout_1.FANOUT_CONSUMER_PREFIX)) {
329
+ throw new ReplayConsumerUnknownError(existing.consumer);
330
+ }
331
+ const domain = existing.consumer.slice(fanout_1.FANOUT_CONSUMER_PREFIX.length);
332
+ if (!fanout_1.KNOWN_CONSUMER_DOMAINS.includes(domain)) {
333
+ throw new ReplayConsumerUnknownError(existing.consumer);
334
+ }
335
+ // Reconstruct the envelope from the snapshot. The snapshot is the
336
+ // exact JSON object the fanout originally POSTed; we re-sign and
337
+ // re-POST with the current HMAC secret so signature rotation since
338
+ // the original delivery is handled transparently.
339
+ const envelope = existing.envelope_snapshot;
340
+ let consumerStatus = 0;
341
+ try {
342
+ await (0, fanout_1.postEnvelopeToConsumer)(domain, envelope);
343
+ consumerStatus = 200;
344
+ }
345
+ catch (e) {
346
+ if (e instanceof fanout_1.WebhookDispatchError) {
347
+ throw new ReplayDeliveryFailedError(e.statusCode, e.message, `Replay POST to ${e.consumer}'s inbox failed: ${e.message}`);
348
+ }
349
+ throw e;
350
+ }
351
+ // Replay succeeded; auto-resolve the row so the DLQ doesn't keep
352
+ // showing it. The resolution note records what happened so an
353
+ // operator looking back can tell this was a replay, not a manual
354
+ // close.
355
+ const replayedAt = new Date().toISOString();
356
+ const baseNote = `replayed via DLQ console at ${replayedAt}`;
357
+ const fullNote = input.resolutionNote
358
+ ? `${baseNote} — ${input.resolutionNote}`
359
+ : baseNote;
360
+ const resolved = await resolveDeadLetter(deadLetterId, {
361
+ producer: input.producer,
362
+ resolvedBy: input.replayedBy,
363
+ resolutionNote: fullNote,
364
+ });
365
+ return {
366
+ dead_letter: resolved,
367
+ consumer_domain: domain,
368
+ consumer_status: consumerStatus,
369
+ };
370
+ }
371
+ // =============================================================================
372
+ // Internal: raw pg row → DTO
373
+ // =============================================================================
374
+ function toDeadLetterFromRaw(producer, row) {
375
+ const createdAt = row.created_at;
376
+ const resolvedAt = row.resolved_at;
377
+ return {
378
+ producer,
379
+ dead_letter_id: String(row.id),
380
+ consumer: String(row.consumer),
381
+ event_id: String(row.event_id),
382
+ attempt_count: Number(row.attempt_count),
383
+ last_error: String(row.last_error),
384
+ last_error_stack: row.last_error_stack ?? null,
385
+ envelope_snapshot: row.envelope_snapshot,
386
+ created_at: createdAt.toISOString(),
387
+ resolved_at: resolvedAt ? resolvedAt.toISOString() : null,
388
+ resolved_by: row.resolved_by ?? null,
389
+ resolution_note: row.resolution_note ?? null,
390
+ };
391
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Node-only SIGTERM/SIGINT drain registration for the fanout worker.
3
+ *
4
+ * Lives in its own module so `instrumentation.ts` can reference it only via
5
+ * dynamic import behind the `process.env.NEXT_RUNTIME === "nodejs"` guard.
6
+ * Doing it this way keeps `process.once` out of the file the Next.js
7
+ * bundler statically analyzes for Edge Runtime compatibility — otherwise
8
+ * the build emits an "Ecmascript file had an error: A Node.js API is used"
9
+ * warning even though the call site is gated and never executes off Node.
10
+ */
11
+ export declare function registerFanoutDrain(stopFanout: () => Promise<void>): void;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * Node-only SIGTERM/SIGINT drain registration for the fanout worker.
4
+ *
5
+ * Lives in its own module so `instrumentation.ts` can reference it only via
6
+ * dynamic import behind the `process.env.NEXT_RUNTIME === "nodejs"` guard.
7
+ * Doing it this way keeps `process.once` out of the file the Next.js
8
+ * bundler statically analyzes for Edge Runtime compatibility — otherwise
9
+ * the build emits an "Ecmascript file had an error: A Node.js API is used"
10
+ * warning even though the call site is gated and never executes off Node.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.registerFanoutDrain = registerFanoutDrain;
14
+ function registerFanoutDrain(stopFanout) {
15
+ const drainOnTermination = async () => {
16
+ try {
17
+ const drainPromise = stopFanout();
18
+ const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 30_000));
19
+ await Promise.race([drainPromise, timeoutPromise]);
20
+ }
21
+ catch (e) {
22
+ console.error(`[fanout-drain] stop error: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
23
+ }
24
+ };
25
+ process.once("SIGTERM", () => {
26
+ void drainOnTermination();
27
+ });
28
+ process.once("SIGINT", () => {
29
+ void drainOnTermination();
30
+ });
31
+ }