SvEx. Config
(SvEx v0.4.2)
The whole scaffolder config for one project, as a struct — one child struct
per section of the templates in priv/templates.
Both project types are this same struct. project.type is a field, not a
separate shape, because the two templates differ in exactly four values and
nothing else: project.type, assets.type, and the two assets.ui flags.
Modelling them as two structs would duplicate twenty-five identical fields to
express four.
The two are reached through their presets, which correspond 1:1 to the template files:
SvEx.Config.Otp.new!() # priv/templates/base_otp_app.exs
SvEx.Config.Web.new!() # priv/templates/base_web_app.exsnew!/1 here is the unpresetted form, taking every section verbatim. Prefer
a preset unless you genuinely mean "no project type chosen for me".
Canonical names
The names below are canonical. The retired scaffolder skills reached the
same switches under older spellings, and the templates used to carry three
"reconcile before anything reads this" markers; this is the reconciliation,
and the older spellings are retired rather than aliased. Nothing accepts
them as input — target.exs carries the canonical names, and this is the
table any older spelling translates to.
| Canonical | Retired spelling | Notes |
|---|---|---|
project.github_org | github.org | Sectioned in TOML, one flat atom here |
auth.local | options.local_auth | |
auth.oidc | options.oidc_auth | Forces auth.local |
auth.saml | options.saml_auth | Forces auth.local |
container.compose | options.podman, options.docker | Two booleans collapse to one tri-state |
container.compose is the only one that changes shape rather than just
spelling. Two independent booleans admit docker: true, podman: false,
which is not a thing a project can be; [:none, :podman, :docker] is
ordered, so :docker outranks :podman and the impossible state is
unrepresentable.
Why this exists
The templates document five rules and enforce none of them, saying outright that keeping them in step "is on you". Three are implications and two are hard constraints, and nothing that reads a keyword list can tell the difference. Here they are code.
Construction runs in the order the templates specify — implications expanded before validation:
otp.repoforcesotp.supervisor(a repo is a supervised process)auth.oidcorauth.samlforcesauth.local(both issue app tokens through that path)cache.backend: :valkeyforcescontainer.sidecars.valkey, andsecurity.openbaoforcescontainer.sidecars.openbao- any sidecar forces
container.composeto at least:podman
Then, and only then, the two hard constraints raise
SvEx.Config.InvalidError: any auth method requires project.type: :web, and assets.type of :api or :both requires it too.
Step 3 must precede step 4, or a :valkey backend would leave compose at
:none — a cache with nothing to run it in.
Consequence
A constructed %SvEx.Config{} is always self-consistent, so no caller
ever re-derives an implication. config.otp.supervisor is simply correct.
iex> config = SvEx.Config.new!(otp: [repo: true, supervisor: false])
iex> config.otp.supervisor
trueUndeclared keys raise rather than being ignored, which is what makes a mistyped flag a failure instead of a silent default.
Reading target.exs
read!/1 is the other way in. It reads a project's hand-authored
target.exs — the four-key slim shape of SDD 8.1 — validates the file,
then resolves it through the preset its base: names. What comes back is
the same fully-expanded struct every other caller sees, not a transcription
of the file: the file supplies four of project's six values and the preset
supplies the rest. base: is NOT project.type — it names a preset. :otp
and :web happen to resolve to the project.type of the same name; :api
resolves to project.type: :web with assets.type: :api, which is what a
preset is for. There is deliberately no second struct for the literal file,
because there would be nothing for it to express.
Validation happens at two layers, and they are not competitors. This layer
validates a file — keys, types, formats — before any struct exists to hang
a SvEx.Vendor.Vex declaration off. The sections validate the resolved config. Both
raise SvEx.Config.InvalidError, so a caller sees one exception type.
Three asymmetries between the file and this struct are deliberate:
schema_versionsits at the top level of the file and atproject.schema_versionhere, which is where both templates already put it.plugins:is version-conditional. Inschema_version: 1it must be[]— the only legal value there, so the key carries no information. Version 2 accepts entries in three shapes, mirroring mix's dep syntax and SDD 8.1's own sketch:plugins: [ {:cache, path: "priv/meta/meta_cache"}, {:valkey, "~> 1.3"}, {"acme/audit_log", "~> 0.2"} ]Both versions VALIDATE and then DROP the list. Nothing in this package consumes a declaration yet — D7's update merge is what will — and a resolved config carrying a field neither template has would promise something nothing delivers. The file is the record; this reader's job is to refuse a malformed one, loudly, at the boundary.
A version BUMP rather than a relaxation of version 1, because the change is backwards-compatible in one direction only. An older SvEx reading a version 2 file reports "schema_version 2 is not supported" — a clear statement about the reader. Relaxing version 1 would instead have it report a confusing error about plugins, on a file it has no business reading.
The file's
project:must be a keyword list, whilenew!/1accepts a map as readily. The file is hand-authored and 8.1's only specimen is a keyword list, with ordering semantics a map does not share; the in-memory constructor has no literal to be faithful to.
Every key of the file is required and unknown keys are rejected rather than ignored: a silently-ignored key reads to the author as "applied".
The implication rules above cannot vary with the file. Nothing in
target.exs reaches otp.repo, auth.*, cache.backend or
security.openbao, so with plugins: dropped every field they read is fixed
at its preset value and they resolve identically for every file this reader
accepts. They stay because new!/1 is a public entry point too.
Summary
Functions
Resolves already-read target.exs source. file names it in errors only.
Builds this section, and any nested block, from input.
The absolute path to target's target.exs.
Reads and resolves target's target.exs.
Types
@type t() :: %SvEx.Config{ assets: SvEx.Config.Assets.t(), auth: SvEx.Config.Authn.t(), cache: SvEx.Config.Cache.t(), container: SvEx.Config.Container.t(), features: SvEx.Config.Features.t(), otp: SvEx.Config.Supervision.t(), project: SvEx.Config.Project.t(), security: SvEx.Config.Security.t() }
Functions
Resolves already-read target.exs source. file names it in errors only.
Builds this section, and any nested block, from input.
@spec path(SvEx.Root.t()) :: Path.t()
The absolute path to target's target.exs.
There is no filename/0 beside it: a public accessor over a module
attribute has no caller that path/1 does not serve better.
@spec read!(SvEx.Root.t()) :: t()
Reads and resolves target's target.exs.
Raises File.Error if the file is absent, SvEx.Source.DecodeError if it
is not plain data, and SvEx.Config.InvalidError if it is plain data of
the wrong shape. The target's mix.exs is never consulted, so this works
against a project that does not compile.