@bymax-one/nest-core 1.5.3 → 1.6.1

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,216 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.6.1] - 2026-09-05
15
+
16
+ A library contributing an OpenAPI component under a name the application already
17
+ declared had its entry discarded, and neither side could tell. The document
18
+ validated, the served output looked exactly as it would have had nothing been
19
+ offered, and the contributing library received no signal at all — so the failure
20
+ was indistinguishable from success at both ends.
21
+
22
+ Two changes, and they are independent: an empty placeholder no longer counts as a
23
+ declaration, and every genuine collision is now reported.
24
+
25
+ **This patch adds public surface**, which a patch number cannot tell you:
26
+ `discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type it holds.
27
+ Both are additive and nothing is removed or renamed, so the digit stays at a
28
+ patch under this project's versioning policy — which means this paragraph, rather
29
+ than the version, is what says there is something new to look for.
30
+
31
+ **Apply to a derived backend:** nothing is required, and no served document loses
32
+ anything it carried. A schema the application declares with a shape of its own
33
+ still wins, exactly as before. What changes is that a schema present only as
34
+ `{"type":"object","properties":{}}` — which `@nestjs/swagger` emits for every DTO
35
+ in a package built without its plugin — is now filled from the contribution
36
+ rather than preferred over it. Read `discarded` on the `OpenApiMountOutcome` to
37
+ see what a contributor offered and did not place; the boot log reports the same
38
+ entries.
39
+
40
+ ### Added
41
+
42
+ - **`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type.**
43
+ Lists every contributed component entry the document already declared with a
44
+ shape of its own, naming the contributor, the member and the entry. Present
45
+ whenever the document mounted, empty when every contribution was placed — an
46
+ empty array reports that the merge looked and found none, which absence would
47
+ confuse with not having looked.
48
+
49
+ Carries the contributor's label because a collision is only actionable if you
50
+ know whose entry lost: an application with several contributing libraries would
51
+ otherwise be told a name was dropped without being told by whom.
52
+
53
+ A warning reports the same entries on the way up, elided into a count on the
54
+ same reasoning as the unsecured-operation line beside it — a library contributing a
55
+ schema per DTO turns one collision into a hundred, and the version that prints
56
+ them all is the one an operator learns to skip.
57
+
58
+ ### Changed
59
+
60
+ - **A schema that describes no shape no longer counts as a declaration.** The
61
+ merge still prefers what the document carries: a consumer authored theirs on
62
+ purpose, and a library silently overwriting it is the worse defect wearing the
63
+ other sign. But `@nestjs/swagger` emits `{"type":"object","properties":{}}` for
64
+ every DTO in a package built without its plugin, so the name is never actually
65
+ missing and a contributed schema under it would never be written. The more
66
+ correct the library's own schema, the more completely it was discarded, because
67
+ a correct schema and an empty one collide on the same key.
68
+
69
+ This is the rule `mergeResponses` already applied to responses, arriving
70
+ through a different placeholder — a `200` carrying a description and no
71
+ `content` occupies its status the same way. Filling one in is additive: it
72
+ constrains nothing before and describes a shape after.
73
+
74
+ The test is deliberately narrow. `type` must be absent or `object`, because
75
+ `{"type":"string"}` carries no properties and is nonetheless complete;
76
+ reading "no properties" as "no shape" would discard a real schema, which is
77
+ the failure this path exists to prevent. An empty `required` and a
78
+ `description` do not change that; any other keyword, or a single property,
79
+ makes the entry a declaration. A description the document
80
+ wrote survives being filled in, the same carve-out `mergeResponses` makes.
81
+
82
+ The carve-out applies to `schemas` alone. A parameter carries `name` and `in`
83
+ rather than `properties`, so a schema-shaped emptiness test reads every one of
84
+ them as a placeholder and would let a contributor overwrite one the consumer
85
+ wrote — the precedence failure inverted, arriving through the fix for the
86
+ other one.
87
+
88
+ This package's own catalogue, under `openapi.includeCoreSchemas`, is placed by
89
+ the same rule and before any library. A placeholder under a catalogue name is
90
+ filled with the package's definition, so a library offering that name meets a
91
+ declaration and is reported rather than outranking the package.
92
+
93
+ - **`fastify` and `@nestjs/platform-fastify` devDependency floors raised to
94
+ `^5.12.5` and `^11.2.4`.** Shipped in the tarball's manifest only; `dist/` is
95
+ unchanged by them.
96
+
97
+ ## [1.6.0] - 2026-08-29
98
+
99
+ A readiness check that fails without rejecting left no record an operator would
100
+ ever read, and one that fails by rejecting left one per probe. Both are the same
101
+ missing seam, and this release adds it: the aggregator now holds the last state
102
+ of every check and reports each **change** — to its own logger, and to an
103
+ optional sink a consumer binds.
104
+
105
+ **Apply to a derived backend:** nothing is required. Transitions now reach Nest's
106
+ logger on every path, where before only a rejection did. Bind
107
+ `BYMAX_HEALTH_TRANSITION_SINK` to route them into a structured logging surface,
108
+ and read `cause.kind` to tell the three failure shapes apart. If you have written
109
+ a health reporter of your own, delete it — including any wrapper that races a
110
+ timer beneath `health.indicatorTimeoutMs` to recover a verdict, which this seam
111
+ makes unnecessary.
112
+
113
+ ### Added
114
+
115
+ - **`IHealthTransitionSink`, bound under `BYMAX_HEALTH_TRANSITION_SINK`.**
116
+ Receives one `HealthTransition` per change of readiness state, per check name —
117
+ never one per probe. `@Optional()` and unbound by default, like
118
+ `BYMAX_HEALTH_INDICATORS`, so a consumer's binding is never shadowed by a local
119
+ one.
120
+
121
+ With no sink the aggregator writes the transition to Nest's logger, so a
122
+ readiness failure is never silent by default; binding one stands that line down
123
+ in favour of the sink. Both destinations are usually the same logger in a
124
+ consuming application, and two records of one transition side by side is the
125
+ noise this feature exists to remove. The sink is handed the cause as structured
126
+ data, strictly more than the line renders, so what reaches the log after that
127
+ is the consumer's decision rather than this package's.
128
+
129
+ The de-duplication rule lives in the aggregator rather than in the sink, and
130
+ that is the whole point. A readiness check runs every few seconds, so a line
131
+ per failing probe turns one outage into thousands of identical records that
132
+ bury the one carrying the cause; leaving that rule to each consumer means every
133
+ backend re-deriving it, slightly differently. A sink that never sees raw
134
+ outcomes cannot get it wrong.
135
+
136
+ Overlapping probes are ordered by when they started, not by when they
137
+ finished. Readiness is not called one at a time — an orchestrator's probe and a
138
+ load balancer's health check reach it concurrently — and a dependency that
139
+ hangs until the bound elapses is exactly what makes an earlier probe finish
140
+ last. Comparing states alone would write such an outage backwards: the later
141
+ probe reports the recovery, and the earlier one's timeout lands behind it and
142
+ reports the dependency down again on evidence that is already stale.
143
+
144
+ The rule is asymmetric on a first observation, deliberately. A first
145
+ observation that is **failing** is reported — a process that boots against a
146
+ dependency already down would otherwise look healthy in the log forever — while
147
+ a first observation that is healthy is not, since announcing the expected state
148
+ would write one line per dependency on every boot.
149
+
150
+ - **`HealthTransitionCause`, distinguishing the three ways a check is down.**
151
+ `reported-down` (the indicator answered and said so), `rejected` (carrying the
152
+ summarized message, bounded to 300 characters), and `timed-out` (carrying the
153
+ `timeoutMs` that elapsed). A discriminated union rather than a string, so a
154
+ consumer switches exhaustively and the compiler names the arm it has not
155
+ handled if a future version adds one.
156
+
157
+ `timed-out` is the one that exists nowhere else. An indicator this package
158
+ gave up on is never told, so it reports nothing, and a consumer racing its own
159
+ timer beneath `indicatorTimeoutMs` still never learns the bound that actually
160
+ applied. A hung dependency is also the common shape rather than a refused one:
161
+ a database under load, a network partition and a paused container all hang,
162
+ while a refusal returns immediately and would have been reported. The obstacle
163
+ for a consumer is information, not effort — which is the argument for this
164
+ living in the library at all.
165
+
166
+ ### Fixed
167
+
168
+ - **An `Error` whose `message` is not a string broke the readiness aggregation.**
169
+ `message` is a writable property, so `Object.assign(new Error(), { message:
170
+ null })` is an `Error` that reads without throwing and then throws on every
171
+ string operation. The summarizer coerced a non-`Error` reason but trusted an
172
+ `Error`'s own `message`, then measured and truncated it outside that guard.
173
+
174
+ It surfaced where the guard was supposed to hold. Summarizing runs inside the
175
+ rejection-to-`down` conversion, so an indicator rejecting with such an error
176
+ rejected the whole aggregation: the probe answered `500` instead of `503`, and
177
+ reported nothing about the dependencies that were healthy — the exact outcome
178
+ the conversion exists to prevent.
179
+
180
+ **This is present in 1.5.3 and earlier**, on the indicator path. The transition
181
+ sink added in this release reaches the same summarizer, so the fix covers both.
182
+
183
+ - **An `async` timing sink could take the process down.** `ITimingSink.record`
184
+ is declared to return `void`, and TypeScript accepts any return value in a
185
+ void-returning position, so `async record()` compiles — and it is the natural
186
+ shape when the backend behind the sink is async. Its rejection settled a
187
+ microtask after the recorder's `try`/`catch` had exited, so instead of the
188
+ contained failure the contract promises it became an unhandled rejection, able
189
+ to kill the process under `--unhandled-rejections=strict`. An observer that can
190
+ break what it observes is the one thing a fire-and-forget contract exists to
191
+ rule out.
192
+
193
+ Both the middleware and the deprecated interceptor were affected, and both now
194
+ route delivery through one implementation rather than two copies of the same
195
+ `try`/`catch` — the containment guarantee is worth exactly as much as its least
196
+ careful copy. Nothing to change in a consumer: a sink that already returned
197
+ synchronously behaves identically, and an `async` one is now caught.
198
+
199
+ Found while reviewing the health transition sink, which had the same hole
200
+ before release.
201
+
202
+ ### Changed
203
+
204
+ - **A rejecting indicator is logged once per outage, not once per probe.** The
205
+ aggregator already wrote a warning for a rejection, on every readiness check
206
+ for as long as the dependency stayed down: roughly sixty identical lines for a
207
+ ten-minute outage probed every ten seconds. That line now follows the
208
+ transition rule like every other path. No API moves; log volume does, and a
209
+ recovery now writes a line where nothing did before.
210
+
211
+ This arrives with the upgrade, not with the binding — a deployment that wires
212
+ no sink still stops repeating.
213
+
214
+ The cost is stated rather than hidden: a dependency that stays down while its
215
+ failure mode changes underneath keeps the cause observed **at the transition**.
216
+ That is the trade for one line per outage instead of one per probe.
217
+
218
+ - **The root bundle's size budget moved from 15 to 17 KiB brotli**, measured
219
+ 14.25 → 15.82. Checked before the number moved: the transition contract is
220
+ types-only and erases at build time, no module entered the root that was not
221
+ already there, and the rationale prose was moved into the erased file — this
222
+ bundle ships its comments — before the budget was touched.
223
+
14
224
  ## [1.5.3] - 2026-08-18
15
225
 
16
226
  Three findings from a functional and security audit of a derived backend
@@ -875,4 +1085,6 @@ have regressed from. They are kept because the reasoning is worth having.
875
1085
  [1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
876
1086
  [1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
877
1087
  [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
1088
+ [1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
1089
+ [1.6.1]: https://github.com/bymaxone/nest-core/compare/v1.6.0...v1.6.1
1090
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.1...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
@@ -1079,10 +1163,35 @@ can emit it once and never branch:
1079
1163
 
1080
1164
  | Result | Meaning |
1081
1165
  | ------------------------------------------ | ------------------------------------------------------- |
1082
- | `{ mounted: true, path }` | The UI and the document are served at `path`. |
1166
+ | `{ mounted: true, path, discarded }` | The UI and the document are served at `path`. |
1083
1167
  | `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
1084
1168
  | `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
1085
1169
 
1170
+ `discarded` lists every component entry a library contributed that the served
1171
+ document already defined with a shape of its own — a `DiscardedComponent` per
1172
+ entry, naming the contributor, the member and the name. It is empty when every
1173
+ contribution was placed, and the boot log reports the same entries.
1174
+
1175
+ A collision is not an error and does not stop the boot: the merge prefers
1176
+ whatever holds the name already. That is usually your own declaration, and you
1177
+ authored it on purpose — but it may also be a core schema this package
1178
+ contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
1179
+ entry, since contributors merge in class-name order. The report names what was
1180
+ dropped and by whom, not who owns the definition that won, so read the document
1181
+ before deciding what to remove.
1182
+
1183
+ It is reported because it is otherwise unobservable from either side: the
1184
+ contributing library cannot tell its entry lost, the application cannot tell one
1185
+ was offered, and the served document looks the same either way.
1186
+
1187
+ A schema present only as `{"type":"object","properties":{}}` is **not** a
1188
+ declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
1189
+ in a package built without its plugin, so such a name is filled from the
1190
+ contribution rather than preferred over it, and does not appear in `discarded`.
1191
+ An empty `required: []` and a `description` do not change that; anything else
1192
+ does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
1193
+ "any value" — and are kept.
1194
+
1086
1195
  ### Production is a closed door
1087
1196
 
1088
1197
  `NODE_ENV` decides, and the decision is fail-closed: only `development` and
@@ -1636,7 +1745,8 @@ in the sections above.
1636
1745
  | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1637
1746
  | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1638
1747
  | `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). |
1748
+ | `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). |
1749
+ | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1640
1750
  | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1641
1751
  | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1642
1752
  | `BymaxExceptionFilter` | class | The envelope exception filter. |
@@ -1667,6 +1777,9 @@ in the sections above.
1667
1777
  | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
1668
1778
  | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
1669
1779
  | `HealthResponse` | type | The stable liveness and readiness response shape. |
1780
+ | `IHealthTransitionSink` | type | Receives one event per change of readiness state. |
1781
+ | `HealthTransition` | type | One change of state, for one check. |
1782
+ | `HealthTransitionCause` | type | Why a check went down, as the aggregator sees it. |
1670
1783
  | `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
1671
1784
  | `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
1672
1785
 
@@ -1685,6 +1798,7 @@ in the sections above.
1685
1798
  | -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
1686
1799
  | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1687
1800
  | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1801
+ | `DiscardedComponent` | type | A contributed component entry the document already declared. |
1688
1802
  | `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
1689
1803
  | `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
1690
1804
  | `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
@@ -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 };