@onlineapps/service-wrapper 4.0.0 → 4.1.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 +101 -0
- package/package.json +14 -14
- package/src/ErrorMapper.js +180 -21
- package/src/ServiceWrapper.js +195 -49
- package/src/index.js +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format and semantic versioning within the internal-monorepo convention (all biz-service consumers ship matching changes in the same session).
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
**Patch.** No exported API moves: `BUSINESS_ERROR_BRAND`, `isBusinessError`, the
|
|
8
|
+
error classes, `ServiceWrapper` and `bootstrap` are untouched. What changes is
|
|
9
|
+
startup behaviour and one field inside `conn-runtime/validation-failure.json`,
|
|
10
|
+
a runtime file this package writes and reads alone — not a contract any consumer
|
|
11
|
+
compiles against.
|
|
12
|
+
|
|
13
|
+
### Fixed — the restart cooldown is keyed on the CAUSE, not on time alone
|
|
14
|
+
|
|
15
|
+
Measured on `api_service_emailer`, 2026-08-29: the cookbooks were repaired and
|
|
16
|
+
the container restarted, but the failure file still said `attemptCount: 240`, so
|
|
17
|
+
`_ensureValidationProof` slept `min(REVALIDATION_SLOW_MS - since, 300000)` — five
|
|
18
|
+
minutes — before FÁZE 0.2. The boot looked wedged at 0.15, because the only
|
|
19
|
+
announcement went to `this.logger?.info` and stdout said nothing.
|
|
20
|
+
|
|
21
|
+
- `_writeValidationFailureFile` records `causeFingerprint`: the platform hash
|
|
22
|
+
(`FingerprintUtils.generateContentFingerprint`, never a new one) over every
|
|
23
|
+
`.json` under `config/service/` and `tests/cookbooks/` — what Tier-1
|
|
24
|
+
validation actually judges.
|
|
25
|
+
- `_applyRestartCooldown` compares it at startup. Cause changed → the failure
|
|
26
|
+
file is deleted, no wait, and the attempt count starts from zero. Same cause →
|
|
27
|
+
the previous behaviour, unchanged.
|
|
28
|
+
- **A file written before this version has no fingerprint, which is not the
|
|
29
|
+
current one, so it takes the "cause changed" branch.** There is no
|
|
30
|
+
compatibility branch and no migration: a missing fingerprint is a changed
|
|
31
|
+
cause (`architecture-principles.md` §11).
|
|
32
|
+
- Both branches now print through `_logStartup`, which writes to the structured
|
|
33
|
+
log AND to stdout — the same two channels `_logPhase` uses for every
|
|
34
|
+
`[FÁZE …]` line. A five-minute wait that announces itself on a channel the
|
|
35
|
+
container does not show is not an announcement (`automation-gates.md` §5).
|
|
36
|
+
- The failure file is re-read AFTER the cooldown. Reading it before carried the
|
|
37
|
+
old count into the write that follows, so a reset lifted the cooldown for
|
|
38
|
+
exactly one boot and then reinstated it.
|
|
39
|
+
|
|
40
|
+
### Changed — one orchestrator factory, two callers
|
|
41
|
+
|
|
42
|
+
`_executeRevalidation` built a `ValidationOrchestrator` per attempt;
|
|
43
|
+
`_ensureValidationProof` built one and reused it across all six startup
|
|
44
|
+
attempts. Both arrived in the same commit (`869a346a`, 2026-03-26) — the
|
|
45
|
+
difference was a local convenience, not a design.
|
|
46
|
+
|
|
47
|
+
The reuse was not free: dávka 49 had to add `resetResults()` to
|
|
48
|
+
`CookbookTestRunner` because the shared runner accumulated failed cookbooks
|
|
49
|
+
across those six attempts, so the verdict could never come back true inside one
|
|
50
|
+
process. Nothing needs state between attempts, so `_createValidationOrchestrator()`
|
|
51
|
+
is now the single construction site and every attempt gets a fresh instance. The
|
|
52
|
+
injected orchestrator (`options._validationOrchestrator`) is still returned as-is
|
|
53
|
+
— it is the test seam.
|
|
54
|
+
|
|
55
|
+
### Added — `@onlineapps/service-validator-core` 1.0.14 as a declared dependency
|
|
56
|
+
|
|
57
|
+
`FingerprintUtils` is required directly now. It was already installed
|
|
58
|
+
transitively through `@onlineapps/conn-orch-validator`; requiring it without
|
|
59
|
+
declaring it is the §20 defect the new G6 gate rejects.
|
|
60
|
+
|
|
61
|
+
### Fixed — the heartbeat no longer invents an identity
|
|
62
|
+
|
|
63
|
+
`startHealthHeartbeat()` defaulted the two facts that identify the service:
|
|
64
|
+
`service.name || 'unnamed-service'` and `service.version || '1.0.0'`. Both are
|
|
65
|
+
fallbacks (`architecture-principles.md` §3), and of the costly kind: a heartbeat
|
|
66
|
+
published as `unnamed-service@1.0.0` is not a missing heartbeat but a WRONG one —
|
|
67
|
+
the monitoring stack records a service that does not exist while the real one
|
|
68
|
+
looks silent.
|
|
69
|
+
|
|
70
|
+
Not dead branches either. `_ensureValidationProof` fail-fasts on both keys, but
|
|
71
|
+
it runs only when `serviceRoot` is set and `wrapper.validation.enabled !== false`
|
|
72
|
+
(`ServiceWrapper.js:831`), so a service with validation off arrived here with
|
|
73
|
+
neither key ever checked. Both now fail fast with
|
|
74
|
+
`[ServiceWrapper] Missing configuration - service.<key> is required`, in the
|
|
75
|
+
shape `_registerService`already uses one method away.
|
|
76
|
+
|
|
77
|
+
### Fixed — the guard test failed by hanging instead of failing
|
|
78
|
+
|
|
79
|
+
The `_executeRevalidation` guard added above did catch a deleted local, but its
|
|
80
|
+
`catch` scheduled the next attempt through a REAL `setTimeout` (30 s … 30 min).
|
|
81
|
+
Jest then waited on the open handle, so the run froze instead of reporting the
|
|
82
|
+
failure — measured by the lead: five minutes, no output. The test now records
|
|
83
|
+
the scheduling instead of arming it, and asserts the success path never reaches
|
|
84
|
+
the scheduler at all. Under the mutation it fails in 2 ms.
|
|
85
|
+
|
|
86
|
+
### Tests
|
|
87
|
+
|
|
88
|
+
`tests/unit/ServiceWrapper.validationFailureFile.test.js` — 18 cases: the
|
|
89
|
+
fingerprint moves for a cookbook, for `operations.json` and for an added
|
|
90
|
+
cookbook and NOT for an unrelated file; the three cooldown branches; the
|
|
91
|
+
pre-fingerprint file; the reset that really starts from zero; no failure file;
|
|
92
|
+
fewer failures than the fast backoff; an elapsed cooldown; the factory returning
|
|
93
|
+
a new instance per call and the injected one unchanged; a single construction
|
|
94
|
+
site in the source. Plus a guard for the class of defect 3.4.2 hot-fixed — a
|
|
95
|
+
dangling local left by a cleanup pass — by driving `_executeRevalidation` far
|
|
96
|
+
enough to register the service.
|
|
97
|
+
|
|
98
|
+
`tests/unit/ServiceWrapper.heartbeatConfig.test.js` — 7 cases: each missing key
|
|
99
|
+
by name, an absent `service` section, the publisher never constructed under a
|
|
100
|
+
made-up identity, a complete identity starting it under its real name, the older
|
|
101
|
+
`mqClient` guard still firing first, and the retired default absent from the
|
|
102
|
+
source.
|
|
103
|
+
|
|
104
|
+
Suite: 16 suites, 214 passed (was 189).
|
|
105
|
+
|
|
5
106
|
## [3.4.3] — 2026-08-17
|
|
6
107
|
|
|
7
108
|
**Ajv config: `allowUnionTypes: true`.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-wrapper",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.1",
|
|
4
4
|
"description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -24,20 +24,20 @@
|
|
|
24
24
|
"author": "OA Drive Team",
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@onlineapps/conn-base-cache": "1.0.
|
|
28
|
-
"@onlineapps/conn-base-monitoring": "1.0.
|
|
29
|
-
"@onlineapps/conn-base-state": "1.0.
|
|
30
|
-
"@onlineapps/conn-infra-error-handler": "1.0.
|
|
31
|
-
"@onlineapps/conn-infra-mq": "
|
|
32
|
-
"@onlineapps/conn-infra-secrets": "1.
|
|
33
|
-
"@onlineapps/conn-orch-cookbook": "2.1.
|
|
34
|
-
"@onlineapps/conn-orch-orchestrator": "2.1.
|
|
35
|
-
"@onlineapps/conn-orch-registry": "
|
|
36
|
-
"@onlineapps/conn-orch-validator": "
|
|
37
|
-
"@onlineapps/infrastructure-tools": "1.2.
|
|
38
|
-
"@onlineapps/
|
|
39
|
-
"@onlineapps/runtime-config": "1.0.2",
|
|
27
|
+
"@onlineapps/conn-base-cache": "1.0.10",
|
|
28
|
+
"@onlineapps/conn-base-monitoring": "1.0.16",
|
|
29
|
+
"@onlineapps/conn-base-state": "1.0.2",
|
|
30
|
+
"@onlineapps/conn-infra-error-handler": "1.0.15",
|
|
31
|
+
"@onlineapps/conn-infra-mq": "2.0.0",
|
|
32
|
+
"@onlineapps/conn-infra-secrets": "1.1.0",
|
|
33
|
+
"@onlineapps/conn-orch-cookbook": "2.1.5",
|
|
34
|
+
"@onlineapps/conn-orch-orchestrator": "2.1.8",
|
|
35
|
+
"@onlineapps/conn-orch-registry": "2.0.0",
|
|
36
|
+
"@onlineapps/conn-orch-validator": "4.0.1",
|
|
37
|
+
"@onlineapps/infrastructure-tools": "1.2.7",
|
|
38
|
+
"@onlineapps/runtime-config": "1.0.3",
|
|
40
39
|
"@onlineapps/service-common": "2.0.0",
|
|
40
|
+
"@onlineapps/service-validator-core": "1.0.15",
|
|
41
41
|
"ajv": "8.17.1",
|
|
42
42
|
"ajv-formats": "3.0.1"
|
|
43
43
|
},
|
package/src/ErrorMapper.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* modules, no third-party deps. Logger is injected via constructor (DI).
|
|
10
10
|
*
|
|
11
11
|
* Exports:
|
|
12
|
+
* - BUSINESS_ERROR_BRAND — Symbol.for('onlineapps.businessError'), the mark.
|
|
13
|
+
* - isBusinessError(err) — brand + shape predicate (the contract itself).
|
|
12
14
|
* - ErrorMapper — maps exceptions to { status, error: {...} } envelopes.
|
|
13
15
|
* - BusinessError — base class for handler-authored errors (code + status required).
|
|
14
16
|
* - ValidationError — 400 VALIDATION_FAILED. Thrown by SchemaValidator
|
|
@@ -22,6 +24,32 @@
|
|
|
22
24
|
* - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
|
|
23
25
|
* - AbortError — thrown when ctx.abortSignal aborts the invocation.
|
|
24
26
|
*
|
|
27
|
+
* THE CONTRACT IS A BRAND PLUS A SHAPE, NOT A CLASS IDENTITY (DÁVKA 50).
|
|
28
|
+
* Owner decision: api/docs/governance/confirmations/business-error-contract.md
|
|
29
|
+
* (20260829-1500-business-error-contract-001).
|
|
30
|
+
*
|
|
31
|
+
* An error is a business error when it carries ALL of:
|
|
32
|
+
* - the brand `Symbol.for('onlineapps.businessError')` with value `true`
|
|
33
|
+
* (a GLOBAL registry symbol, so it survives two copies of this package in
|
|
34
|
+
* node_modules and can be claimed by a service's own class);
|
|
35
|
+
* - `code` — string matching /^[A-Z][A-Z0-9_]*$/;
|
|
36
|
+
* - `status` — number in [100, 599];
|
|
37
|
+
* - `message` — non-empty string.
|
|
38
|
+
*
|
|
39
|
+
* `map()` therefore dispatches on `code`, never on `instanceof`. `instanceof`
|
|
40
|
+
* remains true for our own classes but is no longer what decides anything.
|
|
41
|
+
*
|
|
42
|
+
* The two outcomes for an error that is not a business error are deliberately
|
|
43
|
+
* different, and that difference is the point:
|
|
44
|
+
* - BRANDED but malformed → OUR defect. 500 `MALFORMED_BUSINESS_ERROR` and an
|
|
45
|
+
* `error` log naming the offending field. Loud, never anonymous.
|
|
46
|
+
* - UNBRANDED → a genuinely unknown error. 500 `INTERNAL_ERROR`,
|
|
47
|
+
* message and details masked. Unchanged.
|
|
48
|
+
*
|
|
49
|
+
* A service may throw its own class as long as it declares the brand and the
|
|
50
|
+
* shape. Extending `BusinessError` is the easy way to get both; it is not the
|
|
51
|
+
* only permitted one.
|
|
52
|
+
*
|
|
25
53
|
* Two constructor shapes, because the parameter sets genuinely differ:
|
|
26
54
|
* - `BusinessError` takes `{ code, status, message, details }` — the author
|
|
27
55
|
* must supply code + status, so they are named.
|
|
@@ -36,6 +64,88 @@
|
|
|
36
64
|
* base class fail-fasts instead.
|
|
37
65
|
*/
|
|
38
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The business-error brand. `Symbol.for` puts it in the GLOBAL symbol registry,
|
|
69
|
+
* so two independently loaded copies of this file resolve to the SAME symbol —
|
|
70
|
+
* which is exactly the case `instanceof` used to lose.
|
|
71
|
+
*/
|
|
72
|
+
const BUSINESS_ERROR_BRAND = Symbol.for('onlineapps.businessError');
|
|
73
|
+
|
|
74
|
+
/** `code` must be an uppercase SNAKE_CASE identifier. */
|
|
75
|
+
const BUSINESS_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]*$/;
|
|
76
|
+
|
|
77
|
+
const BUSINESS_ERROR_STATUS_MIN = 100;
|
|
78
|
+
const BUSINESS_ERROR_STATUS_MAX = 599;
|
|
79
|
+
|
|
80
|
+
/** Render a value for an operator-facing defect line: `"404" (string)`. */
|
|
81
|
+
function describeValue(value) {
|
|
82
|
+
const rendered = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
83
|
+
? JSON.stringify(value)
|
|
84
|
+
: String(value);
|
|
85
|
+
return `${rendered} (${typeof value})`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Does the error claim to be one of ours? Claiming is not being — see
|
|
90
|
+
* {@link describeBusinessErrorDefects}.
|
|
91
|
+
*
|
|
92
|
+
* @param {*} err
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function hasBusinessErrorBrand(err) {
|
|
96
|
+
return !!err && (typeof err === 'object' || typeof err === 'function') && err[BUSINESS_ERROR_BRAND] === true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every way a branded error breaks the shape, named field by field. Empty array
|
|
101
|
+
* = the contract holds.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} err
|
|
104
|
+
* @returns {string[]}
|
|
105
|
+
*/
|
|
106
|
+
function describeBusinessErrorDefects(err) {
|
|
107
|
+
const defects = [];
|
|
108
|
+
|
|
109
|
+
if (typeof err.code !== 'string' || !BUSINESS_ERROR_CODE_PATTERN.test(err.code)) {
|
|
110
|
+
defects.push(`code is ${describeValue(err.code)}, expected a string matching ${BUSINESS_ERROR_CODE_PATTERN.source}`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof err.status !== 'number' || !Number.isFinite(err.status) ||
|
|
113
|
+
err.status < BUSINESS_ERROR_STATUS_MIN || err.status > BUSINESS_ERROR_STATUS_MAX) {
|
|
114
|
+
defects.push(`status is ${describeValue(err.status)}, expected a number between ${BUSINESS_ERROR_STATUS_MIN} and ${BUSINESS_ERROR_STATUS_MAX}`);
|
|
115
|
+
}
|
|
116
|
+
if (typeof err.message !== 'string' || err.message.length === 0) {
|
|
117
|
+
defects.push(`message is ${describeValue(err.message)}, expected a non-empty string`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return defects;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* THE contract. A service's own class passes this by declaring the brand and
|
|
125
|
+
* the three fields — it does not have to extend `BusinessError`.
|
|
126
|
+
*
|
|
127
|
+
* @param {*} err
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
function isBusinessError(err) {
|
|
131
|
+
return hasBusinessErrorBrand(err) && describeBusinessErrorDefects(err).length === 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Stamp the brand on a class prototype: non-enumerable and non-writable, so it
|
|
136
|
+
* never reaches a JSON payload and cannot be unset by accident.
|
|
137
|
+
*
|
|
138
|
+
* @param {Function} Cls
|
|
139
|
+
*/
|
|
140
|
+
function brand(Cls) {
|
|
141
|
+
Object.defineProperty(Cls.prototype, BUSINESS_ERROR_BRAND, {
|
|
142
|
+
value: true,
|
|
143
|
+
enumerable: false,
|
|
144
|
+
writable: false,
|
|
145
|
+
configurable: false
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
39
149
|
class BusinessError extends Error {
|
|
40
150
|
constructor({ code, status, message, details } = {}) {
|
|
41
151
|
if (!code) {
|
|
@@ -163,6 +273,12 @@ class AbortError extends Error {
|
|
|
163
273
|
}
|
|
164
274
|
}
|
|
165
275
|
|
|
276
|
+
// `UnknownOperationError` and `AbortError` do not extend `BusinessError` (their
|
|
277
|
+
// constructor shape differs), so each is branded on its own prototype.
|
|
278
|
+
brand(BusinessError);
|
|
279
|
+
brand(UnknownOperationError);
|
|
280
|
+
brand(AbortError);
|
|
281
|
+
|
|
166
282
|
class ErrorMapper {
|
|
167
283
|
/**
|
|
168
284
|
* @param {Object} options
|
|
@@ -179,6 +295,10 @@ class ErrorMapper {
|
|
|
179
295
|
* Map a handler exception to an MQ response envelope.
|
|
180
296
|
* Never throws. Always returns `{ status, error: { code, message, [details], [correlation_id] } }`.
|
|
181
297
|
*
|
|
298
|
+
* Recognition is by the brand + shape contract (see the file header), never by
|
|
299
|
+
* `instanceof`. Branded + malformed → 500 `MALFORMED_BUSINESS_ERROR` with the
|
|
300
|
+
* offending field named in the log; unbranded → 500 `INTERNAL_ERROR`, masked.
|
|
301
|
+
*
|
|
182
302
|
* @param {Error} err
|
|
183
303
|
* @param {Object} meta
|
|
184
304
|
* @param {string} [meta.operation]
|
|
@@ -188,8 +308,34 @@ class ErrorMapper {
|
|
|
188
308
|
map(err, meta = {}) {
|
|
189
309
|
const { operation, correlation_id } = meta;
|
|
190
310
|
|
|
191
|
-
if (err
|
|
192
|
-
|
|
311
|
+
if (hasBusinessErrorBrand(err)) {
|
|
312
|
+
const defects = describeBusinessErrorDefects(err);
|
|
313
|
+
if (defects.length > 0) {
|
|
314
|
+
// Branded but malformed = a defect in OUR code (or in a service that
|
|
315
|
+
// claimed the brand and then broke the shape). It gets a named, loud
|
|
316
|
+
// 500 instead of the anonymous one an unknown error gets, so the log
|
|
317
|
+
// says which field is wrong rather than "something threw".
|
|
318
|
+
this._logger.error(
|
|
319
|
+
`[ErrorMapper] branded error has invalid shape - ${defects.join('; ')}. ` +
|
|
320
|
+
'Fix: a branded business error must carry code (' + BUSINESS_ERROR_CODE_PATTERN.source + '), ' +
|
|
321
|
+
`status (number ${BUSINESS_ERROR_STATUS_MIN}-${BUSINESS_ERROR_STATUS_MAX}) and a non-empty message.`,
|
|
322
|
+
{
|
|
323
|
+
err,
|
|
324
|
+
operation,
|
|
325
|
+
correlation_id,
|
|
326
|
+
code: 'MALFORMED_BUSINESS_ERROR',
|
|
327
|
+
status: 500,
|
|
328
|
+
stack: err && err.stack,
|
|
329
|
+
defects
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
return this._buildError(500, 'MALFORMED_BUSINESS_ERROR', 'An internal error occurred', undefined, correlation_id);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// From here on the shape is guaranteed. Branches key on `code`, so a
|
|
336
|
+
// class from a second copy of this package — or a service's own class
|
|
337
|
+
// declaring the same code — takes exactly the same branch as ours.
|
|
338
|
+
if (err.code === 'VALIDATION_FAILED' && err.phase === 'output') {
|
|
193
339
|
this._logger.error(
|
|
194
340
|
'output schema validation failed - server bug',
|
|
195
341
|
{ err, operation, correlation_id, code: 'INTERNAL_ERROR', status: 500 }
|
|
@@ -197,23 +343,7 @@ class ErrorMapper {
|
|
|
197
343
|
return this._buildError(500, 'INTERNAL_ERROR', 'Internal validation error', undefined, correlation_id);
|
|
198
344
|
}
|
|
199
345
|
|
|
200
|
-
|
|
201
|
-
'input schema validation failed',
|
|
202
|
-
{ err, operation, correlation_id, code: 'VALIDATION_FAILED', status: 400 }
|
|
203
|
-
);
|
|
204
|
-
return this._buildError(400, 'VALIDATION_FAILED', err.message, err.details, correlation_id);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (err instanceof UnknownOperationError) {
|
|
208
|
-
this._logger.warn(
|
|
209
|
-
'unknown operation requested',
|
|
210
|
-
{ err, operation, correlation_id, code: 'UNKNOWN_OPERATION', status: 404 }
|
|
211
|
-
);
|
|
212
|
-
return this._buildError(404, 'UNKNOWN_OPERATION', err.message, undefined, correlation_id);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (err instanceof BusinessError) {
|
|
216
|
-
// A BusinessError with a 5xx status is a server fault wearing a business
|
|
346
|
+
// A business error with a 5xx status is a server fault wearing a business
|
|
217
347
|
// class. It is logged like one — `error` level WITH the stack — and the
|
|
218
348
|
// caller gets the same neutral text as any other internal error, so the
|
|
219
349
|
// original message (which may name a tenant, a column or a credential)
|
|
@@ -226,6 +356,30 @@ class ErrorMapper {
|
|
|
226
356
|
return this._buildError(err.status, err.code, 'An internal error occurred', undefined, correlation_id);
|
|
227
357
|
}
|
|
228
358
|
|
|
359
|
+
if (err.code === 'UNKNOWN_OPERATION') {
|
|
360
|
+
this._logger.warn(
|
|
361
|
+
'unknown operation requested',
|
|
362
|
+
{ err, operation, correlation_id, code: err.code, status: err.status }
|
|
363
|
+
);
|
|
364
|
+
return this._buildError(err.status, err.code, err.message, undefined, correlation_id);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (err.code === 'CLIENT_CANCELLED') {
|
|
368
|
+
this._logger.info(
|
|
369
|
+
'invocation aborted by client',
|
|
370
|
+
{ err, operation, correlation_id, code: err.code, status: err.status }
|
|
371
|
+
);
|
|
372
|
+
return this._buildError(err.status, err.code, err.message, undefined, correlation_id);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (err.code === 'VALIDATION_FAILED') {
|
|
376
|
+
this._logger.info(
|
|
377
|
+
'input schema validation failed',
|
|
378
|
+
{ err, operation, correlation_id, code: err.code, status: err.status }
|
|
379
|
+
);
|
|
380
|
+
return this._buildError(err.status, err.code, err.message, err.details, correlation_id);
|
|
381
|
+
}
|
|
382
|
+
|
|
229
383
|
this._logger.info(
|
|
230
384
|
'handler raised BusinessError',
|
|
231
385
|
{ err, operation, correlation_id, code: err.code, status: err.status }
|
|
@@ -233,8 +387,11 @@ class ErrorMapper {
|
|
|
233
387
|
return this._buildError(err.status, err.code, err.message, err.details, correlation_id);
|
|
234
388
|
}
|
|
235
389
|
|
|
236
|
-
|
|
237
|
-
|
|
390
|
+
// Deliberately NOT the brand contract: an abort is a platform signal, not a
|
|
391
|
+
// business error, and the one we must recognise is Node's own DOMException
|
|
392
|
+
// from AbortController, which we do not construct and cannot brand.
|
|
393
|
+
if (err && err.name === 'AbortError') {
|
|
394
|
+
const message = err.message ? err.message : 'Invocation aborted';
|
|
238
395
|
this._logger.info(
|
|
239
396
|
'invocation aborted by client',
|
|
240
397
|
{ err, operation, correlation_id, code: 'CLIENT_CANCELLED', status: 499 }
|
|
@@ -265,6 +422,8 @@ class ErrorMapper {
|
|
|
265
422
|
}
|
|
266
423
|
|
|
267
424
|
module.exports = {
|
|
425
|
+
BUSINESS_ERROR_BRAND,
|
|
426
|
+
isBusinessError,
|
|
268
427
|
ErrorMapper,
|
|
269
428
|
BusinessError,
|
|
270
429
|
ValidationError,
|
package/src/ServiceWrapper.js
CHANGED
|
@@ -326,7 +326,8 @@ class ServiceWrapper {
|
|
|
326
326
|
/**
|
|
327
327
|
* Fail-fast validation of naming conventions and workspaceScoped invariants.
|
|
328
328
|
*
|
|
329
|
-
* Enforced rules (all normative — see
|
|
329
|
+
* Enforced rules (all normative — see api/docs/biz/30-operations/schema-v3.md and
|
|
330
|
+
* api/docs/biz/60-templates/onboarding-checklist.md):
|
|
330
331
|
* 1. Every key in `operations.operations` matches kebab-case regex
|
|
331
332
|
* /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ (no camelCase, snake_case, or PascalCase).
|
|
332
333
|
* 2. `config.service.workspaceScoped` is an explicit boolean (no default).
|
|
@@ -379,12 +380,14 @@ class ServiceWrapper {
|
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
// Rules 3 & 4: workspaceScoped consistency with list-workspaces
|
|
383
|
+
// @see api/docs/biz/60-templates/onboarding-checklist.md §4 (workspace discovery convention)
|
|
384
|
+
// @see api/docs/biz/30-operations/schema-v3.md § Reserved operation names
|
|
382
385
|
const hasListWorkspaces = Object.prototype.hasOwnProperty.call(opsMap, 'list-workspaces');
|
|
383
386
|
if (ws === true && !hasListWorkspaces) {
|
|
384
387
|
throw new Error(
|
|
385
388
|
`[ServiceWrapper][${serviceName}] service.workspaceScoped is true but 'list-workspaces' operation is not declared. ` +
|
|
386
|
-
`Workspace-scoped services MUST expose list-workspaces (workspace discovery convention)
|
|
387
|
-
`
|
|
389
|
+
`Workspace-scoped services MUST expose list-workspaces (workspace discovery convention) - ` +
|
|
390
|
+
`Fix: declare a 'list-workspaces' operation in config/service/operations.json, or set service.workspaceScoped to false.`
|
|
388
391
|
);
|
|
389
392
|
}
|
|
390
393
|
if (ws === false && hasListWorkspaces) {
|
|
@@ -1587,9 +1590,25 @@ class ServiceWrapper {
|
|
|
1587
1590
|
throw new Error('[ServiceWrapper] mqClient is required to start heartbeat publisher');
|
|
1588
1591
|
}
|
|
1589
1592
|
|
|
1593
|
+
// Identity is required, never defaulted. A heartbeat published as
|
|
1594
|
+
// `unnamed-service@1.0.0` is not a missing heartbeat, it is a WRONG one:
|
|
1595
|
+
// the monitoring stack records a service that does not exist while the real
|
|
1596
|
+
// one looks silent (architecture-principles.md §3).
|
|
1597
|
+
//
|
|
1598
|
+
// Not a dead branch either. `_ensureValidationProof` fail-fasts on both keys
|
|
1599
|
+
// (below), but it runs only when serviceRoot is set and
|
|
1600
|
+
// wrapper.validation.enabled !== false — a service with validation off used
|
|
1601
|
+
// to arrive here with neither key ever checked.
|
|
1602
|
+
const serviceName = this.config.service?.name;
|
|
1603
|
+
if (!serviceName) {
|
|
1604
|
+
throw new Error('[ServiceWrapper] Missing configuration - service.name is required');
|
|
1605
|
+
}
|
|
1606
|
+
const serviceVersion = this.config.service?.version;
|
|
1607
|
+
if (!serviceVersion) {
|
|
1608
|
+
throw new Error('[ServiceWrapper] Missing configuration - service.version is required');
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1590
1611
|
const { createBaseClientAdapter } = require('@onlineapps/infrastructure-tools');
|
|
1591
|
-
const serviceName = this.config.service?.name || 'unnamed-service';
|
|
1592
|
-
const serviceVersion = this.config.service?.version || '1.0.0';
|
|
1593
1612
|
|
|
1594
1613
|
const getHealthData = () => this._collectHeartbeatComponents();
|
|
1595
1614
|
|
|
@@ -2391,16 +2410,7 @@ class ServiceWrapper {
|
|
|
2391
2410
|
const serviceVersion = this.config.service?.version;
|
|
2392
2411
|
const serviceUrl = this.config.service?.url;
|
|
2393
2412
|
|
|
2394
|
-
|
|
2395
|
-
const validationLogger = this.logger || console;
|
|
2396
|
-
const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
|
|
2397
|
-
serviceRoot: this.serviceRoot,
|
|
2398
|
-
serviceName,
|
|
2399
|
-
serviceVersion,
|
|
2400
|
-
serviceUrl,
|
|
2401
|
-
logger: validationLogger
|
|
2402
|
-
});
|
|
2403
|
-
|
|
2413
|
+
const orchestrator = this._createValidationOrchestrator();
|
|
2404
2414
|
const result = await orchestrator.validate();
|
|
2405
2415
|
|
|
2406
2416
|
if (!result.success) {
|
|
@@ -2442,6 +2452,160 @@ class ServiceWrapper {
|
|
|
2442
2452
|
}
|
|
2443
2453
|
}
|
|
2444
2454
|
|
|
2455
|
+
/**
|
|
2456
|
+
* One place the startup path waits, so a test can watch it and a reader can
|
|
2457
|
+
* find it. Never inlined as `new Promise(setTimeout)` again — an invisible
|
|
2458
|
+
* five-minute sleep is what made a healthy boot look wedged.
|
|
2459
|
+
*
|
|
2460
|
+
* @private
|
|
2461
|
+
* @param {number} ms
|
|
2462
|
+
*/
|
|
2463
|
+
_sleep(ms) {
|
|
2464
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
/**
|
|
2468
|
+
* A startup line on BOTH channels — structured log and stdout — exactly as
|
|
2469
|
+
* `_logPhase` does for the `[FÁZE …]` lines.
|
|
2470
|
+
*
|
|
2471
|
+
* The five-minute cooldown announced itself only through `this.logger?.info`.
|
|
2472
|
+
* On a container whose logger writes elsewhere that is no announcement at
|
|
2473
|
+
* all: the boot stops after FÁZE 0.15 and says nothing for five minutes
|
|
2474
|
+
* (automation-gates.md §5 — silence is a defect).
|
|
2475
|
+
*
|
|
2476
|
+
* @private
|
|
2477
|
+
* @param {string} message
|
|
2478
|
+
* @param {Object} [data]
|
|
2479
|
+
*/
|
|
2480
|
+
_logStartup(message, data = {}) {
|
|
2481
|
+
this.logger?.info(message, data);
|
|
2482
|
+
console.log(message);
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* Fingerprint of everything Tier-1 validation judges: the service's own
|
|
2487
|
+
* declaration files and its cookbooks.
|
|
2488
|
+
*
|
|
2489
|
+
* This is what the restart cooldown is keyed on. Time alone is the wrong key:
|
|
2490
|
+
* biz-emailer was repaired and restarted on 2026-08-29 and still waited five
|
|
2491
|
+
* minutes, because the file remembered 240 failures of a cause that no longer
|
|
2492
|
+
* existed.
|
|
2493
|
+
*
|
|
2494
|
+
* Uses the platform hash (`FingerprintUtils`), never a new one.
|
|
2495
|
+
*
|
|
2496
|
+
* @private
|
|
2497
|
+
* @returns {string|null} null when there is no service root to read
|
|
2498
|
+
*/
|
|
2499
|
+
_computeValidationCauseFingerprint() {
|
|
2500
|
+
const fs = require('fs');
|
|
2501
|
+
const path = require('path');
|
|
2502
|
+
|
|
2503
|
+
if (!this.serviceRoot) return null;
|
|
2504
|
+
|
|
2505
|
+
const FingerprintUtils = require('@onlineapps/service-validator-core/src/utils/FingerprintUtils');
|
|
2506
|
+
|
|
2507
|
+
const contents = {};
|
|
2508
|
+
const collect = (dir) => {
|
|
2509
|
+
let entries;
|
|
2510
|
+
try {
|
|
2511
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2512
|
+
} catch {
|
|
2513
|
+
return; // an absent directory is a fact about the tree, not an error
|
|
2514
|
+
}
|
|
2515
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2516
|
+
const abs = path.join(dir, entry.name);
|
|
2517
|
+
if (entry.isDirectory()) { collect(abs); continue; }
|
|
2518
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
2519
|
+
try {
|
|
2520
|
+
contents[path.relative(this.serviceRoot, abs)] = fs.readFileSync(abs, 'utf8');
|
|
2521
|
+
} catch {
|
|
2522
|
+
contents[path.relative(this.serviceRoot, abs)] = '<unreadable>';
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
collect(path.join(this.serviceRoot, 'config', 'service'));
|
|
2528
|
+
collect(path.join(this.serviceRoot, 'tests', 'cookbooks'));
|
|
2529
|
+
|
|
2530
|
+
return FingerprintUtils.generateContentFingerprint(contents);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* The restart-aware cooldown, applied ONLY to the same cause.
|
|
2535
|
+
*
|
|
2536
|
+
* Three outcomes, and each one says which on stdout:
|
|
2537
|
+
* - cause changed → no wait, the failure file is dropped so the attempt
|
|
2538
|
+
* count starts from zero. A file written before fingerprints existed has
|
|
2539
|
+
* none, which is not the current one, so it takes this branch: a missing
|
|
2540
|
+
* fingerprint is a changed cause, not a compatibility case
|
|
2541
|
+
* (architecture-principles.md §11).
|
|
2542
|
+
* - same cause, cooldown not elapsed → wait, as before.
|
|
2543
|
+
* - same cause, cooldown elapsed → no wait.
|
|
2544
|
+
*
|
|
2545
|
+
* @private
|
|
2546
|
+
*/
|
|
2547
|
+
async _applyRestartCooldown() {
|
|
2548
|
+
const failureData = this._readValidationFailureFile();
|
|
2549
|
+
if (!failureData || failureData.attemptCount < REVALIDATION_FAST_BACKOFF_MS.length) {
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
const currentCause = this._computeValidationCauseFingerprint();
|
|
2554
|
+
if (failureData.causeFingerprint !== currentCause) {
|
|
2555
|
+
this._logStartup(
|
|
2556
|
+
'[ServiceWrapper][startup] Validation cause changed since the last failure - retrying fresh',
|
|
2557
|
+
{ previousAttempts: failureData.attemptCount, firstFailure: failureData.firstFailure }
|
|
2558
|
+
);
|
|
2559
|
+
this._clearValidationFailureFile();
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
const sinceLastAttempt = Date.now() - new Date(failureData.lastAttempt).getTime();
|
|
2564
|
+
if (sinceLastAttempt >= REVALIDATION_SLOW_MS) {
|
|
2565
|
+
this._logStartup('[ServiceWrapper][startup] Previous failures detected, but cooldown elapsed - retrying fresh');
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
const remainingMs = REVALIDATION_SLOW_MS - sinceLastAttempt;
|
|
2570
|
+
const waitMs = Math.min(remainingMs, 300000);
|
|
2571
|
+
this._logStartup(
|
|
2572
|
+
`[ServiceWrapper][startup] Previous validation failures detected (${failureData.attemptCount} attempts since ${failureData.firstFailure}, same cause) - waiting ${Math.round(waitMs / 60000)}min before retry`,
|
|
2573
|
+
{ attemptCount: failureData.attemptCount, waitMs }
|
|
2574
|
+
);
|
|
2575
|
+
await this._sleep(waitMs);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
/**
|
|
2579
|
+
* The ONE place a ValidationOrchestrator is built.
|
|
2580
|
+
*
|
|
2581
|
+
* Both validation paths arrived in the same commit (869a346a, 2026-03-26) and
|
|
2582
|
+
* ended up different by accident, not by design: `_executeRevalidation` builds
|
|
2583
|
+
* one per attempt because the whole function IS one attempt, while
|
|
2584
|
+
* `_ensureValidationProof` hoisted the construction out of its retry loop and
|
|
2585
|
+
* reused a single instance across six attempts. That reuse was not free —
|
|
2586
|
+
* dávka 49 had to add `resetResults()` to `CookbookTestRunner` because the
|
|
2587
|
+
* shared runner accumulated failures across those attempts and the verdict
|
|
2588
|
+
* could never come back true inside one process.
|
|
2589
|
+
*
|
|
2590
|
+
* Nothing needs state between attempts, so every attempt gets a fresh
|
|
2591
|
+
* orchestrator. The injected one (tests) is still returned as-is: it is the
|
|
2592
|
+
* seam, and a seam that behaved differently per call would test nothing.
|
|
2593
|
+
*
|
|
2594
|
+
* @private
|
|
2595
|
+
*/
|
|
2596
|
+
_createValidationOrchestrator() {
|
|
2597
|
+
if (this._injectedValidationOrchestrator) return this._injectedValidationOrchestrator;
|
|
2598
|
+
|
|
2599
|
+
// INTENTIONAL FALLBACK: bootstrap logger — see docs/standards/FALLBACKS_INVENTORY.md §5.1
|
|
2600
|
+
return new ValidationOrchestrator({
|
|
2601
|
+
serviceRoot: this.serviceRoot,
|
|
2602
|
+
serviceName: this.config.service?.name,
|
|
2603
|
+
serviceVersion: this.config.service?.version,
|
|
2604
|
+
serviceUrl: this.config.service?.url || '',
|
|
2605
|
+
logger: this.logger || console
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2445
2609
|
/**
|
|
2446
2610
|
* Read restart-aware validation failure tracking file.
|
|
2447
2611
|
* Used at startup to detect if the service has been failing validation across restarts.
|
|
@@ -2486,7 +2650,10 @@ class ServiceWrapper {
|
|
|
2486
2650
|
lastAttempt: new Date().toISOString(),
|
|
2487
2651
|
attemptCount,
|
|
2488
2652
|
lastError: error.message,
|
|
2489
|
-
firstFailure: firstFailure || new Date().toISOString()
|
|
2653
|
+
firstFailure: firstFailure || new Date().toISOString(),
|
|
2654
|
+
// What validation was judging when it failed. The restart cooldown is
|
|
2655
|
+
// keyed on this, not on time alone.
|
|
2656
|
+
causeFingerprint: this._computeValidationCauseFingerprint()
|
|
2490
2657
|
};
|
|
2491
2658
|
|
|
2492
2659
|
fs.writeFileSync(failurePath, JSON.stringify(data, null, 2));
|
|
@@ -2614,49 +2781,28 @@ class ServiceWrapper {
|
|
|
2614
2781
|
throw new Error('Service version is required for validation');
|
|
2615
2782
|
}
|
|
2616
2783
|
|
|
2617
|
-
|
|
2784
|
+
// ADR 0005: biz services have no HTTP surface — no port to bind, no URL to
|
|
2785
|
+
// advertise. The two guards above are the contract; the values themselves
|
|
2786
|
+
// are read by _createValidationOrchestrator() straight from this.config, so
|
|
2787
|
+
// there is nothing left to hold in a local (change-discipline.md § Removing
|
|
2788
|
+
// something removes its declaration).
|
|
2618
2789
|
|
|
2619
|
-
//
|
|
2620
|
-
|
|
2621
|
-
// dropped in 3.4.1 because they had no consumer post-A2. Kept as a
|
|
2622
|
-
// local var so downstream ValidationOrchestrator can still receive it
|
|
2623
|
-
// (accepts empty string; only used for telemetry / log annotation).
|
|
2624
|
-
const serviceUrl = this.config.service?.url || '';
|
|
2790
|
+
// Phase 5: restart-aware failure tracking, keyed on the CAUSE.
|
|
2791
|
+
await this._applyRestartCooldown();
|
|
2625
2792
|
|
|
2626
|
-
//
|
|
2793
|
+
// Read AFTER the cooldown: a changed cause deletes the file, and the run
|
|
2794
|
+
// that follows must start from zero attempts. Reading before would carry
|
|
2795
|
+
// the old count into the write below and reinstate the very cooldown the
|
|
2796
|
+
// reset just lifted.
|
|
2627
2797
|
const failureData = this._readValidationFailureFile();
|
|
2628
|
-
if (failureData && failureData.attemptCount >= REVALIDATION_FAST_BACKOFF_MS.length) {
|
|
2629
|
-
const lastAttempt = new Date(failureData.lastAttempt).getTime();
|
|
2630
|
-
const sinceLastAttempt = Date.now() - lastAttempt;
|
|
2631
|
-
|
|
2632
|
-
if (sinceLastAttempt < REVALIDATION_SLOW_MS) {
|
|
2633
|
-
const remainingMs = REVALIDATION_SLOW_MS - sinceLastAttempt;
|
|
2634
|
-
const remainingMin = Math.round(remainingMs / 60000);
|
|
2635
|
-
this.logger?.info(
|
|
2636
|
-
`[ServiceWrapper][startup] Previous validation failures detected (${failureData.attemptCount} attempts since ${failureData.firstFailure}) - waiting ${remainingMin}min before retry`
|
|
2637
|
-
);
|
|
2638
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(remainingMs, 300000)));
|
|
2639
|
-
} else {
|
|
2640
|
-
this.logger?.info('[ServiceWrapper][startup] Previous failures detected, but cooldown elapsed - retrying fresh');
|
|
2641
|
-
}
|
|
2642
|
-
}
|
|
2643
2798
|
|
|
2644
2799
|
this.logger?.info('[ServiceWrapper] Checking validation proof...');
|
|
2645
2800
|
|
|
2646
|
-
// INTENTIONAL FALLBACK: bootstrap logger — see docs/standards/FALLBACKS_INVENTORY.md §5.1
|
|
2647
|
-
const revalidationLogger = this.logger || console;
|
|
2648
|
-
const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
|
|
2649
|
-
serviceRoot: this.serviceRoot,
|
|
2650
|
-
serviceName,
|
|
2651
|
-
serviceVersion,
|
|
2652
|
-
serviceUrl,
|
|
2653
|
-
logger: revalidationLogger
|
|
2654
|
-
});
|
|
2655
|
-
|
|
2656
2801
|
// Startup retry loop with fast backoff
|
|
2657
2802
|
let lastError = null;
|
|
2658
2803
|
for (let attempt = 0; attempt < REVALIDATION_FAST_BACKOFF_MS.length; attempt++) {
|
|
2659
2804
|
try {
|
|
2805
|
+
const orchestrator = this._createValidationOrchestrator();
|
|
2660
2806
|
const result = await orchestrator.validate();
|
|
2661
2807
|
|
|
2662
2808
|
if (!result.success) {
|
package/src/index.js
CHANGED
|
@@ -124,6 +124,8 @@ const { OperationContext } = require('./OperationContext');
|
|
|
124
124
|
const { ContextBuilder } = require('./ContextBuilder');
|
|
125
125
|
const { SchemaValidator } = require('./SchemaValidator');
|
|
126
126
|
const {
|
|
127
|
+
BUSINESS_ERROR_BRAND,
|
|
128
|
+
isBusinessError,
|
|
127
129
|
ErrorMapper,
|
|
128
130
|
BusinessError,
|
|
129
131
|
ValidationError,
|
|
@@ -161,6 +163,18 @@ module.exports.ErrorMapper = ErrorMapper;
|
|
|
161
163
|
// biz service throws; the retired @onlineapps/service-common hierarchy used the
|
|
162
164
|
// same `(message, options)` signature, so migration is an import-line change.
|
|
163
165
|
// Contract + per-class status/code: src/ErrorMapper.js header.
|
|
166
|
+
//
|
|
167
|
+
// The contract itself (DÁVKA 50, confirmation 20260829-1500-business-error-contract-001)
|
|
168
|
+
// is the BRAND plus the SHAPE, not the class identity: an error is a business
|
|
169
|
+
// error when it carries `BUSINESS_ERROR_BRAND` === true, a `code` matching
|
|
170
|
+
// /^[A-Z][A-Z0-9_]*$/, a numeric `status` in [100, 599] and a non-empty
|
|
171
|
+
// `message`. ErrorMapper dispatches on `code`, never on `instanceof`, so a
|
|
172
|
+
// service may throw its OWN class as long as it declares the brand and the
|
|
173
|
+
// shape — `isBusinessError()` is the predicate that decides. Branded but
|
|
174
|
+
// malformed is a named 500 MALFORMED_BUSINESS_ERROR; unbranded stays a masked
|
|
175
|
+
// 500 INTERNAL_ERROR.
|
|
176
|
+
module.exports.BUSINESS_ERROR_BRAND = BUSINESS_ERROR_BRAND;
|
|
177
|
+
module.exports.isBusinessError = isBusinessError;
|
|
164
178
|
module.exports.BusinessError = BusinessError;
|
|
165
179
|
module.exports.ValidationError = ValidationError;
|
|
166
180
|
module.exports.NotFoundError = NotFoundError;
|