Skip to content

feat: Phase 3 — declarative engine (config, plan/diff, dependency graph) - #11

Merged
2000game merged 2 commits into
mainfrom
feat/phase-3-engine
Jul 7, 2026
Merged

2000game merged 2 commits into
mainfrom
feat/phase-3-engine

Conversation

@2000game

@2000game 2000game commented Jul 7, 2026

Copy link
Copy Markdown
Member

⚠️ Stacked on #10 (Phase 2). Review/merge #10 first; this targets feat/phase-2-adopt-state so the diff is Phase 3 only. I'll retarget to main once #10 lands.Implements the engine (#5): TypeScript-as-code config, a diff/plan engine, and dependency ordering.

Config DSL (src/config/*)

A config file default-exports (ct) => { … } and declares resources via an injected context (no global state — trivially testable). Loaded at runtime with jiti so .ts configs run directly. Blueprints are plain functions + loops:

export default (ct) => {
  for (const c of ["mainz", "berlin"]) {
    ct.campus({ key: c, name: `Campus ${c}` });
    ct.group({ key: `${c}_kids_lead`, name: `${c} · Kids Leitung`, parent: `${c}_area` });
  }
};

Duplicate/missing keys are rejected.

Dependency graph (src/engine/graph.ts)

Tiered topological sort: metadata → group → hierarchy → role → permission → dynamic-group, parents before children, with cycle detection. Deletes run in reverse. Unit-tested with a synthetic graph (the #5 DoD).

Plan/diff (src/engine/plan.ts)

Pure computePlan(desired, state, actualById) → create / update / delete / no-op, plus drift (last-known → actual) and recreate for resources vanished from CT. Unmanaged resources stay invisible (managed-guard). IDs compared with null checks (id 0). Terraform-style renderPlan.

ct plan [--config --state --json]

Loads config + state, fetches actual via the Phase 2 registry (404 → recreate), renders the diff. Read-only.

Tests — 63 (+24)

graph (tiers/parents/cycle), plan (every action + drift + recreate + unmanaged + ordering), context (DSL/blueprints/dedup), jiti load, render. typecheck · lint · build green.

Live-verified ✅

Against the instance (CT 3.134.0): adopt campus 0 → config matches ⇒ "No changes"; change shortName ⇒ 1 update (~ campus.mz (#0) shortName: "MZ" -> "MZ-NEW"); add un-adopted campus ⇒ 1 create, mz stays no-op.

Refs #5

2000game added a commit that referenced this pull request Jul 7, 2026
Key actual-state map by logical key, not CT id
  CT ids are unique only within a type (the Mainz campus is id 0), so a
  numeric-id map collided across types and diffed one resource against
  another's fields. computePlan now takes actual keyed by logical key,
  and the plan command populates it that way.

Structural deep-equal instead of JSON.stringify
  Order-independent object comparison, so a mere key-order difference
  (e.g. group-type.nameTranslated) no longer shows a perpetual, never-
  converging update.

Reject a config/state type mismatch on the same logical key
  Diffing a campus against a group under a reused key silently corrupted
  the plan; now a clear error.

Distinguish an unresolvable managed type from a 404
  A managed type with no registry entry is left untouched (surfaced as
  "unresolved-type"), not falsely proposed for recreation.

Tolerate partial fetch failures in the read-only plan
  A single non-404 error no longer aborts the whole plan; failures are
  reported, the rest of the diff is shown, and exit code is 1.

Fetch managed resources concurrently (bounded pool) instead of serially.

Surface stale state entries (gone from CT + dropped from config) instead
  of a silent no-op that printed "No changes".

Reject a desired type with no apply tier, and lock the DSL type list to
  TYPE_TIER with a test (no more silent tier-0 default).

Friendly "config not found" error instead of a raw jiti stack.

Re-add `ct get group-types` to the README (command still exists).
@2000game

2000game commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Review fixes applied (5ab3d9f)

Addressed the findings from the high-effort review. All 78 tests pass; lint, format, typecheck, and build are green.

# Finding Fix
1 actualById keyed by numeric CT id → cross-type id collision computePlan now takes actual keyed by logical key (globally unique); the command populates it that way.
2 JSON.stringify deep-equal → spurious non-converging updates Replaced with a structural, key-order-independent deepEqual.
3 Deletes ordered by tier only, not reverse dependency Deletes now reuse orderKeys (reversed) — the same topo sort as creates.
4 Desired↔state matched by key without checking type Type mismatch on a shared key now throws a clear error.
5 Registry-missing type conflated with a 404 → false recreate Unresolvable types are tracked and left untouched (surfaced as unresolved-type), never recreated.
6 One non-404 fetch error aborts the whole read-only plan Per-resource errors are collected; the rest of the plan renders; exit code 1 on partial failure.
7 Type names in three unsynchronized lists; DSL type without a tier silently tier-0 computePlan rejects a desired type with no tier; a test locks the DSL type list to TYPE_TIER.
8 Managed resources fetched serially Bounded-concurrency fetch pool (mapConcurrent, limit 8).
9 Dropped-and-vanished resource silently no-op → state never pruned Surfaced as a stale state entry section instead of "No changes".
10 Missing config → cryptic jiti error Friendly "config not found" message.

Also re-added ct get group-types to the README (the command still exists).

Known limitation (noted, not a regression): full intra-tier reverse-topo delete ordering needs the state file to record dependency edges — deferred to Phase 4 (apply/destroy). Today deletes are deterministic reverse-tier order via the unified mechanism.

@2000game
2000game changed the base branch from feat/phase-2-adopt-state to main July 7, 2026 12:41
2000game added 2 commits July 7, 2026 14:42
TS-as-code config, a diff/plan engine, and dependency ordering.

- Config DSL (src/config/*): a config file default-exports (ct) => { ... }
  and declares resources via an injected context (no global state). Loaded
  at runtime with jiti so .ts configs run directly. Blueprints are plain
  functions + loops; duplicate/missing keys are rejected.
- Dependency graph (src/engine/graph.ts): tiered topological sort — metadata
  → group → hierarchy → role → permission → dynamic-group, parents before
  children — with cycle detection. Deletes run in reverse.
- Plan/diff (src/engine/plan.ts): pure computePlan(desired, state, actualById)
  → create/update/delete/no-op, drift (last-known → actual), recreate for
  vanished resources. Unmanaged resources stay invisible (managed-guard).
  IDs compared with null checks (id 0). Terraform-style renderPlan.
- ct plan [--config --state --json]: loads config + state, fetches actual via
  the Phase 2 registry (404 → recreate), renders the diff. Read-only.
- 24 new tests (graph tiers/parents/cycle, plan create/update/delete/no-op/
  drift/recreate/unmanaged/ordering, context, jiti load, render) = 63.

Refs #5
Key actual-state map by logical key, not CT id
  CT ids are unique only within a type (the Mainz campus is id 0), so a
  numeric-id map collided across types and diffed one resource against
  another's fields. computePlan now takes actual keyed by logical key,
  and the plan command populates it that way.

Structural deep-equal instead of JSON.stringify
  Order-independent object comparison, so a mere key-order difference
  (e.g. group-type.nameTranslated) no longer shows a perpetual, never-
  converging update.

Reject a config/state type mismatch on the same logical key
  Diffing a campus against a group under a reused key silently corrupted
  the plan; now a clear error.

Distinguish an unresolvable managed type from a 404
  A managed type with no registry entry is left untouched (surfaced as
  "unresolved-type"), not falsely proposed for recreation.

Tolerate partial fetch failures in the read-only plan
  A single non-404 error no longer aborts the whole plan; failures are
  reported, the rest of the diff is shown, and exit code is 1.

Fetch managed resources concurrently (bounded pool) instead of serially.

Surface stale state entries (gone from CT + dropped from config) instead
  of a silent no-op that printed "No changes".

Reject a desired type with no apply tier, and lock the DSL type list to
  TYPE_TIER with a test (no more silent tier-0 default).

Friendly "config not found" error instead of a raw jiti stack.

Re-add `ct get group-types` to the README (command still exists).
@2000game
2000game force-pushed the feat/phase-3-engine branch from 5ab3d9f to b292e2f Compare July 7, 2026 12:43
@2000game
2000game merged commit b72ea99 into main Jul 7, 2026
1 check passed
@2000game
2000game deleted the feat/phase-3-engine branch July 7, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant