@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.
- package/README.md +354 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
- package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
- package/contracts/event-envelope/schema/envelope-v1.json +79 -0
- package/contracts/event-types-registry.json +541 -0
- package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
- package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
- package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
- package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
- package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.js +81 -0
- package/dist/dispatcher-errors.d.ts +20 -0
- package/dist/dispatcher-errors.js +42 -0
- package/dist/dispatcher.d.ts +123 -0
- package/dist/dispatcher.js +171 -0
- package/dist/dlq.d.ts +173 -0
- package/dist/dlq.js +391 -0
- package/dist/fanout-drain.d.ts +11 -0
- package/dist/fanout-drain.js +31 -0
- package/dist/fanout.d.ts +144 -0
- package/dist/fanout.js +321 -0
- package/dist/inbox.d.ts +125 -0
- package/dist/inbox.js +120 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +70 -0
- package/dist/internal/id.d.ts +38 -0
- package/dist/internal/id.js +78 -0
- package/dist/internal/pg-search-path.d.ts +34 -0
- package/dist/internal/pg-search-path.js +55 -0
- package/dist/internal/resolve-contract-path.d.ts +41 -0
- package/dist/internal/resolve-contract-path.js +65 -0
- package/dist/observability.d.ts +24 -0
- package/dist/observability.js +37 -0
- package/dist/postgres-consumer.d.ts +175 -0
- package/dist/postgres-consumer.js +561 -0
- package/dist/postgres-transport.d.ts +70 -0
- package/dist/postgres-transport.js +144 -0
- package/dist/producer-db.d.ts +80 -0
- package/dist/producer-db.js +115 -0
- package/dist/registry.d.ts +94 -0
- package/dist/registry.js +99 -0
- package/dist/signature.d.ts +44 -0
- package/dist/signature.js +79 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +13 -0
- package/dist/validator.d.ts +60 -0
- package/dist/validator.js +171 -0
- 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
|
+
}
|