@bymax-one/nest-core 1.5.3 → 1.6.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/CHANGELOG.md CHANGED
@@ -11,6 +11,133 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.6.0] - 2026-08-29
15
+
16
+ A readiness check that fails without rejecting left no record an operator would
17
+ ever read, and one that fails by rejecting left one per probe. Both are the same
18
+ missing seam, and this release adds it: the aggregator now holds the last state
19
+ of every check and reports each **change** — to its own logger, and to an
20
+ optional sink a consumer binds.
21
+
22
+ **Apply to a derived backend:** nothing is required. Transitions now reach Nest's
23
+ logger on every path, where before only a rejection did. Bind
24
+ `BYMAX_HEALTH_TRANSITION_SINK` to route them into a structured logging surface,
25
+ and read `cause.kind` to tell the three failure shapes apart. If you have written
26
+ a health reporter of your own, delete it — including any wrapper that races a
27
+ timer beneath `health.indicatorTimeoutMs` to recover a verdict, which this seam
28
+ makes unnecessary.
29
+
30
+ ### Added
31
+
32
+ - **`IHealthTransitionSink`, bound under `BYMAX_HEALTH_TRANSITION_SINK`.**
33
+ Receives one `HealthTransition` per change of readiness state, per check name —
34
+ never one per probe. `@Optional()` and unbound by default, like
35
+ `BYMAX_HEALTH_INDICATORS`, so a consumer's binding is never shadowed by a local
36
+ one.
37
+
38
+ With no sink the aggregator writes the transition to Nest's logger, so a
39
+ readiness failure is never silent by default; binding one stands that line down
40
+ in favour of the sink. Both destinations are usually the same logger in a
41
+ consuming application, and two records of one transition side by side is the
42
+ noise this feature exists to remove. The sink is handed the cause as structured
43
+ data, strictly more than the line renders, so what reaches the log after that
44
+ is the consumer's decision rather than this package's.
45
+
46
+ The de-duplication rule lives in the aggregator rather than in the sink, and
47
+ that is the whole point. A readiness check runs every few seconds, so a line
48
+ per failing probe turns one outage into thousands of identical records that
49
+ bury the one carrying the cause; leaving that rule to each consumer means every
50
+ backend re-deriving it, slightly differently. A sink that never sees raw
51
+ outcomes cannot get it wrong.
52
+
53
+ Overlapping probes are ordered by when they started, not by when they
54
+ finished. Readiness is not called one at a time — an orchestrator's probe and a
55
+ load balancer's health check reach it concurrently — and a dependency that
56
+ hangs until the bound elapses is exactly what makes an earlier probe finish
57
+ last. Comparing states alone would write such an outage backwards: the later
58
+ probe reports the recovery, and the earlier one's timeout lands behind it and
59
+ reports the dependency down again on evidence that is already stale.
60
+
61
+ The rule is asymmetric on a first observation, deliberately. A first
62
+ observation that is **failing** is reported — a process that boots against a
63
+ dependency already down would otherwise look healthy in the log forever — while
64
+ a first observation that is healthy is not, since announcing the expected state
65
+ would write one line per dependency on every boot.
66
+
67
+ - **`HealthTransitionCause`, distinguishing the three ways a check is down.**
68
+ `reported-down` (the indicator answered and said so), `rejected` (carrying the
69
+ summarized message, bounded to 300 characters), and `timed-out` (carrying the
70
+ `timeoutMs` that elapsed). A discriminated union rather than a string, so a
71
+ consumer switches exhaustively and the compiler names the arm it has not
72
+ handled if a future version adds one.
73
+
74
+ `timed-out` is the one that exists nowhere else. An indicator this package
75
+ gave up on is never told, so it reports nothing, and a consumer racing its own
76
+ timer beneath `indicatorTimeoutMs` still never learns the bound that actually
77
+ applied. A hung dependency is also the common shape rather than a refused one:
78
+ a database under load, a network partition and a paused container all hang,
79
+ while a refusal returns immediately and would have been reported. The obstacle
80
+ for a consumer is information, not effort — which is the argument for this
81
+ living in the library at all.
82
+
83
+ ### Fixed
84
+
85
+ - **An `Error` whose `message` is not a string broke the readiness aggregation.**
86
+ `message` is a writable property, so `Object.assign(new Error(), { message:
87
+ null })` is an `Error` that reads without throwing and then throws on every
88
+ string operation. The summarizer coerced a non-`Error` reason but trusted an
89
+ `Error`'s own `message`, then measured and truncated it outside that guard.
90
+
91
+ It surfaced where the guard was supposed to hold. Summarizing runs inside the
92
+ rejection-to-`down` conversion, so an indicator rejecting with such an error
93
+ rejected the whole aggregation: the probe answered `500` instead of `503`, and
94
+ reported nothing about the dependencies that were healthy — the exact outcome
95
+ the conversion exists to prevent.
96
+
97
+ **This is present in 1.5.3 and earlier**, on the indicator path. The transition
98
+ sink added in this release reaches the same summarizer, so the fix covers both.
99
+
100
+ - **An `async` timing sink could take the process down.** `ITimingSink.record`
101
+ is declared to return `void`, and TypeScript accepts any return value in a
102
+ void-returning position, so `async record()` compiles — and it is the natural
103
+ shape when the backend behind the sink is async. Its rejection settled a
104
+ microtask after the recorder's `try`/`catch` had exited, so instead of the
105
+ contained failure the contract promises it became an unhandled rejection, able
106
+ to kill the process under `--unhandled-rejections=strict`. An observer that can
107
+ break what it observes is the one thing a fire-and-forget contract exists to
108
+ rule out.
109
+
110
+ Both the middleware and the deprecated interceptor were affected, and both now
111
+ route delivery through one implementation rather than two copies of the same
112
+ `try`/`catch` — the containment guarantee is worth exactly as much as its least
113
+ careful copy. Nothing to change in a consumer: a sink that already returned
114
+ synchronously behaves identically, and an `async` one is now caught.
115
+
116
+ Found while reviewing the health transition sink, which had the same hole
117
+ before release.
118
+
119
+ ### Changed
120
+
121
+ - **A rejecting indicator is logged once per outage, not once per probe.** The
122
+ aggregator already wrote a warning for a rejection, on every readiness check
123
+ for as long as the dependency stayed down: roughly sixty identical lines for a
124
+ ten-minute outage probed every ten seconds. That line now follows the
125
+ transition rule like every other path. No API moves; log volume does, and a
126
+ recovery now writes a line where nothing did before.
127
+
128
+ This arrives with the upgrade, not with the binding — a deployment that wires
129
+ no sink still stops repeating.
130
+
131
+ The cost is stated rather than hidden: a dependency that stays down while its
132
+ failure mode changes underneath keeps the cause observed **at the transition**.
133
+ That is the trade for one line per outage instead of one per probe.
134
+
135
+ - **The root bundle's size budget moved from 15 to 17 KiB brotli**, measured
136
+ 14.25 → 15.82. Checked before the number moved: the transition contract is
137
+ types-only and erases at build time, no module entered the root that was not
138
+ already there, and the rationale prose was moved into the erased file — this
139
+ bundle ships its comments — before the budget was touched.
140
+
14
141
  ## [1.5.3] - 2026-08-18
