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.exs

new!/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.

CanonicalRetired spellingNotes
project.github_orggithub.orgSectioned in TOML, one flat atom here
auth.localoptions.local_auth
auth.oidcoptions.oidc_authForces auth.local
auth.samloptions.saml_authForces auth.local
container.composeoptions.podman, options.dockerTwo 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:

  1. otp.repo forces otp.supervisor (a repo is a supervised process)
  2. auth.oidc or auth.saml forces auth.local (both issue app tokens through that path)
  3. cache.backend: :valkey forces container.sidecars.valkey, and security.openbao forces container.sidecars.openbao
  4. any sidecar forces container.compose to 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
true

Undeclared 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_version sits at the top level of the file and at project.schema_version here, which is where both templates already put it.

  • plugins: is version-conditional. In schema_version: 1 it 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, while new!/1 accepts 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

t()

@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

decode!(source, file)

@spec decode!(binary(), Path.t()) :: t()

Resolves already-read target.exs source. file names it in errors only.

new!(input \\ [])

@spec new!(keyword() | map()) :: t()

Builds this section, and any nested block, from input.

path(target)

@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.

read!(target)

@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.

valid?(self)