@onlineapps/service-wrapper 3.0.15 → 3.0.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/service-wrapper",
3
- "version": "3.0.15",
3
+ "version": "3.0.16",
4
4
  "description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -156,6 +156,11 @@ class ServiceWrapper {
156
156
  this._revalidationState = REVALIDATION_STATE.IDLE;
157
157
  this._revalidationAttempt = 0;
158
158
  this._revalidationTimer = null;
159
+
160
+ // Runtime connector contract + probes (loaded from config/service/integration-contract.json)
161
+ this.integrationContract = null;
162
+ this.requiredConnectors = null;
163
+ this._dbHealthConnector = null;
159
164
  }
160
165
 
161
166
  /**
@@ -438,6 +443,160 @@ class ServiceWrapper {
438
443
  return processObject(config);
439
444
  }
440
445
 
446
+ /**
447
+ * Load and validate runtime integration contract used by health checks.
448
+ * @private
449
+ */
450
+ _loadIntegrationContract() {
451
+ if (!this.serviceRoot) {
452
+ throw new Error(
453
+ '[ServiceWrapper] Missing dependency - serviceRoot is required to load config/service/integration-contract.json for runtime health checks'
454
+ );
455
+ }
456
+
457
+ const fs = require('fs');
458
+ const path = require('path');
459
+ const contractPath = path.join(this.serviceRoot, 'config', 'service', 'integration-contract.json');
460
+
461
+ if (!fs.existsSync(contractPath)) {
462
+ throw new Error(
463
+ `[ServiceWrapper] Missing integration contract - Expected file at ${contractPath}. ` +
464
+ 'Fix: add config/service/integration-contract.json with requiredConnectors booleans.'
465
+ );
466
+ }
467
+
468
+ let raw;
469
+ try {
470
+ raw = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
471
+ } catch (error) {
472
+ throw new Error(
473
+ `[ServiceWrapper] Invalid integration contract - Cannot parse JSON at ${contractPath}. ${error.message}`
474
+ );
475
+ }
476
+
477
+ const requiredConnectors = raw?.requiredConnectors;
478
+ if (!requiredConnectors || typeof requiredConnectors !== 'object' || Array.isArray(requiredConnectors)) {
479
+ throw new Error(
480
+ `[ServiceWrapper] Invalid integration contract - requiredConnectors object is required in ${contractPath}`
481
+ );
482
+ }
483
+
484
+ const keys = ['db', 'redis', 'mq', 'minio'];
485
+ const normalized = {};
486
+ for (const key of keys) {
487
+ if (typeof requiredConnectors[key] !== 'boolean') {
488
+ throw new Error(
489
+ `[ServiceWrapper] Invalid integration contract - requiredConnectors.${key} must be boolean in ${contractPath}`
490
+ );
491
+ }
492
+ normalized[key] = requiredConnectors[key];
493
+ }
494
+
495
+ this.integrationContract = raw;
496
+ this.requiredConnectors = normalized;
497
+ }
498
+
499
+ /**
500
+ * Resolve DB connector used for runtime health checks.
501
+ * @private
502
+ * @returns {Object}
503
+ */
504
+ _loadDatabaseHealthConnector() {
505
+ if (this._dbHealthConnector) {
506
+ return this._dbHealthConnector;
507
+ }
508
+
509
+ const path = require('path');
510
+ const dbModulePath = path.join(this.serviceRoot, 'src', 'config', 'database');
511
+
512
+ let dbConnector;
513
+ try {
514
+ dbConnector = require(dbModulePath);
515
+ } catch (error) {
516
+ throw new Error(
517
+ `[ServiceWrapper] Database connector unavailable - Failed to load ${dbModulePath}.js. ${error.message}`
518
+ );
519
+ }
520
+
521
+ if (!dbConnector || typeof dbConnector.authenticate !== 'function') {
522
+ throw new Error(
523
+ `[ServiceWrapper] Database connector invalid - Expected Sequelize-compatible module with authenticate() at ${dbModulePath}.js`
524
+ );
525
+ }
526
+
527
+ this._dbHealthConnector = dbConnector;
528
+ return dbConnector;
529
+ }
530
+
531
+ /**
532
+ * Check required DB dependency according to integration-contract.
533
+ * @private
534
+ * @returns {Promise<{status: string, reason: (string|null)}>}
535
+ */
536
+ async _checkDatabaseDependency() {
537
+ if (!this.requiredConnectors?.db) {
538
+ return { status: 'not_required', reason: null };
539
+ }
540
+
541
+ try {
542
+ const dbConnector = this._loadDatabaseHealthConnector();
543
+ await dbConnector.authenticate();
544
+ return { status: 'healthy', reason: null };
545
+ } catch (error) {
546
+ return { status: 'unhealthy', reason: error.message };
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Check required Redis dependency according to integration-contract.
552
+ * Uses available cache/state connectors and requires at least one healthy probe.
553
+ * @private
554
+ * @returns {Promise<{status: string, reason: (string|null)}>}
555
+ */
556
+ async _checkRedisDependency() {
557
+ if (!this.requiredConnectors?.redis) {
558
+ return { status: 'not_required', reason: null };
559
+ }
560
+
561
+ const checks = [];
562
+ const failures = [];
563
+
564
+ const runProbe = async (name, connector) => {
565
+ if (!connector || typeof connector.healthCheck !== 'function') {
566
+ failures.push(`${name}: connector missing healthCheck()`);
567
+ return;
568
+ }
569
+ try {
570
+ const healthy = await connector.healthCheck();
571
+ checks.push({ name, healthy: healthy === true });
572
+ if (healthy !== true) {
573
+ failures.push(`${name}: healthCheck returned false`);
574
+ }
575
+ } catch (error) {
576
+ failures.push(`${name}: ${error.message}`);
577
+ }
578
+ };
579
+
580
+ await runProbe('cache', this.cacheConnector);
581
+ await runProbe('state', this.stateConnector);
582
+
583
+ if (checks.some((entry) => entry.healthy === true)) {
584
+ return { status: 'healthy', reason: null };
585
+ }
586
+
587
+ if (checks.length === 0) {
588
+ return {
589
+ status: 'unhealthy',
590
+ reason: 'Redis is required but no Redis connector is initialized (cache/state missing)'
591
+ };
592
+ }
593
+
594
+ return {
595
+ status: 'unhealthy',
596
+ reason: failures.length > 0 ? failures.join('; ') : 'Redis health probes failed'
597
+ };
598
+ }
599
+
441
600
  /**
442
601
  * Initialize all wrapper components
443
602
  * @async
@@ -638,6 +797,19 @@ class ServiceWrapper {
638
797
  // See RFC api/docs/architecture/biz-service-invocation-model.md §5.9 step 5.
639
798
  this._logPhase('0.1b', 'Operations-Routes Alignment', 'SKIPPED', null, 0);
640
799
 
800
+ // FÁZE 0.15: Runtime integration connector contract (health + readiness source of truth)
801
+ if (this.config.wrapper?.health?.enabled !== false) {
802
+ const contractStartTime = Date.now();
803
+ try {
804
+ this._loadIntegrationContract();
805
+ this._logPhase('0.15', 'Integration Contract Load', 'PASSED', null, Date.now() - contractStartTime);
806
+ } catch (error) {
807
+ return await this._handleInitializationError('0.15', 'Integration Contract Load', error, false); // Trvalá chyba
808
+ }
809
+ } else {
810
+ this._logPhase('0.15', 'Integration Contract Load', 'SKIPPED', null, 0);
811
+ }
812
+
641
813
  // FÁZE 0.2: Tier 1 validace (PŘED MQ připojením)
642
814
  if (this.serviceRoot && this.config.wrapper?.validation?.enabled !== false) {
643
815
  const validationStartTime = Date.now();
@@ -1299,16 +1471,20 @@ class ServiceWrapper {
1299
1471
  const healthEndpoint = this.config.wrapper?.health?.endpoint || '/health';
1300
1472
 
1301
1473
  const healthHandler = async (req, res) => {
1474
+ const dependencyIssues = [];
1302
1475
  const health = {
1303
1476
  status: 'healthy',
1304
1477
  service: this.config.service?.name || 'unnamed-service',
1305
1478
  timestamp: new Date().toISOString(),
1479
+ requiredConnectors: this.requiredConnectors || null,
1306
1480
  components: {
1307
1481
  http: 'healthy',
1308
1482
  mq: 'disabled',
1309
1483
  registry: this.registryClient ? 'healthy' : 'disabled',
1310
1484
  cache: this.cacheConnector ? 'healthy' : 'disabled',
1311
- state: this.stateConnector ? 'healthy' : 'disabled'
1485
+ state: this.stateConnector ? 'healthy' : 'disabled',
1486
+ database: this.requiredConnectors?.db === true ? 'unknown' : 'not_required',
1487
+ redis: this.requiredConnectors?.redis === true ? 'unknown' : 'not_required'
1312
1488
  }
1313
1489
  };
1314
1490
 
@@ -1330,6 +1506,30 @@ class ServiceWrapper {
1330
1506
  }
1331
1507
  }
1332
1508
 
1509
+ if (!this.requiredConnectors) {
1510
+ health.components.database = 'unhealthy';
1511
+ health.components.redis = 'unhealthy';
1512
+ dependencyIssues.push(
1513
+ 'Integration contract was not loaded - expected config/service/integration-contract.json during initialization'
1514
+ );
1515
+ } else {
1516
+ const dbDependency = await this._checkDatabaseDependency();
1517
+ health.components.database = dbDependency.status;
1518
+ if (dbDependency.status === 'unhealthy' && dbDependency.reason) {
1519
+ dependencyIssues.push(`[db] ${dbDependency.reason}`);
1520
+ }
1521
+
1522
+ const redisDependency = await this._checkRedisDependency();
1523
+ health.components.redis = redisDependency.status;
1524
+ if (redisDependency.status === 'unhealthy' && redisDependency.reason) {
1525
+ dependencyIssues.push(`[redis] ${redisDependency.reason}`);
1526
+ }
1527
+ }
1528
+
1529
+ if (dependencyIssues.length > 0) {
1530
+ health.dependencyIssues = dependencyIssues;
1531
+ }
1532
+
1333
1533
  const statuses = Object.values(health.components);
1334
1534
  if (statuses.includes('unhealthy')) {
1335
1535
  health.status = 'unhealthy';