I'm publishing this series ahead of Code BEAM Europe this October, where I'll reveal Bondy, a new declarative language for the BEAM: statically typed, functional, relational and rule-based.
An Erlang application describes itself in several files. The .app.src says what the application is. rebar.config says what it depends on. sys.config and vm.args say how a release runs it. Nothing checks that the values in sys.config are the ones the application expects. The only description of the configuration an application accepts is the code that reads it.
Bondy puts all of this in one language, the Bondy Config Language (BCL). Apart from its imports, a BCL document holds three things: type definitions, let bindings for reuse, and top-level bindings, which are the document's output. The types are Bondy shapes, and & is the same unification that validates data in Bondy code.[1] So a configuration file has a type, and the compiler checks the file against it.
Three tiers, three types
Each tier has a type in the standard library. Library.Manifest describes a library: an OTP application with modules and no supervision tree. Application.Manifest adds the entry point of the supervision tree and the application environment. Release puts applications together with flags for the virtual machine and a system configuration.
A project has a manifest.bcl at its root, and its manifest binding unifies with one of the two manifest types. Here is the manifest of shop:
%% manifest.bcl
import Application
public type Config = {
port?: Int & > 0 & < 65536 = 8080,
pool_size?: Int & > 0 = 10,
timeout_ms?: Int = .pool_size * 1000
}
manifest: Application.Manifest & {
project: {
name: :shop,
version: "0.1.0",
description: "An online shop"
},
start_module: Shop.App.__MODULE__,
dependencies: {
cowboy: { version: "~> 2.20" }
},
extra_applications: [:crypto],
env: Config
}Config is the application environment: every field shop reads, with its type and its default. The ? marks a field as optional, and = 8080 is its default. A default can read another field of the same shape as .field, so timeout_ms follows pool_size.
The toolchain generates the .app.src from this document:
{application,shop, [
{description,"An online shop"},
{vsn,"0.1.0"},
{registered,[]},
{mod,{'Bondy.Shop.App',[]}},
{applications,[bondy,cowboy,crypto,kernel,stdlib]},
{env,[{port,8080},{timeout_ms,10000},{pool_size,10}]},
{modules,[]},
{licenses,[]}
]}.You don't write the applications list. The toolchain derives it from kernel, stdlib and bondy, the extra_applications, and the name of each dependency. The env list holds the defaults of Config.
A manifest that is wrong does not build
The types in the standard library say what a manifest must hold. In Library.Project, name and version are required, and version must match a semantic version pattern. Application.Manifest requires start_module. Leave it out, and the manifest does not finalize:
Cannot build a complete Application.Manifest: field `start_module` is required and was not givenWrite version: "one", and the error names the pattern and the path to the field:
Value "one" does not match pattern "^[0-9]+\.[0-9]+\.[0-9]+"Library.Manifest is the same type without start_module, env and start_phases. A library has no supervision tree, so its .app.src has no mod entry. It can still declare a Config type for the applications that use it.
The manifest is a module
The toolchain compiles each manifest.bcl into a Bondy module. Its name comes from the application name, one segment for each part between underscores: :shop gives Shop.Manifest, and :acme_pricing gives Acme.Pricing.Manifest. The build compiles this module first, into the application's own ebin. Then it compiles the application's code, which can import the types of its manifest:
module Shop.Settings
import Shop.Manifest.{type Config}
public function defaults() :: Config ->
Config()
public function tuned() :: Config ->
Config(pool_size: 20)Config() constructs the value with every default: port 8080, a pool of 10 and a timeout of 10000. Config(pool_size: 20) gives a timeout of 20000, because the default of timeout_ms reads pool_size. The type in the manifest is the type in the code. There is no second definition to keep in step with the first.
The order of the build sets one rule. The manifest compiles before the rest of its application, so it must not name a module of that application. The build refuses such a reference by name:
manifest.bcl names Shop.Settings, a module of its own application; a manifest is compiled before its application, so declare the type in the manifestWithout the rule, an incremental build would find the module from the last build, and a clean build would fail. With it, an incremental build fails too, so the result does not depend on what is left in _build. On a clean build, the reference fails as an unknown module.
What a document is allowed to do
A BCL document can call functions of the Bondy standard library, so it can compute a value. The compiler checks the document as one unit, with one allowance: reading environment variables.[2] The compiler refuses a call that needs any other capability. This document builds:
cfg: {
host: System.get_env("SHOP_DB_HOST", "localhost"),
name: String.uppercase("shop")
}With SHOP_DB_HOST unset, host is "localhost". With SHOP_DB_HOST=db1.internal, it is "db1.internal". The compiler refuses a field that calls File.read/1, and the call never runs:
Function cfg/0 requires capabilities not permitted in a safe-mode unit. Its capability set must be within the unit's allowance (the capabilities the host granted this unit). Route effectful work through a granted capability.So a dependency's manifest, which your build loads, can't read your files, write to a table or create atoms.
A release imports what it configures
A release lives in releases/<name>.bcl. It names the applications to include, the flags for the virtual machine and the system configuration. The section for each application can use that application's Config, because the release imports the manifest module like any other module:
%% releases/shop.bcl
import Release
import Shop.Manifest
release: Release & {
applications: [:shop],
vm: VM & {
name: System.get_env("NODE_NAME", "shop@127.0.0.1"),
async_threads: 64
},
sys_config: {
kernel: { logger_level: :warning },
shop: Shop.Manifest.Config & { port: 80 }
}
}The release sets only the port. Unification with Config supplies the other fields from their defaults, and the toolchain writes the result as sys.config:
[
{kernel, [
{logger_level, warning}
]},
{shop, [
{port, 80},
{timeout_ms, 10000},
{pool_size, 10}
]}
].It writes the vm section as vm.args:
-name shop@127.0.0.1
+A 64Give the port as 70000, and the release does not compile. The compiler sees that no value satisfies both the release and the type:
this intersection: `Config & {port: 70000, ...Any}` denotes no value: no value is a member of every conjunctThe team that writes the release and the team that writes shop can be different. The type is the contract between them, and the compiler checks it.
Environments patch the release
Values that change between environments live in config/. config/base.bcl patches every environment, and config/<env>.bcl patches one:
%% config/prod.bcl
release: {
vm: {
name: System.get_env("NODE_NAME", "shop@10.0.0.5")
},
sys_config: {
kernel: { logger_level: :warning },
shop: { pool_size: 50 }
}
}bondy config generate --env prod applies base.bcl and then prod.bcl to the release. A patch overrides values. It does not unify with them, because with & a production value that differs from the release's would denote no value. Maps merge, and the patch replaces any other value.
Two consequences follow. The patch applies to the release's finished values, so the patched pool_size of 50 leaves timeout_ms at 10000. The default that reads pool_size does not run again. Also, the toolchain does not check a patch against Config when it generates the files. A patch that sets the port to 70000 produces a sys.config with port 70000.
The application can check its application environment when it starts:
module Shop.App implements Application(State = Null)
import Shop.Manifest.{type Config}
type State = Null
public function start(_start_type: Application.StartType, _args: Any) :: Application.StartResult ->
let Ok(config) = Application.validate_env(:shop, Config)
Shop.Sup.start_link(config)
public function stop(_state: State) :: Atom ->
:okApplication.validate_env/2 unifies the application environment with Config and adds the defaults. With port 70000, it returns an error. The match on Ok(config) fails, start/2 crashes, and shop does not start.
One type, four readers
Follow Config through the project. The manifest declares it, and the .app.src holds its defaults. The application's code imports it from Shop.Manifest. A release imports it from the same module, and the compiler checks the release against it. At start, the application checks its application environment against it again.
In an Erlang project, these are four places that each hold their own copy of the facts. In a Bondy project, they read one type.
The type that validates explains shapes and unification in Bondy code. ↩︎
What a function is allowed to do explains how the compiler works out what a function can do. ↩︎