15
142
 
16
143
  Three findings from a functional and security audit of a derived backend
@@ -875,4 +1002,5 @@ have regressed from. They are kept because the reasoning is worth having.
875
1002
  [1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
876
1003
  [1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
877
1004
  [1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
878
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.5.3...HEAD
1005
+ [1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
1006
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.0...HEAD
package/README.md CHANGED
@@ -515,9 +515,9 @@ requirement.
515
515
 
516
516
  ## 🔑 DI Tokens
517
517
 
518
- Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
519
- `BYMAX_HEALTH_INDICATORS` are consumed with `@Optional()` and are not bound by
520
- the module: provide either from your own module to supply your own
518
+ Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
519
+ `BYMAX_HEALTH_INDICATORS` and `BYMAX_HEALTH_TRANSITION_SINK` are consumed with
520
+ `@Optional()` and are not bound by the module: provide either from your own module to supply your own
521
521
  implementation, otherwise the internal fallback in the last column applies.
522
522
  `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
523
523
  `forRootAsync`, where options resolve after the module is defined: there the
@@ -528,14 +528,15 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
528
528
  [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
529
529
  below.
530
530
 
531
- | Token | Provides | When you do not provide one |
532
- | ---------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
533
- | `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
534
- | `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
535
- | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
536
- | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
537
- | `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
538
- | `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
531
+ | Token | Provides | When you do not provide one |
532
+ | ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
533
+ | `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
534
+ | `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
535
+ | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
536
+ | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
537
+ | `BYMAX_HEALTH_TRANSITION_SINK` | `IHealthTransitionSink` | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
538
+ | `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
539
+ | `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
539
540
 
540
541
  ## 🚨 Error Envelope
541
542
 
@@ -757,6 +758,13 @@ class LoggerTimingSink implements ITimingSink {
757
758
  export class ObservabilityModule {}
758
759
  ```
759
760
 
761
+ A sink that fails cannot reach the request: both a synchronous throw and a
762
+ rejection from an `async record()` are absorbed. Write it `async` if the backend
763
+ behind it is async — the `void` return type accepts it, and the rejection is
764
+ caught. The failure is swallowed rather than logged, unlike a health transition
765
+ sink, because this runs on every request and a systematically failing sink would
766
+ otherwise become a second flood beside the first.
767
+
760
768
  ## 📄 Pagination
761
769
 
762
770
  Framework-neutral, pure functions on the `./pagination` subpath: no NestJS
@@ -900,6 +908,82 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
900
908
  converted to a `down` entry with a safe, bounded diagnostic detail; it never
901
909
  hides the results of the other registered indicators.
902
910
 
911
+ ### A failing check leaves a record
912
+
913
+ A readiness failure that nothing records is a `503` an orchestrator acts on with
914
+ an empty log behind it, and diagnosing it means reproducing it. That is easy to
915
+ arrive at without deciding to: probe paths are the highest-volume request a
916
+ backend serves, so they are usually excluded from the HTTP log surface, and a
917
+ well-written indicator returns `{ status: 'down' }` rather than throwing —
918
+ because readiness is typically unauthenticated and a driver's error carries
919
+ hosts, ports and sometimes credentials.
920
+
921
+ So the aggregator records it. It holds the last state of every check and reports
922
+ each **change** — never once per probe — to its own logger, and to a sink you
923
+ bind:
924
+
925
+ ```typescript
926
+ import { Injectable } from '@nestjs/common'
927
+ import type { HealthTransition, IHealthTransitionSink } from '@bymax-one/nest-core/health'
928
+
929
+ @Injectable()
930
+ export class HealthTransitionLogger implements IHealthTransitionSink {
931
+ constructor(private readonly logger: MyStructuredLogger) {}
932
+
933
+ record(transition: HealthTransition): void {
934
+ if (transition.isUp) {
935
+ this.logger.info('HEALTH_CHECK_RECOVERED', { check: transition.name })
936
+ return
937
+ }
938
+ this.logger.warn('HEALTH_CHECK_DEGRADED', {
939
+ check: transition.name,
940
+ cause: transition.cause.kind
941
+ })
942
+ }
943
+ }
944
+ ```
945
+
946
+ Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module,
947
+ the same override pattern as the indicator token above.
948
+
949
+ Binding nothing is supported, and is not the silent case: the transitions reach
950
+ Nest's logger instead, so a readiness failure always leaves a record. **Binding a
951
+ sink stands that line down** — both destinations are usually the same logger, so
952
+ keeping it would put two records of one transition side by side, which is the
953
+ noise this feature exists to remove. Your sink receives the cause as structured
954
+ data, strictly more than the line renders, so what reaches the log after that is
955
+ your decision rather than this package's.
956
+
957
+ `transition.cause` distinguishes the three ways a check can be down, which the
958
+ `up`/`down` response body cannot:
959
+
960
+ | `cause.kind` | Meaning | Carries |
961
+ | --------------- | -------------------------------------------------- | ----------- |
962
+ | `reported-down` | The indicator answered, and reported it down. | — |
963
+ | `rejected` | The indicator rejected or threw. | `message` |
964
+ | `timed-out` | The aggregator gave up after `indicatorTimeoutMs`. | `timeoutMs` |
965
+
966
+ `timed-out` is the one that cannot be observed anywhere else. An indicator the
967
+ aggregator abandoned is never told, so it reports nothing — and a hung
968
+ dependency is the common shape, not a refused one: a database under load, a
969
+ network partition and a paused container all hang, while a refusal comes back
970
+ immediately and would have been reported. That is why the rule lives in the
971
+ aggregator rather than in each backend: the obstacle is information, not effort.
972
+
973
+ Two details worth knowing before you rely on it. A first observation that is
974
+ **failing** is reported, while a first observation that is healthy is not — a
975
+ process that boots against a dependency already down would otherwise look
976
+ healthy in the log forever, whereas announcing every healthy check would write a
977
+ line per dependency on every boot. And the cause is the one seen **at the
978
+ transition**: a dependency that stays down while its failure mode changes
979
+ underneath keeps the first cause, which is the cost of one line per outage
980
+ instead of one per probe.
981
+
982
+ The sink is called synchronously on the readiness path, so keep it cheap. A
983
+ throw is caught and logged rather than failing the probe — readiness answering
984
+ `500` because its own logging broke would take a healthy deployment out of
985
+ rotation — but do not use that as flow control.
986
+
903
987
  ### Discovered indicators
904
988
 
905
989
  Registering every indicator by hand stops scaling once the libraries an
@@ -1636,7 +1720,8 @@ in the sections above.
1636
1720
  | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1637
1721
  | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1638
1722
  | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1639
- | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1723
+ | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1724
+ | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1640
1725
  | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1641
1726
  | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1642
1727
  | `BymaxExceptionFilter` | class | The envelope exception filter. |
@@ -1667,6 +1752,9 @@ in the sections above.
1667
1752
  | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
1668
1753
  | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
1669
1754
  | `HealthResponse` | type | The stable liveness and readiness response shape. |
1755
+ | `IHealthTransitionSink` | type | Receives one event per change of readiness state. |
1756
+ | `HealthTransition` | type | One change of state, for one check. |
1757
+ | `HealthTransitionCause` | type | Why a check went down, as the aggregator sees it. |
1670
1758
  | `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
1671
1759
  | `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
1672
1760
 
@@ -1,4 +1,5 @@
1
1
  import { CustomDecorator } from '@nestjs/common';
2
+ export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.cjs';
2
3
 
3
4
  /**
4
5
  * Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
@@ -1,4 +1,5 @@
1
1
  import { CustomDecorator } from '@nestjs/common';
2
+ export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.js';
2
3
 
3
4
  /**
4
5
  * Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @fileoverview The readiness transition contract: a sink that receives one
3
+ * event each time a check changes state, and the cause that says why.
4
+ *
5
+ * This exists because a readiness failure can otherwise leave no record an
6
+ * operator will ever read. There are three ways for a check to be down, and the
7
+ * aggregator is the only place that can tell them apart: the indicator rejected,
8
+ * the indicator reported `down` on purpose, or the aggregator gave up waiting.
9
+ * The third is knowledge that exists nowhere else — an indicator the aggregator
10
+ * abandoned is never told, so it cannot report anything — and it is the shape a
11
+ * hung dependency takes. A database under load, a network partition and a paused
12
+ * container all hang; a refusal comes back immediately.
13
+ *
14
+ * Two decisions elsewhere combine to make that silence total in a typical
15
+ * deployment, and each is right on its own. Probe paths are excluded from the
16
+ * HTTP log surface, because probes are the highest-volume request a backend
17
+ * serves. And a well-written indicator returns `{ status: 'down' }` rather than
18
+ * throwing, because readiness is usually unauthenticated and a driver's error
19
+ * carries hosts, ports and sometimes credentials — which is exactly the row this
20
+ * package's own contract steers implementations toward.
21
+ *
22
+ * **Transitions, not outcomes.** The sink is called once per *change* of state
23
+ * per check name, never once per probe. That rule lives in the aggregator rather
24
+ * than in the sink deliberately: a readiness check runs every few seconds, so a
25
+ * line per failure turns one outage into thousands of identical records that
26
+ * bury the one carrying the cause. Leaving the de-duplication to each consumer
27
+ * would mean every backend re-deriving the same rule, and re-deriving it subtly
28
+ * differently. A sink that never sees raw outcomes cannot get it wrong.
29
+ * @layer Contract
30
+ */
31
+ /**
32
+ * Why a check is down, as only the aggregator can distinguish it.
33
+ *
34
+ * A discriminated union rather than a string, so a consumer switches
35
+ * exhaustively and the compiler reports the arm it has not handled when a future
36
+ * version adds one. "The dependency answered that it is down" and "the
37
+ * dependency accepted the work and never answered" are different outages calling
38
+ * for different responses, and the readiness body cannot tell them apart because
39
+ * it carries only `up` or `down`.
40
+ */
41
+ type HealthTransitionCause = {
42
+ /** The indicator rejected, or threw synchronously. */
43
+ readonly kind: 'rejected';
44
+ /**
45
+ * The rejection's top-level message, truncated to 300 characters. Never
46
+ * the raw error, its stack, or any nested cause.
47
+ *
48
+ * This reaches the sink whether or not `health.exposeIndicatorErrors` is
49
+ * set: that option governs what is written into the HTTP response, which
50
+ * is typically unauthenticated, while a sink is application-side code at
51
+ * the same trust level as the logger. An indicator usually does not author
52
+ * this text — it lets a driver's error propagate — so treat it as
53
+ * potentially carrying hosts and ports, and send it where access is
54
+ * already controlled.
55
+ */
56
+ readonly message: string;
57
+ } | {
58
+ /** The indicator answered, and reported its dependency unhealthy. */
59
+ readonly kind: 'reported-down';
60
+ } | {
61
+ /** The aggregator gave up waiting for the indicator. */
62
+ readonly kind: 'timed-out';
63
+ /** The bound that elapsed, from `health.indicatorTimeoutMs`. */
64
+ readonly timeoutMs: number;
65
+ };
66
+ /**
67
+ * One change of state for one check.
68
+ *
69
+ * A discriminated union on `isUp` rather than an object with an optional cause,
70
+ * so the compiler guarantees that a transition to `down` carries one and that a
71
+ * transition to `up` does not — a recovery has no cause to describe.
72
+ */
73
+ type HealthTransition = {
74
+ /** The check's name, as {@link IHealthIndicator.name} declares it. */
75
+ readonly name: string;
76
+ /** The dependency is reachable again. */
77
+ readonly isUp: true;
78
+ } | {
79
+ /** The check's name, as {@link IHealthIndicator.name} declares it. */
80
+ readonly name: string;
81
+ /** The dependency is not reachable. */
82
+ readonly isUp: false;
83
+ /** Why, as far as the aggregator can tell. */
84
+ readonly cause: HealthTransitionCause;
85
+ };
86
+ /**
87
+ * Receives one event per change of readiness state, per check.
88
+ *
89
+ * Bind an implementation under `BYMAX_HEALTH_TRANSITION_SINK` from a module of
90
+ * your own marked `@Global()`. Nothing is bound by default, and with no sink the
91
+ * aggregator writes its own transition lines to Nest's logger, so a readiness
92
+ * failure is never silent.
93
+ *
94
+ * Binding a sink stands that line down. Both destinations are usually the same
95
+ * logger in a consuming application, so keeping it would put two records of one
96
+ * transition side by side — the noise this feature exists to remove. The sink is
97
+ * handed the cause as structured data, strictly more than the line renders, so
98
+ * what reaches the log after that is the consumer's decision.
99
+ *
100
+ * Called synchronously from the readiness path, so an implementation must be
101
+ * cheap and must not block: hand the event to a logger and return. A throw is
102
+ * caught and reported by the aggregator rather than failing the probe — a
103
+ * readiness endpoint that answers `500` because its *logging* broke would take a
104
+ * healthy deployment out of rotation — but do not rely on that as flow control.
105
+ *
106
+ * Takes a single object, matching `ITimingSink.record`, so a later field is an
107
+ * additive change rather than a new positional parameter.
108
+ */
109
+ interface IHealthTransitionSink {
110
+ /**
111
+ * Record one change of readiness state.
112
+ *
113
+ * @param transition - The check that changed, its new state, and the cause
114
+ * when it went down.
115
+ */
116
+ record(transition: HealthTransition): void;
117
+ }
118
+
119
+ export type { HealthTransition as H, IHealthTransitionSink as I, HealthTransitionCause as a };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @fileoverview The readiness transition contract: a sink that receives one
3
+ * event each time a check changes state, and the cause that says why.
4
+ *
5
+ * This exists because a readiness failure can otherwise leave no record an
6
+ * operator will ever read. There are three ways for a check to be down, and the
7
+ * aggregator is the only place that can tell them apart: the indicator rejected,
8
+ * the indicator reported `down` on purpose, or the aggregator gave up waiting.
9
+ * The third is knowledge that exists nowhere else — an indicator the aggregator
10
+ * abandoned is never told, so it cannot report anything — and it is the shape a
11
+ * hung dependency takes. A database under load, a network partition and a paused
12
+ * container all hang; a refusal comes back immediately.
13
+ *
14
+ * Two decisions elsewhere combine to make that silence total in a typical
15
+ * deployment, and each is right on its own. Probe paths are excluded from the
16
+ * HTTP log surface, because probes are the highest-volume request a backend
17
+ * serves. And a well-written indicator returns `{ status: 'down' }` rather than
18
+ * throwing, because readiness is usually unauthenticated and a driver's error
19
+ * carries hosts, ports and sometimes credentials — which is exactly the row this
20
+ * package's own contract steers implementations toward.
21
+ *
22
+ * **Transitions, not outcomes.** The sink is called once per *change* of state
23
+ * per check name, never once per probe. That rule lives in the aggregator rather
24
+ * than in the sink deliberately: a readiness check runs every few seconds, so a
25
+ * line per failure turns one outage into thousands of identical records that
26
+ * bury the one carrying the cause. Leaving the de-duplication to each consumer
27
+ * would mean every backend re-deriving the same rule, and re-deriving it subtly
28
+ * differently. A sink that never sees raw outcomes cannot get it wrong.
29
+ * @layer Contract
30
+ */
31
+ /**
32
+ * Why a check is down, as only the aggregator can distinguish it.
33
+ *
34
+ * A discriminated union rather than a string, so a consumer switches
35
+ * exhaustively and the compiler reports the arm it has not handled when a future
36
+ * version adds one. "The dependency answered that it is down" and "the
37
+ * dependency accepted the work and never answered" are different outages calling
38
+ * for different responses, and the readiness body cannot tell them apart because
39
+ * it carries only `up` or `down`.
40
+ */
41
+ type HealthTransitionCause = {
42
+ /** The indicator rejected, or threw synchronously. */
43
+ readonly kind: 'rejected';
44
+ /**
45
+ * The rejection's top-level message, truncated to 300 characters. Never
46
+ * the raw error, its stack, or any nested cause.
47
+ *
48
+ * This reaches the sink whether or not `health.exposeIndicatorErrors` is
49
+ * set: that option governs what is written into the HTTP response, which
50
+ * is typically unauthenticated, while a sink is application-side code at
51
+ * the same trust level as the logger. An indicator usually does not author
52
+ * this text — it lets a driver's error propagate — so treat it as
53
+ * potentially carrying hosts and ports, and send it where access is
54
+ * already controlled.
55
+ */
56
+ readonly message: string;
57
+ } | {
58
+ /** The indicator answered, and reported its dependency unhealthy. */
59
+ readonly kind: 'reported-down';
60
+ } | {
61
+ /** The aggregator gave up waiting for the indicator. */
62
+ readonly kind: 'timed-out';
63
+ /** The bound that elapsed, from `health.indicatorTimeoutMs`. */
64
+ readonly timeoutMs: number;
65
+ };
66
+ /**
67
+ * One change of state for one check.
68
+ *
69
+ * A discriminated union on `isUp` rather than an object with an optional cause,
70
+ * so the compiler guarantees that a transition to `down` carries one and that a
71
+ * transition to `up` does not — a recovery has no cause to describe.
72
+ */
73
+ type HealthTransition = {
74
+ /** The check's name, as {@link IHealthIndicator.name} declares it. */
75
+ readonly name: string;
76
+ /** The dependency is reachable again. */
77
+ readonly isUp: true;
78
+ } | {
79
+ /** The check's name, as {@link IHealthIndicator.name} declares it. */
80
+ readonly name: string;
81
+ /** The dependency is not reachable. */
82
+ readonly isUp: false;
83
+ /** Why, as far as the aggregator can tell. */
84
+ readonly cause: HealthTransitionCause;
85
+ };
86
+ /**
87
+ * Receives one event per change of readiness state, per check.
88
+ *
89
+ * Bind an implementation under `BYMAX_HEALTH_TRANSITION_SINK` from a module of
90
+ * your own marked `@Global()`. Nothing is bound by default, and with no sink the
91
+ * aggregator writes its own transition lines to Nest's logger, so a readiness
92
+ * failure is never silent.
93
+ *
94
+ * Binding a sink stands that line down. Both destinations are usually the same
95
+ * logger in a consuming application, so keeping it would put two records of one
96
+ * transition side by side — the noise this feature exists to remove. The sink is
97
+ * handed the cause as structured data, strictly more than the line renders, so
98
+ * what reaches the log after that is the consumer's decision.
99
+ *
100
+ * Called synchronously from the readiness path, so an implementation must be
101
+ * cheap and must not block: hand the event to a logger and return. A throw is
102
+ * caught and reported by the aggregator rather than failing the probe — a
103
+ * readiness endpoint that answers `500` because its *logging* broke would take a
104
+ * healthy deployment out of rotation — but do not rely on that as flow control.
105
+ *
106
+ * Takes a single object, matching `ITimingSink.record`, so a later field is an
107
+ * additive change rather than a new positional parameter.
108
+ */
109
+ interface IHealthTransitionSink {
110
+ /**
111
+ * Record one change of readiness state.
112
+ *
113
+ * @param transition - The check that changed, its new state, and the cause
114
+ * when it went down.
115
+ */
116
+ record(transition: HealthTransition): void;
117
+ }
118
+
119
+ export type { HealthTransition as H, IHealthTransitionSink as I, HealthTransitionCause as a };