Skip to content

feat(dx): human-authorable configs — idiomatic adopt output, dynamic sugar, located errors, machine-only state, blueprints by default #52

Description

@2000game

Bootstrapping the real Equippers instance (eqrm/ct-structure) showed the authored config is hard to read and maintain:

  • ct.config.ts is repetitive: 35 near-identical ct.group({...}) calls; the Mainz Kids subtree alone is ~20 hand-written declarations differing only in key + name.
  • Numeric ids leak: all 35 group declarations carry a raw groupStatusId (33× 1, 2× 4). Note: the DSL already supports status: "<name>" — ID_SUGAR in src/config/context.ts and the resolver's group-status → /group/memberstatus catalog exist since feat: portable configs — logical references instead of numeric CT ids (shared resolver) #20. The gap is that ct adopt emits numeric ids and the real config was never migrated.
  • Dynamic membership is manual boilerplate: every auto-group repeats dynamic: { status: "active", ruleset: { ref: "./rulesets/<key>.json" } }.
  • ct-state.json churns: ~604 lines for ~48 resources; upsertResource (src/state/state.ts) bumps updatedAt unconditionally, so unrelated lines change on every apply and the file is unreviewable.
  • ct adopt emits raw one-liners: configSnippet() (src/resources/registry.ts:158) is a single-line JSON.stringify with numeric ids — not paste-ready config.

Goal: author ChurchTools structure as naturally as Terraform HCL — declarative, DRY, readable — with machine state out of the human's way.

Decided approach — ordered work items

  • A. ct adopt emits idiomatic config. Rework configSnippet(): multi-line, prettier-compatible formatting; reverse-resolve campusId/groupTypeId/groupStatusId to the logical campus:/groupType:/status: sugar via the shared resolver catalogs; when adopting several resources (bulk adopt, feat(adopt): bulk/filtered adoption + auto-capture dynamic rulesets + warn on unknown declaration fields #51), group output by type with a comment header. Acceptance: adopt a live group, paste the snippet verbatim, ct plan is a no-op — zero hand edits.
  • B. Dynamic sugar. dynamic: true desugars to { status: "active", ruleset: { ref: "./rulesets/<key>.json" } }; dynamic: "<path>.json" desugars with an explicit path. Pure eval-time desugaring in src/config/context.ts; engine untouched.
  • C. Located validation errors. Capture the call site (new Error().stack, first user-config frame) in each ConfigContext declaration; eval-time validation errors and unknown-field warnings report file:line + resource key + field (e.g. ct.config.ts:42 — group "kids_planeten": unknown field "shortName"). The unknown-field warning itself is feat(adopt): bulk/filtered adoption + auto-capture dynamic rulesets + warn on unknown declaration fields #51's item 3; this issue supplies the location machinery.
  • D. State is machine-only (decision: adopted). (1) upsertResource bumps updatedAt only when fields actually changed (deep-equal guard); adoptedAt never changes. (2) In ct-structure, mark ct-state*.json with linguist-generated=true in .gitattributes so GitHub collapses it; humans review ct plan output (PR comment, feat: GitOps loop — plan on PR, gated apply, scheduled drift detection #24), never the state file.
  • E. Blueprints become the default authoring pattern (lands with chore: bootstrap eqrm/ct-structure — adopt the real Equippers scaffold #23 in ct-structure). Rewrite the real config as a per-campus blueprint function (mechanism exists — examples/campus-blueprint.config.ts): Mainz Kids subtree 20 ct.group calls → 1 blueprint call + params, with shared fields (campus, status, groupType) supplied by the blueprint, not repeated per declaration. Migrate all 35 groupStatusId: N → status: "<name>". One-time live verification on eqrm-dev that /group/memberstatus rows carry name (documented assumption, src/resolve/resolver.ts:53). README gets an authoring guide (declare → plan → PR).
  • Cut: no ct fmt. Configs are TypeScript; prettier is the formatter (idempotent by construction). ct-structure enforces prettier --check in CI. No defaults engine either — blueprints are the DRY mechanism; a field omitted from a declaration stays unmanaged (existing semantics).

Suggested order: D (tiny) → C → B → A tool-side, then E with #23.

Locked targets (definition of done)

  • Numeric ids in hand-authored config: 0 (measure: grep -cE '[A-Za-z]+Id: [0-9]+' ct.config.ts; currently 35).
  • Fields per plain ct.group() call: median 3 (key, name, groupType); shared fields live in the blueprint (currently median 5).
  • Mainz Kids subtree: 20 declarations → 1 blueprint call (N=20, K=1).
  • State diff for a one-resource change touches only that resource's block — no unrelated updatedAt bumps; state file collapsed on GitHub (machine-only decision replaces the X-lines target).
  • Adopt round-trip: paste emitted config verbatim → ct plan no-op, 0 hand edits.
  • A new contributor adds a correct resource in ≤ 10 min using only the README (timed dry-run, T=10).
  • Invalid config / unknown field fails with file + line + resource key + field.

Relates

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions