@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 +213 -1
- package/README.md +127 -13
- package/dist/health/index.d.cts +1 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health.transition-B_IgXJTz.d.cts +119 -0
- package/dist/health.transition-B_IgXJTz.d.ts +119 -0
- package/dist/index.cjs +212 -24
- package/dist/index.d.cts +23 -8
- package/dist/index.d.ts +23 -8
- package/dist/index.mjs +212 -25
- package/dist/openapi/index.cjs +108 -34
- package/dist/openapi/index.d.cts +97 -53
- package/dist/openapi/index.d.ts +97 -53
- package/dist/openapi/index.mjs +108 -34
- package/package.json +3 -3
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
|
-
[
|
|
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
|
|
519
|
-
`BYMAX_HEALTH_INDICATORS`
|
|
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
|
|
532
|
-
|
|
|
533
|
-
| `BYMAX_CORE_OPTIONS`
|
|
534
|
-
| `BYMAX_CORRELATION_PROVIDER`
|
|
535
|
-
| `BYMAX_TIMING_SINK`
|
|
536
|
-
| `BYMAX_HEALTH_INDICATORS`
|
|
537
|
-
| `
|
|
538
|
-
| `
|
|
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 }`
|
|
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`
|
|
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. |
|
package/dist/health/index.d.cts
CHANGED
|
@@ -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
|
package/dist/health/index.d.ts
CHANGED
|
@@ -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 };
|