@onlineapps/service-wrapper 4.1.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 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.1.0",
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.9",
28
- "@onlineapps/conn-base-monitoring": "1.0.15",
29
- "@onlineapps/conn-base-state": "1.0.1",
30
- "@onlineapps/conn-infra-error-handler": "1.0.14",
31
- "@onlineapps/conn-infra-mq": "1.1.70",
32
- "@onlineapps/conn-infra-secrets": "1.0.0",
33
- "@onlineapps/conn-orch-cookbook": "2.1.4",
34
- "@onlineapps/conn-orch-orchestrator": "2.1.7",
35
- "@onlineapps/conn-orch-registry": "1.2.2",
36
- "@onlineapps/conn-orch-validator": "4.0.0",
37
- "@onlineapps/infrastructure-tools": "1.2.6",
38
- "@onlineapps/monitoring-core": "1.0.26",
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
  },
@@ -1590,9 +1590,25 @@ class ServiceWrapper {
1590
1590
  throw new Error('[ServiceWrapper] mqClient is required to start heartbeat publisher');
1591
1591
  }
1592
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
+
1593
1611
  const { createBaseClientAdapter } = require('@onlineapps/infrastructure-tools');
1594
- const serviceName = this.config.service?.name || 'unnamed-service';
1595
- const serviceVersion = this.config.service?.version || '1.0.0';
1596
1612
 
1597
1613
  const getHealthData = () => this._collectHeartbeatComponents();
1598
1614
 
@@ -2394,16 +2410,7 @@ class ServiceWrapper {
2394
2410
  const serviceVersion = this.config.service?.version;
2395
2411
  const serviceUrl = this.config.service?.url;
2396
2412
 
2397
- // INTENTIONAL FALLBACK: bootstrap logger — see docs/standards/FALLBACKS_INVENTORY.md §5.1
2398
- const validationLogger = this.logger || console;
2399
- const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
2400
- serviceRoot: this.serviceRoot,
2401
- serviceName,
2402
- serviceVersion,
2403
- serviceUrl,
2404
- logger: validationLogger
2405
- });
2406
-
2413
+ const orchestrator = this._createValidationOrchestrator();
2407
2414
  const result = await orchestrator.validate();
2408
2415
 
2409
2416
  if (!result.success) {
@@ -2445,6 +2452,160 @@ class ServiceWrapper {
2445
2452
  }
2446
2453
  }
2447
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
+
2448
2609
  /**
2449
2610
  * Read restart-aware validation failure tracking file.
2450
2611
  * Used at startup to detect if the service has been failing validation across restarts.
@@ -2489,7 +2650,10 @@ class ServiceWrapper {
2489
2650
  lastAttempt: new Date().toISOString(),
2490
2651
  attemptCount,
2491
2652
  lastError: error.message,
2492
- 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()
2493
2657
  };
2494
2658
 
2495
2659
  fs.writeFileSync(failurePath, JSON.stringify(data, null, 2));
@@ -2617,49 +2781,28 @@ class ServiceWrapper {
2617
2781
  throw new Error('Service version is required for validation');
2618
2782
  }
2619
2783
 
2620
- const { name: serviceName, version: serviceVersion } = this.config.service;
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).
2621
2789
 
2622
- // ADR 0005: biz services have no HTTP surface — no port to bind, no
2623
- // URL to advertise. Prior versions of this method required both;
2624
- // dropped in 3.4.1 because they had no consumer post-A2. Kept as a
2625
- // local var so downstream ValidationOrchestrator can still receive it
2626
- // (accepts empty string; only used for telemetry / log annotation).
2627
- const serviceUrl = this.config.service?.url || '';
2790
+ // Phase 5: restart-aware failure tracking, keyed on the CAUSE.
2791
+ await this._applyRestartCooldown();
2628
2792
 
2629
- // Phase 5: Check restart-aware failure tracking
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.
2630
2797
  const failureData = this._readValidationFailureFile();
2631
- if (failureData && failureData.attemptCount >= REVALIDATION_FAST_BACKOFF_MS.length) {
2632
- const lastAttempt = new Date(failureData.lastAttempt).getTime();
2633
- const sinceLastAttempt = Date.now() - lastAttempt;
2634
-
2635
- if (sinceLastAttempt < REVALIDATION_SLOW_MS) {
2636
- const remainingMs = REVALIDATION_SLOW_MS - sinceLastAttempt;
2637
- const remainingMin = Math.round(remainingMs / 60000);
2638
- this.logger?.info(
2639
- `[ServiceWrapper][startup] Previous validation failures detected (${failureData.attemptCount} attempts since ${failureData.firstFailure}) - waiting ${remainingMin}min before retry`
2640
- );
2641
- await new Promise(resolve => setTimeout(resolve, Math.min(remainingMs, 300000)));
2642
- } else {
2643
- this.logger?.info('[ServiceWrapper][startup] Previous failures detected, but cooldown elapsed - retrying fresh');
2644
- }
2645
- }
2646
2798
 
2647
2799
  this.logger?.info('[ServiceWrapper] Checking validation proof...');
2648
2800
 
2649
- // INTENTIONAL FALLBACK: bootstrap logger — see docs/standards/FALLBACKS_INVENTORY.md §5.1
2650
- const revalidationLogger = this.logger || console;
2651
- const orchestrator = this._injectedValidationOrchestrator || new ValidationOrchestrator({
2652
- serviceRoot: this.serviceRoot,
2653
- serviceName,
2654
- serviceVersion,
2655
- serviceUrl,
2656
- logger: revalidationLogger
2657
- });
2658
-
2659
2801
  // Startup retry loop with fast backoff
2660
2802
  let lastError = null;
2661
2803
  for (let attempt = 0; attempt < REVALIDATION_FAST_BACKOFF_MS.length; attempt++) {
2662
2804
  try {
2805
+ const orchestrator = this._createValidationOrchestrator();
2663
2806
  const result = await orchestrator.validate();
2664
2807
 
2665
2808
  if (!result.success) {