@onlineapps/conn-orch-validator 3.3.2 → 4.0.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 +358 -0
- package/README.md +46 -6
- package/TESTING_STRATEGY.md +1 -1
- package/docs/DESIGN.md +18 -11
- package/package.json +2 -8
- package/src/CookbookTestRunner.js +155 -26
- package/src/CookbookTestUtils.js +12 -4
- package/src/ServiceReadinessValidator.js +35 -18
- package/src/ValidationOrchestrator.js +223 -126
- package/src/cli/biz-ci-gate.js +356 -7
- package/src/helpers/README.md +3 -55
- package/src/helpers/createServiceReadinessTests.js +5 -1
- package/src/index.js +5 -2
- package/src/mocks/MockMQClient.js +100 -15
- package/src/utils/bizCiGateContract.js +110 -21
- package/src/utils/connectorContract.js +96 -0
- package/src/utils/cookbookFormat.js +95 -0
- package/src/utils/deployContract.js +488 -0
- package/src/utils/envContract.js +417 -0
- package/src/utils/installContract.js +142 -0
- package/src/utils/integrationRun.js +297 -0
- package/src/utils/libCompat.js +158 -0
- package/src/utils/preValidation.js +137 -0
- package/src/utils/setupDatabase.js +154 -0
- package/src/utils/stepFailure.js +73 -0
- package/src/utils/testNamespace.js +104 -0
- package/src/validators/ServiceStructureValidator.js +195 -48
- package/src/config.js +0 -32
- package/src/defaults.js +0 -11
- package/src/helpers/createPreValidationTests.js +0 -326
- package/test-mq-flow.js +0 -72
- package/test-orchestrator.js +0 -95
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one schema build for every biz service.
|
|
5
|
+
*
|
|
6
|
+
* The service declares WHAT its database is in integration-contract.json; this
|
|
7
|
+
* decides HOW it gets built, identically for all of them
|
|
8
|
+
* (docs/biz/00-model/uniformity-principle.md).
|
|
9
|
+
*
|
|
10
|
+
* It replaces six per-repo ci-setup-db.js scripts that had each solved the same
|
|
11
|
+
* problem differently — 113, 69, 56, 54, 22 and 20 lines, in three strategies,
|
|
12
|
+
* two of which bypassed migrations entirely with sequelize.sync(). That bypass
|
|
13
|
+
* let a service's CI prove a schema built from models while production ran one
|
|
14
|
+
* built from hand-applied migrations. There is deliberately no option here to
|
|
15
|
+
* build from anything but the migrations: the absence is what makes the bypass
|
|
16
|
+
* impossible rather than merely discouraged.
|
|
17
|
+
*
|
|
18
|
+
* Two invariants, both from ADR 0006:
|
|
19
|
+
*
|
|
20
|
+
* §2 every .sql file in the declared directory is applied, in sorted order.
|
|
21
|
+
* No list lives in this file — the set grows with each schema change, and a
|
|
22
|
+
* list would drift until a missing table surfaced in an unrelated test.
|
|
23
|
+
*
|
|
24
|
+
* §3 foreign key checks stay ON. Deferring them would let a migration
|
|
25
|
+
* reference a table a later file creates and still report success, which is
|
|
26
|
+
* the exact defect class building from empty exists to catch.
|
|
27
|
+
*
|
|
28
|
+
* SQL runs through the mysql/mariadb CLI rather than a driver, because trigger
|
|
29
|
+
* definitions use DELIMITER — a client directive drivers report as a syntax
|
|
30
|
+
* error. The executor is injected so the decisions are testable without a
|
|
31
|
+
* database.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { spawnSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
function requireValue(value, name) {
|
|
39
|
+
if (value === undefined || value === null || value === '') {
|
|
40
|
+
throw new Error(`[SetupDatabase] Missing ${name} - Expected a value. `
|
|
41
|
+
+ `Fix: set ${name} in the CI job (or env-active/*.env locally).`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decide which files to apply and in what order.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} serviceRoot repository root
|
|
50
|
+
* @param {{migrations: string, seeds: string[]}} database the contract declaration
|
|
51
|
+
*/
|
|
52
|
+
function resolveMigrationPlan(serviceRoot, database) {
|
|
53
|
+
// The declaration is a glob-ish path; only the directory part is read, and
|
|
54
|
+
// only its own .sql files — subdirectories such as migrations/superseded/ hold
|
|
55
|
+
// the record of how a baseline was reached and must not be replayed.
|
|
56
|
+
const dir = path.resolve(serviceRoot, path.dirname(database.migrations));
|
|
57
|
+
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
throw new Error(`[SetupDatabase] Migrations directory not found: ${dir}\n`
|
|
60
|
+
+ ` Fix: create it, or correct database.migrations in the integration contract.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const migrations = fs.readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
|
65
|
+
.map((entry) => entry.name)
|
|
66
|
+
.sort()
|
|
67
|
+
.map((name) => path.join(dir, name));
|
|
68
|
+
|
|
69
|
+
if (migrations.length === 0) {
|
|
70
|
+
throw new Error(`[SetupDatabase] No .sql files in ${dir} - refusing to report a schema as built.\n`
|
|
71
|
+
+ ' Fix: the migration set is the source of truth for the schema; it cannot be empty.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const seeds = (database.seeds ?? []).map((seed) => {
|
|
75
|
+
const file = path.resolve(serviceRoot, seed);
|
|
76
|
+
if (!fs.existsSync(file)) {
|
|
77
|
+
throw new Error(`[SetupDatabase] Declared seed not found: ${seed}\n`
|
|
78
|
+
+ ' Fix: correct database.seeds in the integration contract, or add the file.');
|
|
79
|
+
}
|
|
80
|
+
return file;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { dir, migrations, seeds };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run one statement or file through the CLI client. Injectable for tests. */
|
|
87
|
+
function defaultExec({ connection, schema, sql, file }) {
|
|
88
|
+
const client = ['mariadb', 'mysql'].find((bin) => !spawnSync(bin, ['--version'], { encoding: 'utf8' }).error);
|
|
89
|
+
if (!client) {
|
|
90
|
+
return {
|
|
91
|
+
status: 1,
|
|
92
|
+
stderr: '[SetupDatabase] No mysql/mariadb client on PATH - required because migrations use DELIMITER. '
|
|
93
|
+
+ 'Fix: install mariadb-client in the CI job image.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const args = ['-h', connection.host, '-P', String(connection.port), '-u', connection.user,
|
|
98
|
+
'--default-character-set=utf8mb4'];
|
|
99
|
+
// MariaDB Connector/C 3.x requires TLS by default and the CI database is a
|
|
100
|
+
// sidecar with none; the connection never leaves the job's private network.
|
|
101
|
+
if (connection.requireTls !== true) args.push('--skip-ssl');
|
|
102
|
+
if (connection.password) args.push(`-p${connection.password}`);
|
|
103
|
+
if (schema) args.push(schema);
|
|
104
|
+
|
|
105
|
+
const result = spawnSync(client, args, {
|
|
106
|
+
input: sql !== undefined ? sql : fs.readFileSync(file, 'utf8'),
|
|
107
|
+
encoding: 'utf8'
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
status: result.error ? 1 : result.status,
|
|
112
|
+
stderr: result.error ? result.error.message
|
|
113
|
+
: (result.stderr || '').split('\n').filter((l) => !l.includes('Using a password')).join('\n').trim()
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the schema from the declared migrations.
|
|
119
|
+
*
|
|
120
|
+
* @returns {{schema: string, migrationsApplied: number, seedsApplied: number}}
|
|
121
|
+
*/
|
|
122
|
+
function buildSchema({ serviceRoot, database, connection, exec = defaultExec }) {
|
|
123
|
+
requireValue(connection?.host, 'DB_HOST');
|
|
124
|
+
requireValue(connection?.user, 'DB_USER');
|
|
125
|
+
const port = connection.port ?? 3306;
|
|
126
|
+
|
|
127
|
+
const conn = { ...connection, port };
|
|
128
|
+
const plan = resolveMigrationPlan(serviceRoot, database);
|
|
129
|
+
|
|
130
|
+
const created = exec({ connection: conn, sql: `CREATE DATABASE IF NOT EXISTS \`${database.schema}\`;` });
|
|
131
|
+
if (created.status !== 0) {
|
|
132
|
+
throw new Error(`[SetupDatabase] Could not create schema ${database.schema}:\n${created.stderr}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const apply = (file, kind) => {
|
|
136
|
+
const result = exec({ connection: conn, schema: database.schema, file });
|
|
137
|
+
if (result.status !== 0) {
|
|
138
|
+
throw new Error(`[SetupDatabase] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
|
|
139
|
+
+ ' Fix: the set must apply to an empty database in file order. Either the SQL is wrong, '
|
|
140
|
+
+ 'or the file is numbered before something it depends on.');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const file of plan.migrations) apply(file, 'Migration');
|
|
145
|
+
for (const file of plan.seeds) apply(file, 'Seed');
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
schema: database.schema,
|
|
149
|
+
migrationsApplied: plan.migrations.length,
|
|
150
|
+
seedsApplied: plan.seeds.length
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { resolveMigrationPlan, buildSchema };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One owner for the sentence that says WHY a cookbook step failed.
|
|
5
|
+
*
|
|
6
|
+
* Two consumers read it and they must not drift apart:
|
|
7
|
+
* - CookbookTestRunner.executeStep — the per-step log line at boot;
|
|
8
|
+
* - ValidationOrchestrator.runCookbookTests — the step-4 error list that
|
|
9
|
+
* reaches `results.errors`, the wrapper's `Validation failed: …` message
|
|
10
|
+
* and validation.responses.
|
|
11
|
+
*
|
|
12
|
+
* Before 2026-08-29 neither of them said anything: the log line was
|
|
13
|
+
* `Step count_stub: FAILED (1101ms)` and the orchestrator's only error was
|
|
14
|
+
* `1 cookbook test(s) failed`. The reason existed the whole time in
|
|
15
|
+
* `result.validationErrors` / `result.error` and was discarded at both ends,
|
|
16
|
+
* so a failed biz-hello boot could not be diagnosed from its own logs
|
|
17
|
+
* (automation-gates.md §5 — silence is a defect).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why this step failed, in one line, from whichever field holds it.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} stepResult - a step result produced by CookbookTestRunner
|
|
24
|
+
* @returns {string} the concrete reason; never an empty string
|
|
25
|
+
*/
|
|
26
|
+
function describeStepFailure(stepResult) {
|
|
27
|
+
if (!stepResult || typeof stepResult !== 'object') {
|
|
28
|
+
throw new Error('[stepFailure] stepResult is required - Expected the step result object produced by CookbookTestRunner');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parts = [];
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(stepResult.validationErrors) && stepResult.validationErrors.length > 0) {
|
|
34
|
+
parts.push(stepResult.validationErrors.join('; '));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const error = stepResult.error;
|
|
38
|
+
if (typeof error === 'string' && error.length > 0) {
|
|
39
|
+
parts.push(error);
|
|
40
|
+
} else if (error && typeof error === 'object') {
|
|
41
|
+
parts.push(`${error.code || 'HANDLER_ERROR'}: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof stepResult.errorStack === 'string' && stepResult.errorStack.length > 0) {
|
|
45
|
+
parts.push(stepResult.errorStack);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (parts.length === 0) {
|
|
49
|
+
// A step marked failed with nothing recorded is itself a defect; say so
|
|
50
|
+
// rather than printing an empty reason that reads like "no problem".
|
|
51
|
+
return 'no reason recorded — neither validationErrors nor error was set on the step result';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parts.join(' | ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The same reason, prefixed with enough context to find the case: which
|
|
59
|
+
* cookbook, which step, which operation. This is what step 4 puts into
|
|
60
|
+
* `results.errors`.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} stepResult - a step result carrying `cookbook`
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function describeStepFailureWithContext(stepResult) {
|
|
66
|
+
const reason = describeStepFailure(stepResult);
|
|
67
|
+
const cookbook = stepResult.cookbook || 'unknown cookbook';
|
|
68
|
+
const stepId = stepResult.id || stepResult.step_id || 'unnamed step';
|
|
69
|
+
const operation = stepResult.operation ? ` (${stepResult.operation})` : '';
|
|
70
|
+
return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { describeStepFailure, describeStepFailureWithContext };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The tenant / workspace an integration test is allowed to write into.
|
|
5
|
+
*
|
|
6
|
+
* An integration test reaches a real database, so the namespace it targets is a
|
|
7
|
+
* safety boundary. Two incidents show what happens when that boundary is
|
|
8
|
+
* written per file instead of read from one source:
|
|
9
|
+
*
|
|
10
|
+
* biz-property and biz-converter overrode TESTING_TENANT_ID in their own env
|
|
11
|
+
* template to point at the LIVE tenant, because that is where their cookbook
|
|
12
|
+
* fixtures happened to exist. The Tier-1 runner executes real handler
|
|
13
|
+
* dispatch at every service start, production included — so every restart
|
|
14
|
+
* wrote into real data, and did.
|
|
15
|
+
*
|
|
16
|
+
* 24 of biz-property's 26 integration tests carried their own
|
|
17
|
+
* `const TENANT = 100` and inserted and deleted under it. Nothing could
|
|
18
|
+
* redirect them, because there was nothing to redirect.
|
|
19
|
+
*
|
|
20
|
+
* So the value comes from the platform env (`TESTING_TENANT_ID` /
|
|
21
|
+
* `TESTING_WORKSPACE_ID`, set in `config/env-templates/shared.env`) and this
|
|
22
|
+
* module is the only place that reads it. It throws when the env is absent
|
|
23
|
+
* rather than guessing: a test that does not know which namespace it owns must
|
|
24
|
+
* not write anywhere at all.
|
|
25
|
+
*
|
|
26
|
+
* Enforced by deploy-contract requirement R8 (see utils/deployContract.js).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The environment classes a validation or test run may write into.
|
|
31
|
+
*
|
|
32
|
+
* This is a WHITELIST, and that is the whole point. The previous rule refused
|
|
33
|
+
* the one tenant an env var declared live (`PLATFORM_LIVE_TENANT_ID=100`), which
|
|
34
|
+
* left every customer tenant — 101+, production, real data, 101 = Meditest —
|
|
35
|
+
* outside the guard, and the gap grew with every customer onboarded. Naming what
|
|
36
|
+
* is permitted cannot grow a gap: a class that is not on this list is refused
|
|
37
|
+
* whether or not anyone remembered to declare it.
|
|
38
|
+
*
|
|
39
|
+
* Why the values live in the library and not in the environment: they are a
|
|
40
|
+
* CONTRACT CONSTANT taken from a normative standard, not a deployment knob. The
|
|
41
|
+
* standard says WHAT each tenant id means (an environment class, allocated once,
|
|
42
|
+
* "Adding a class — do not"); this module is the HOW of enforcing it at run time.
|
|
43
|
+
* Architecture principle §2 (No Hardcoded Values) targets configuration of an
|
|
44
|
+
* environment — ports, hosts, timeouts, credentials — which differs per
|
|
45
|
+
* deployment by design. This boundary does not: it is identical in every
|
|
46
|
+
* environment because the norm defines it, and making it env-settable would hand
|
|
47
|
+
* the very misconfiguration this guard exists to stop a switch to turn it off.
|
|
48
|
+
*
|
|
49
|
+
* @see api/docs/standards/tenant-allocation.md § "The allocation"
|
|
50
|
+
*/
|
|
51
|
+
const ALLOWED_TENANT_CLASSES = Object.freeze([
|
|
52
|
+
96, // CI
|
|
53
|
+
97, // TESTING
|
|
54
|
+
98, // DEVEL
|
|
55
|
+
99 // VALIDATION
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function readNamespaceId(key) {
|
|
59
|
+
const raw = process.env[key];
|
|
60
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
61
|
+
throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
|
|
62
|
+
+ 'Fix: set it in config/env-active/shared.env (the platform default is the '
|
|
63
|
+
+ 'non-production namespace). An integration test may not pick a namespace itself.');
|
|
64
|
+
}
|
|
65
|
+
const value = Number(String(raw).trim());
|
|
66
|
+
if (!Number.isInteger(value)) {
|
|
67
|
+
throw new Error(`[TestNamespace] Invalid ${key}="${raw}" - Expected an integer id. `
|
|
68
|
+
+ 'Fix: correct the value in config/env-active/shared.env.');
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, so it can be
|
|
75
|
+
* spread straight into a handler call.
|
|
76
|
+
*/
|
|
77
|
+
function getTestNamespace() {
|
|
78
|
+
const tenantId = readNamespaceId('TESTING_TENANT_ID');
|
|
79
|
+
|
|
80
|
+
// The runtime half of the protection, and the reason the tenant is decided
|
|
81
|
+
// before anything else is read. R8 rejects a per-service override before it
|
|
82
|
+
// ships; this refuses the accident that ships anyway, so a startup probe can
|
|
83
|
+
// never reach real data even if the configuration is wrong.
|
|
84
|
+
//
|
|
85
|
+
// The workspace id is deliberately NOT class-checked: tenant-allocation.md
|
|
86
|
+
// allocates environment classes to TENANTS, and calls a workspace "the purpose
|
|
87
|
+
// within the class" without naming any range for it. A restriction here would
|
|
88
|
+
// be one this library invented.
|
|
89
|
+
if (!ALLOWED_TENANT_CLASSES.includes(tenantId)) {
|
|
90
|
+
throw new Error(`[TestNamespace] TESTING_TENANT_ID=${tenantId} is not an allowed `
|
|
91
|
+
+ 'environment class - test and validation runs may never write into production data '
|
|
92
|
+
+ '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
|
|
93
|
+
+ '97 (TESTING), 98 (DEVEL), 99 (VALIDATION). Fix: set TESTING_TENANT_ID to one of '
|
|
94
|
+
+ 'those classes in config/env-active/shared.env and seed that namespace from '
|
|
95
|
+
+ 'migrations/ - see api/docs/standards/tenant-allocation.md.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
tenant_id: tenantId,
|
|
100
|
+
workspace_id: readNamespaceId('TESTING_WORKSPACE_ID')
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { getTestNamespace };
|
|
@@ -37,6 +37,184 @@ function hasConfigDir(root) {
|
|
|
37
37
|
fs.existsSync(path.join(root, 'conn-config'));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// v1.2 — business-error CONTRACT (DÁVKA 50)
|
|
43
|
+
//
|
|
44
|
+
// Owner decision: api/docs/governance/confirmations/business-error-contract.md
|
|
45
|
+
// (20260829-1500-business-error-contract-001, CONFIRMED).
|
|
46
|
+
//
|
|
47
|
+
// v1.2 used to ask "does the service depend on @onlineapps/service-common and
|
|
48
|
+
// import an error class FROM IT?". Both retired checks (`dep_service_common`,
|
|
49
|
+
// `business_error_usage`) demanded the defeated hierarchy, so a service that had
|
|
50
|
+
// already migrated to the wrapper's classes failed the gate for being correct.
|
|
51
|
+
//
|
|
52
|
+
// The question is now the contract, which survives the package moving: does the
|
|
53
|
+
// service raise errors that the wrapper's ErrorMapper will translate — i.e. a
|
|
54
|
+
// BusinessError family from @onlineapps/service-wrapper, or its own class
|
|
55
|
+
// declaring the brand `onlineapps.businessError`?
|
|
56
|
+
//
|
|
57
|
+
// Scope is ANY file under src/, not "a handler or a src/lib/ module a handler
|
|
58
|
+
// requires" — one rule, one sentence (automation-gates.md §1 requirement 2,
|
|
59
|
+
// Simple). A static check cannot prove a `throw` reaches a handler anyway; it
|
|
60
|
+
// can only see the declaration. The narrower rule was measured on 2026-08-29
|
|
61
|
+
// and failed biz-converter and biz-ingest, both of which declare the contract in
|
|
62
|
+
// src/lib/ and reach it through src/services/. Lead decision, same day.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Names exported by the wrapper's error module that satisfy the contract. */
|
|
66
|
+
const WRAPPER_ERROR_NAMES = [
|
|
67
|
+
'BusinessError',
|
|
68
|
+
'ValidationError',
|
|
69
|
+
'NotFoundError',
|
|
70
|
+
'ConflictError',
|
|
71
|
+
'BusinessRuleError',
|
|
72
|
+
'AuthorizationError',
|
|
73
|
+
'InvalidEnvelopeError',
|
|
74
|
+
'BUSINESS_ERROR_BRAND'
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The RETIRED hierarchy: everything @onlineapps/service-common exports from
|
|
79
|
+
* src/errors/BusinessError.js. Deliberately NOT every error class in that
|
|
80
|
+
* package — `ScopedRegistryError` belongs to the live scoped-registry helper and
|
|
81
|
+
* is not part of what DÁVKA 50 retires.
|
|
82
|
+
*/
|
|
83
|
+
const RETIRED_ERROR_NAMES = [
|
|
84
|
+
'BusinessError',
|
|
85
|
+
'NotFoundError',
|
|
86
|
+
'ValidationError',
|
|
87
|
+
'ConflictError',
|
|
88
|
+
'BusinessRuleError',
|
|
89
|
+
'AuthorizationError',
|
|
90
|
+
'ServiceUnavailableError',
|
|
91
|
+
'isBusinessError',
|
|
92
|
+
'ERROR_TYPES',
|
|
93
|
+
'businessErrorHandler'
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
const BUSINESS_ERROR_BRAND_TOKEN = 'onlineapps.businessError';
|
|
97
|
+
|
|
98
|
+
function requirePattern(pkg) {
|
|
99
|
+
return new RegExp(`require\\(\\s*['"]${pkg.replace('/', '\\/')}['"]\\s*\\)`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Collect every .js file under `dir`, as paths relative to `root`. */
|
|
103
|
+
function listJsFiles(root, dir) {
|
|
104
|
+
const out = [];
|
|
105
|
+
if (!fs.existsSync(dir)) return out;
|
|
106
|
+
const walk = (d) => {
|
|
107
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
108
|
+
const p = path.join(d, entry.name);
|
|
109
|
+
if (entry.isDirectory()) walk(p);
|
|
110
|
+
else if (entry.isFile() && entry.name.endsWith('.js')) out.push(path.relative(root, p));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
try { walk(dir); } catch { /* unreadable tree — callers treat as empty */ }
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readFile(root, rel) {
|
|
118
|
+
try { return fs.readFileSync(path.join(root, rel), 'utf-8'); } catch { return null; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Names destructured from `require('<pkg>')` in `content`, across every such
|
|
123
|
+
* statement (single-line or multi-line).
|
|
124
|
+
*
|
|
125
|
+
* The body is `[^{}]*?`, NOT `[\s\S]*?`: a lazy any-character body starts at the
|
|
126
|
+
* nearest preceding `const {` and swallows whole lines to reach the right
|
|
127
|
+
* `require`, which dropped the first imported name. Measured on
|
|
128
|
+
* `api_biz/meta/src/handlers/persons.js:10-11`, where an unrelated `models`
|
|
129
|
+
* destructuring sits directly above the service-common one.
|
|
130
|
+
*
|
|
131
|
+
* @returns {string[]}
|
|
132
|
+
*/
|
|
133
|
+
function destructuredFrom(content, pkg) {
|
|
134
|
+
const re = new RegExp(
|
|
135
|
+
`(?:const|let|var)\\s*\\{([^{}]*?)\\}\\s*=\\s*require\\(\\s*['"]${pkg.replace('/', '\\/')}['"]\\s*\\)`,
|
|
136
|
+
'g'
|
|
137
|
+
);
|
|
138
|
+
const names = [];
|
|
139
|
+
let m;
|
|
140
|
+
while ((m = re.exec(content)) !== null) {
|
|
141
|
+
for (const raw of m[1].split(',')) {
|
|
142
|
+
const name = raw.split(':')[0].trim();
|
|
143
|
+
if (name) names.push(name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return names;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Does this file satisfy the business-error contract? */
|
|
150
|
+
function declaresBusinessErrorContract(content) {
|
|
151
|
+
if (content.includes(BUSINESS_ERROR_BRAND_TOKEN)) return true;
|
|
152
|
+
if (!requirePattern('@onlineapps/service-wrapper').test(content)) return false;
|
|
153
|
+
return destructuredFrom(content, '@onlineapps/service-wrapper')
|
|
154
|
+
.some(name => WRAPPER_ERROR_NAMES.includes(name));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* v1.2 checks: the business-error contract is declared somewhere under src/, and
|
|
159
|
+
* the retired hierarchy is gone from src/ entirely.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} root - Service root
|
|
162
|
+
* @returns {Array<{ passed: boolean, id: string, message: string, fix?: string }>}
|
|
163
|
+
*/
|
|
164
|
+
function businessErrorChecks(root) {
|
|
165
|
+
const srcFiles = listJsFiles(root, path.join(root, 'src'));
|
|
166
|
+
|
|
167
|
+
let contractFile = null;
|
|
168
|
+
for (const rel of srcFiles) {
|
|
169
|
+
const content = readFile(root, rel);
|
|
170
|
+
if (content && declaresBusinessErrorContract(content)) { contractFile = rel; break; }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const contractCheck = contractFile
|
|
174
|
+
? {
|
|
175
|
+
passed: true,
|
|
176
|
+
id: 'business_error_contract',
|
|
177
|
+
message: `Business-error contract declared in ${contractFile}`
|
|
178
|
+
}
|
|
179
|
+
: {
|
|
180
|
+
passed: false,
|
|
181
|
+
id: 'business_error_contract',
|
|
182
|
+
message: '[ServiceStructureValidator] no business-error contract declared - ' +
|
|
183
|
+
'nothing under src/ imports a BusinessError family from @onlineapps/service-wrapper ' +
|
|
184
|
+
'or declares the brand ' + BUSINESS_ERROR_BRAND_TOKEN,
|
|
185
|
+
fix: 'Throw a contract-carrying error: ' +
|
|
186
|
+
"const { ValidationError } = require('@onlineapps/service-wrapper'); " +
|
|
187
|
+
`— or declare Symbol.for('${BUSINESS_ERROR_BRAND_TOKEN}') on the service's own error class.`
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const offenders = [];
|
|
191
|
+
for (const rel of listJsFiles(root, path.join(root, 'src'))) {
|
|
192
|
+
const content = readFile(root, rel);
|
|
193
|
+
if (!content) continue;
|
|
194
|
+
const names = destructuredFrom(content, '@onlineapps/service-common')
|
|
195
|
+
.filter(name => RETIRED_ERROR_NAMES.includes(name));
|
|
196
|
+
if (names.length > 0) offenders.push({ file: rel, names });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const retiredCheck = offenders.length === 0
|
|
200
|
+
? {
|
|
201
|
+
passed: true,
|
|
202
|
+
id: 'no_retired_error_hierarchy',
|
|
203
|
+
message: 'No @onlineapps/service-common error-hierarchy import under src/'
|
|
204
|
+
}
|
|
205
|
+
: {
|
|
206
|
+
passed: false,
|
|
207
|
+
id: 'no_retired_error_hierarchy',
|
|
208
|
+
message: '[ServiceStructureValidator] retired error hierarchy imported from @onlineapps/service-common - ' +
|
|
209
|
+
offenders.map(o => `${o.file} imports ${o.names.join(', ')}`).join('; '),
|
|
210
|
+
fix: offenders
|
|
211
|
+
.map(o => `Replace in ${o.file} with: const { ${o.names.join(', ')} } = require('@onlineapps/service-wrapper');`)
|
|
212
|
+
.join(' ')
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
return [contractCheck, retiredCheck];
|
|
216
|
+
}
|
|
217
|
+
|
|
40
218
|
const STANDARD_LEVELS = [
|
|
41
219
|
{
|
|
42
220
|
level: 'v1.0',
|
|
@@ -111,47 +289,7 @@ const STANDARD_LEVELS = [
|
|
|
111
289
|
level: 'v1.2',
|
|
112
290
|
name: 'Business Error Handling Standard',
|
|
113
291
|
since: '2026-03-24',
|
|
114
|
-
checks: (root) =>
|
|
115
|
-
const read = (p) => { try { return fs.readFileSync(path.join(root, p), 'utf-8'); } catch { return null; } };
|
|
116
|
-
|
|
117
|
-
const pkgRaw = read('package.json');
|
|
118
|
-
let hasServiceCommon = false;
|
|
119
|
-
if (pkgRaw) {
|
|
120
|
-
try {
|
|
121
|
-
const pkg = JSON.parse(pkgRaw);
|
|
122
|
-
hasServiceCommon = !!pkg.dependencies?.['@onlineapps/service-common'];
|
|
123
|
-
} catch { /* invalid JSON */ }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ADR 0005 / F6: businessErrorHandler was Express middleware in
|
|
127
|
-
// src/app.js. Post-F6 there is no src/app.js. Error handling now
|
|
128
|
-
// happens via BusinessError thrown from handlers → wrapper's
|
|
129
|
-
// ErrorMapper. Check: at least one handler imports a
|
|
130
|
-
// BusinessError-family class from @onlineapps/service-common.
|
|
131
|
-
const handlersDir = path.join(root, 'src', 'handlers');
|
|
132
|
-
let hasBusinessErrorUsage = false;
|
|
133
|
-
if (fs.existsSync(handlersDir)) {
|
|
134
|
-
const walk = (dir) => {
|
|
135
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
136
|
-
const p = path.join(dir, entry.name);
|
|
137
|
-
if (entry.isDirectory()) walk(p);
|
|
138
|
-
else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
139
|
-
const content = read(path.relative(root, p));
|
|
140
|
-
if (content && /@onlineapps\/service-common/.test(content) &&
|
|
141
|
-
/BusinessError|ValidationError|NotFoundError|ConflictError|ForbiddenError/.test(content)) {
|
|
142
|
-
hasBusinessErrorUsage = true;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
try { walk(handlersDir); } catch { /* ignore */ }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return [
|
|
151
|
-
{ passed: hasServiceCommon, id: 'dep_service_common', message: '@onlineapps/service-common dependency' },
|
|
152
|
-
{ passed: hasBusinessErrorUsage, id: 'business_error_usage', message: 'At least one handler throws a BusinessError family from @onlineapps/service-common' }
|
|
153
|
-
];
|
|
154
|
-
}
|
|
292
|
+
checks: (root) => businessErrorChecks(root)
|
|
155
293
|
},
|
|
156
294
|
{
|
|
157
295
|
level: 'v1.3',
|
|
@@ -272,12 +410,18 @@ class ServiceStructureValidator {
|
|
|
272
410
|
if (nextLevel) {
|
|
273
411
|
const failing = nextLevel.checks.filter(c => !c.passed);
|
|
274
412
|
for (const check of failing) {
|
|
413
|
+
// A check that carries its own `fix` has already written a
|
|
414
|
+
// `[Context] Problem - Expected/Fix` message naming the offending file;
|
|
415
|
+
// wrapping it in "missing …" would only bury the finding.
|
|
275
416
|
this.warnings.push({
|
|
276
417
|
type: 'STANDARD_LEVEL_GAP',
|
|
277
418
|
level: nextLevel.level,
|
|
278
419
|
check: check.id,
|
|
279
|
-
message:
|
|
280
|
-
|
|
420
|
+
message: check.fix
|
|
421
|
+
? `Standard ${nextLevel.level} (${nextLevel.name}): ${check.message}`
|
|
422
|
+
: `Standard ${nextLevel.level} (${nextLevel.name}): missing ${check.message}`,
|
|
423
|
+
fix: check.fix
|
|
424
|
+
|| `Implement ${check.message} to reach standard ${nextLevel.level}. See docs/biz/60-templates/service-template.md`
|
|
281
425
|
});
|
|
282
426
|
}
|
|
283
427
|
}
|
|
@@ -465,7 +609,8 @@ class ServiceStructureValidator {
|
|
|
465
609
|
|
|
466
610
|
/**
|
|
467
611
|
* Validate operations.json structure (v3 — handler registry dispatch).
|
|
468
|
-
*
|
|
612
|
+
*
|
|
613
|
+
* @see api/docs/biz/30-operations/schema-v3.md § File shape
|
|
469
614
|
*/
|
|
470
615
|
validateOperationsStructure(operations) {
|
|
471
616
|
if (!operations.operations) {
|
|
@@ -473,7 +618,7 @@ class ServiceStructureValidator {
|
|
|
473
618
|
type: 'INVALID_OPERATIONS_STRUCTURE',
|
|
474
619
|
path: 'config/service/operations.json',
|
|
475
620
|
message: 'operations.json must have "operations" key',
|
|
476
|
-
fix: 'Wrap operations in {"operations": {...}}
|
|
621
|
+
fix: 'Wrap operations in {"operations": {...}} in config/service/operations.json'
|
|
477
622
|
});
|
|
478
623
|
return;
|
|
479
624
|
}
|
|
@@ -496,7 +641,7 @@ class ServiceStructureValidator {
|
|
|
496
641
|
field: 'schema_version',
|
|
497
642
|
value: operations.schema_version,
|
|
498
643
|
message: `operations.json schema_version is "${operations.schema_version}" — expected "3.0"`,
|
|
499
|
-
fix: '
|
|
644
|
+
fix: 'Set schema_version to "3.0" in config/service/operations.json'
|
|
500
645
|
});
|
|
501
646
|
}
|
|
502
647
|
|
|
@@ -518,6 +663,8 @@ class ServiceStructureValidator {
|
|
|
518
663
|
/**
|
|
519
664
|
* Validate single operation structure (v3).
|
|
520
665
|
* Required: handler, bundle_scope. Forbidden (v2): endpoint, method, path.
|
|
666
|
+
*
|
|
667
|
+
* @see api/docs/biz/30-operations/schema-v3.md § Per-operation keys
|
|
521
668
|
*/
|
|
522
669
|
validateOperation(name, spec) {
|
|
523
670
|
const requiredFields = ['handler', 'bundle_scope'];
|
|
@@ -530,7 +677,7 @@ class ServiceStructureValidator {
|
|
|
530
677
|
operation: name,
|
|
531
678
|
field,
|
|
532
679
|
message: `Operation "${name}" missing required field: ${field}`,
|
|
533
|
-
fix: `Add "${field}" to operation "${name}"
|
|
680
|
+
fix: `Add "${field}" to operation "${name}" in config/service/operations.json`
|
|
534
681
|
});
|
|
535
682
|
}
|
|
536
683
|
}
|
|
@@ -571,7 +718,7 @@ class ServiceStructureValidator {
|
|
|
571
718
|
field: forbidden,
|
|
572
719
|
value: spec[forbidden],
|
|
573
720
|
message: `Operation "${name}" has retired v2 field "${forbidden}" — not allowed in v3 schema`,
|
|
574
|
-
fix: `Remove "${forbidden}" — v3 dispatches via handler registry
|
|
721
|
+
fix: `Remove "${forbidden}" from operation "${name}" in config/service/operations.json — v3 dispatches via the handler registry, not by URL`
|
|
575
722
|
});
|
|
576
723
|
}
|
|
577
724
|
}
|
package/src/config.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Runtime configuration schema for @onlineapps/conn-orch-validator.
|
|
5
|
-
*
|
|
6
|
-
* Uses @onlineapps/runtime-config for unified priority:
|
|
7
|
-
* 1. Explicit config (passed to ValidationOrchestrator/readiness options)
|
|
8
|
-
* 2. Environment variable
|
|
9
|
-
* 3. Module-owned defaults (none for topology)
|
|
10
|
-
*
|
|
11
|
-
* IMPORTANT: Integration test topology is FAIL-FAST (no defaults).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const { createRuntimeConfig } = require('@onlineapps/runtime-config');
|
|
15
|
-
const DEFAULTS = require('./defaults');
|
|
16
|
-
|
|
17
|
-
const runtimeCfg = createRuntimeConfig({
|
|
18
|
-
defaults: DEFAULTS,
|
|
19
|
-
schema: {
|
|
20
|
-
serviceUrl: { env: 'TEST_SERVICE_URL', required: true },
|
|
21
|
-
mqUrl: { env: 'TEST_MQ_URL', required: true },
|
|
22
|
-
registryUrl: { env: 'TEST_REGISTRY_URL', required: true },
|
|
23
|
-
storageUrl: { env: 'TEST_STORAGE_URL', required: true },
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
module.exports = runtimeCfg;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|