From 0e3bd41dda5117cd9df8d356644408c635c4b43d Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 13:44:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20declarative?= =?UTF-8?q?=20engine=20(config,=20plan/diff,=20dependency=20graph)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 +++++- package-lock.json | 10 +++ package.json | 1 + src/commands/placeholders.ts | 1 - src/commands/plan.ts | 59 +++++++++++++++++ src/config/context.ts | 74 +++++++++++++++++++++ src/config/load.ts | 27 ++++++++ src/engine/graph.ts | 80 ++++++++++++++++++++++ src/engine/plan.ts | 113 ++++++++++++++++++++++++++++++++ src/engine/render.ts | 63 ++++++++++++++++++ src/engine/types.ts | 50 ++++++++++++++ src/index.ts | 2 + tests/context.test.ts | 52 +++++++++++++++ tests/fixtures/bad.config.ts | 1 + tests/fixtures/sample.config.ts | 6 ++ tests/graph.test.ts | 42 ++++++++++++ tests/load.test.ts | 20 ++++++ tests/plan.test.ts | 95 +++++++++++++++++++++++++++ tests/render.test.ts | 52 +++++++++++++++ 19 files changed, 764 insertions(+), 4 deletions(-) create mode 100644 src/commands/plan.ts create mode 100644 src/config/context.ts create mode 100644 src/config/load.ts create mode 100644 src/engine/graph.ts create mode 100644 src/engine/plan.ts create mode 100644 src/engine/render.ts create mode 100644 src/engine/types.ts create mode 100644 tests/context.test.ts create mode 100644 tests/fixtures/bad.config.ts create mode 100644 tests/fixtures/sample.config.ts create mode 100644 tests/graph.test.ts create mode 100644 tests/load.test.ts create mode 100644 tests/plan.test.ts create mode 100644 tests/render.test.ts diff --git a/README.md b/README.md index 2ccb850..edc704e 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,29 @@ npm link # optional: puts `ct` on your PATH ```bash export CT_HOST=https://eqrm.church.tools # default; override for a test instance -ct auth login --token # stored at ~/.config/ct-cli/credentials.json (0600) +ct auth login --token # stored in the macOS Keychain ct auth status # who am I? ct get campuses # JSON to stdout — pipe into jq ct get groups -ct get group-types ct get raw /groups/42 # arbitrary GET -ct plan | apply | adopt | destroy # not yet implemented — see phase issues +ct adopt campus 0 # bring an existing resource under management (→ state file) +ct state list # show the managed set +ct plan # diff the desired-state config against ChurchTools (read-only) +ct plan --json # the raw plan as JSON + +ct apply | destroy # not yet implemented — see phase issues +``` + +The desired state lives in a config file (default `ct.config.ts`) that +default-exports a function receiving the DSL: + +```ts +export default (ct) => { + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); +}; ``` Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go diff --git a/package-lock.json b/package-lock.json index 8323096..e1a0d42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "commander": "^12.1.0", + "jiti": "^2.4.0", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, @@ -2443,6 +2444,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/package.json b/package.json index 15bbc0d..812b0c2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "commander": "^12.1.0", + "jiti": "^2.4.0", "openapi-fetch": "^0.13.0", "picocolors": "^1.1.0" }, diff --git a/src/commands/placeholders.ts b/src/commands/placeholders.ts index e076242..b9b6d95 100644 --- a/src/commands/placeholders.ts +++ b/src/commands/placeholders.ts @@ -12,7 +12,6 @@ interface Planned { } const PLANNED: Planned[] = [ - { name: "plan", description: "Show the diff between desired state and ChurchTools", issue: "Phase 3 (#5)" }, { name: "apply", description: "Apply the plan (idempotent, in dependency order)", issue: "Phase 4 (#6)" }, { name: "destroy", description: "Explicitly remove managed resources (protected)", issue: "Phase 4 (#6)" }, ]; diff --git a/src/commands/plan.ts b/src/commands/plan.ts new file mode 100644 index 0000000..9862d30 --- /dev/null +++ b/src/commands/plan.ts @@ -0,0 +1,59 @@ +import { Command } from "commander"; +import { authedSession } from "../api/session.js"; +import { CtApiError } from "../api/ctClient.js"; +import { resolveConfig } from "../config.js"; +import { RESOURCES } from "../resources/registry.js"; +import { loadState, resolveStatePath } from "../state/state.js"; +import { loadConfig, resolveConfigPath } from "../config/load.js"; +import { computePlan } from "../engine/plan.js"; +import { renderPlan } from "../engine/render.js"; +import { info, warn, out } from "../ui.js"; + +interface PlanOptions { + config?: string; + state?: string; + json?: boolean; +} + +export function planCommand(): Command { + return new Command("plan") + .description("Show the diff between the desired-state config and ChurchTools (read-only)") + .option("-c, --config ", "config file (or set CT_CONFIG)") + .option("-s, --state ", "state file (or set CT_STATE)") + .option("--json", "emit the raw plan as JSON instead of the rendered diff") + .action(async (opts: PlanOptions) => { + const config = resolveConfig(); + const desired = await loadConfig(resolveConfigPath(opts.config)); + const state = await loadState(resolveStatePath(opts.state), config.host); + if (state.host !== config.host) { + throw new Error(`State host (${state.host}) does not match CT_HOST (${config.host}).`); + } + + const { client } = await authedSession(); + const actualById = new Map>(); + for (const managed of Object.values(state.resources)) { + const spec = RESOURCES[managed.type]; + if (!spec) { + warn(`No registry entry for managed type "${managed.type}" (#${managed.id}) — skipping.`); + continue; + } + try { + const raw = await client.get>(spec.itemPath(managed.id)); + actualById.set(managed.id, spec.managedFields(raw)); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + continue; // vanished in CT — the plan will propose recreating it + } + throw err; + } + } + + const plan = computePlan(desired, state, actualById); + if (opts.json) { + out(plan); + return; + } + info(`config: ${resolveConfigPath(opts.config)} · state host: ${state.host}`); + process.stdout.write(`${renderPlan(plan)}\n`); + }); +} diff --git a/src/config/context.ts b/src/config/context.ts new file mode 100644 index 0000000..1c7e541 --- /dev/null +++ b/src/config/context.ts @@ -0,0 +1,74 @@ +/** + * The config DSL. A config file default-exports a function that receives this + * context and declares resources: + * + * export default (ct: ConfigContext) => { + * ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + * ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", parent: "mainz_area" }); + * }; + * + * The context is injected (no global state), so blueprints are just functions + * and loops, and the whole thing is trivially testable without file I/O. + */ +import type { DesiredResource } from "../engine/types.js"; + +export interface ResourceInput { + key: string; + parent?: string; + dependsOn?: string[]; + [field: string]: unknown; +} + +export interface ConfigContext { + campus(input: ResourceInput): void; + group(input: ResourceInput): void; + groupType(input: ResourceInput): void; + ageGroup(input: ResourceInput): void; + targetGroup(input: ResourceInput): void; + relationshipType(input: ResourceInput): void; +} + +export type ConfigModule = (ct: ConfigContext) => void | Promise; + +function toDesired(type: string, input: ResourceInput): DesiredResource { + const { key, parent, dependsOn = [], ...fields } = input; + if (!key || typeof key !== "string") { + throw new Error(`${type} declaration is missing a string "key".`); + } + const edges = [...dependsOn]; + if (parent) { + edges.push(parent); + } + return { type, key, fields, parent, dependsOn: edges }; +} + +export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } { + const resources: DesiredResource[] = []; + const seen = new Set(); + const define = + (type: string) => + (input: ResourceInput): void => { + const resource = toDesired(type, input); + if (seen.has(resource.key)) { + throw new Error(`Duplicate logical key "${resource.key}" in config.`); + } + seen.add(resource.key); + resources.push(resource); + }; + const ct: ConfigContext = { + campus: define("campus"), + group: define("group"), + groupType: define("group-type"), + ageGroup: define("age-group"), + targetGroup: define("target-group"), + relationshipType: define("relationship-type"), + }; + return { ct, resources }; +} + +/** Run a loaded config module against a fresh context and collect its resources. */ +export async function evaluateConfig(mod: ConfigModule): Promise { + const { ct, resources } = createContext(); + await mod(ct); + return resources; +} diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..8c8c5f0 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,27 @@ +/** + * Load a desired-state config file (`.ts`, `.mjs`, or `.js`) at runtime. + * + * `jiti` transpiles TypeScript on the fly so the compiled CLI can import a + * user's `.ts` config directly. The config must default-export a + * {@link ConfigModule}; we run it against a fresh context and return the + * declared resources. + */ +import { resolve } from "node:path"; +import { createJiti } from "jiti"; +import { evaluateConfig, type ConfigModule } from "./context.js"; +import type { DesiredResource } from "../engine/types.js"; + +export const DEFAULT_CONFIG_PATH = "ct.config.ts"; + +export function resolveConfigPath(explicit?: string, env: NodeJS.ProcessEnv = process.env): string { + return explicit?.trim() || env.CT_CONFIG?.trim() || DEFAULT_CONFIG_PATH; +} + +export async function loadConfig(path: string): Promise { + const jiti = createJiti(import.meta.url, { moduleCache: false }); + const mod = await jiti.import(resolve(path), { default: true }); + if (typeof mod !== "function") { + throw new Error(`Config ${path} must default-export a function (ct) => { ... }.`); + } + return evaluateConfig(mod); +} diff --git a/src/engine/graph.ts b/src/engine/graph.ts new file mode 100644 index 0000000..ff5af11 --- /dev/null +++ b/src/engine/graph.ts @@ -0,0 +1,80 @@ +/** + * Dependency ordering for apply/destroy. + * + * Apply order follows the ChurchTools dependency tiers: base metadata first, + * then groups, then the things that reference groups (hierarchy links, roles, + * permissions), then dynamic-group rulesets. Within a tier, explicit + * dependencies (a group's parent) are honoured via topological sort. Destroy + * runs in the exact reverse. + */ +import type { DesiredResource } from "./types.js"; + +/** Lower tier is applied first. Delete runs highest tier first. */ +export const TYPE_TIER: Record = { + campus: 0, + "group-type": 0, + "group-status": 0, + "age-group": 0, + "target-group": 0, + "relationship-type": 0, + group: 1, + "group-hierarchy": 2, + "group-role": 3, + permission: 4, + "dynamic-group": 5, +}; + +export function tierOf(type: string): number { + return TYPE_TIER[type] ?? 0; +} + +/** + * Return logical keys in apply order. Stable: ties break by tier, then by the + * original declaration order. Throws on a dependency cycle. + */ +export function orderKeys(resources: DesiredResource[]): string[] { + const byKey = new Map(resources.map((r, i) => [r.key, { r, i }])); + const indegree = new Map(resources.map((r) => [r.key, 0])); + const successors = new Map(resources.map((r) => [r.key, []])); + + for (const r of resources) { + for (const dep of r.dependsOn) { + if (!byKey.has(dep)) { + continue; // dependency outside the managed set — nothing to order against + } + successors.get(dep)!.push(r.key); + indegree.set(r.key, (indegree.get(r.key) ?? 0) + 1); + } + } + + const ready = resources.filter((r) => (indegree.get(r.key) ?? 0) === 0).map((r) => r.key); + const order: string[] = []; + + const priority = (key: string): [number, number] => { + const entry = byKey.get(key)!; + return [tierOf(entry.r.type), entry.i]; + }; + + while (ready.length > 0) { + ready.sort((a, b) => { + const [ta, ia] = priority(a); + const [tb, ib] = priority(b); + return ta - tb || ia - ib; + }); + const key = ready.shift()!; + order.push(key); + for (const next of successors.get(key)!) { + const remaining = (indegree.get(next) ?? 0) - 1; + indegree.set(next, remaining); + if (remaining === 0) { + ready.push(next); + } + } + } + + if (order.length !== resources.length) { + const cyclic = resources.map((r) => r.key).filter((k) => !order.includes(k)); + throw new Error(`Dependency cycle among: ${cyclic.join(", ")}`); + } + return order; +} diff --git a/src/engine/plan.ts b/src/engine/plan.ts new file mode 100644 index 0000000..05b1ff0 --- /dev/null +++ b/src/engine/plan.ts @@ -0,0 +1,113 @@ +/** + * The diff engine: compare desired state (config) against the state file and + * the actual ChurchTools values, and produce an ordered plan. + * + * Managed-guard: only resources in config or the state file are ever + * considered. Anything else in ChurchTools is invisible — never diffed, never + * proposed for deletion. + */ +import type { State } from "../state/state.js"; +import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; +import { orderKeys, tierOf } from "./graph.js"; + +function deepEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +/** Field-by-field diff over the desired fields only (the ones we manage). */ +export function diffFields(desired: Record, actual: Record): FieldChange[] { + const changes: FieldChange[] = []; + for (const [field, to] of Object.entries(desired)) { + if (!deepEqual(actual[field], to)) { + changes.push({ field, from: actual[field], to }); + } + } + return changes; +} + +function creationChanges(fields: Record): FieldChange[] { + return Object.entries(fields).map(([field, to]) => ({ field, from: undefined, to })); +} + +/** Drift over the managed fields: what changed in ChurchTools since the snapshot (last known → actual). */ +export function driftFields( + lastKnown: Record, + actual: Record, +): FieldChange[] { + const changes: FieldChange[] = []; + for (const [field, known] of Object.entries(lastKnown)) { + if (!deepEqual(actual[field], known)) { + changes.push({ field, from: known, to: actual[field] }); + } + } + return changes; +} + +export function computePlan( + desired: DesiredResource[], + state: State, + actualById: Map>, +): Plan { + const desiredByKey = new Map(desired.map((d) => [d.key, d])); + const creates: PlanItem[] = []; + const updates: PlanItem[] = []; + const deletes: PlanItem[] = []; + + for (const d of desired) { + const managed = state.resources[d.key]; + if (!managed) { + creates.push({ + type: d.type, + key: d.key, + id: null, + action: "create", + changes: creationChanges(d.fields), + }); + continue; + } + const actual = actualById.get(managed.id); + if (!actual) { + creates.push({ + type: d.type, + key: d.key, + id: null, + action: "create", + changes: creationChanges(d.fields), + recreated: true, + }); + continue; + } + const changes = diffFields(d.fields, actual); + const drift = driftFields(managed.fields, actual); + updates.push({ + type: d.type, + key: d.key, + id: managed.id, + action: changes.length > 0 ? "update" : "no-op", + changes, + drift: drift.length > 0 ? drift : undefined, + }); + } + + for (const managed of Object.values(state.resources)) { + if (desiredByKey.has(managed.key)) { + continue; + } + const actual = actualById.get(managed.id); + deletes.push({ + type: managed.type, + key: managed.key, + id: managed.id, + action: actual ? "delete" : "no-op", + changes: [], + }); + } + + const applyOrder = orderKeys(desired); + const rank = new Map(applyOrder.map((key, i) => [key, i])); + const ordered = [...creates, ...updates].sort((a, b) => (rank.get(a.key) ?? 0) - (rank.get(b.key) ?? 0)); + // Deletes run in reverse dependency order (highest tier first). + deletes.sort((a, b) => tierOf(b.type) - tierOf(a.type)); + + return { items: [...ordered, ...deletes] }; +} diff --git a/src/engine/render.ts b/src/engine/render.ts new file mode 100644 index 0000000..04bdcb7 --- /dev/null +++ b/src/engine/render.ts @@ -0,0 +1,63 @@ +/** + * Human-readable, Terraform-style rendering of a {@link Plan}. + * Machine consumers should use the plan object directly (`ct plan --json`). + */ +import pc from "picocolors"; +import { type Plan, type PlanAction, summarize } from "./types.js"; + +function sigil(action: PlanAction): string { + switch (action) { + case "create": + return pc.green("+"); + case "update": + return pc.yellow("~"); + case "delete": + return pc.red("-"); + default: + return " "; + } +} + +function fmt(value: unknown): string { + return value === undefined ? "(none)" : JSON.stringify(value); +} + +export function renderPlan(plan: Plan): string { + const changed = plan.items.filter((i) => i.action !== "no-op"); + const drifted = plan.items.filter((i) => i.drift && i.drift.length > 0); + const lines: string[] = []; + + if (changed.length === 0 && drifted.length === 0) { + return pc.green("No changes. Desired state matches ChurchTools."); + } + + for (const item of changed) { + const id = item.id !== null ? pc.dim(` (#${item.id})`) : ""; + const note = item.recreated ? pc.yellow(" [recreate — missing in ChurchTools]") : ""; + lines.push(` ${sigil(item.action)} ${item.type}.${item.key}${id}${note}`); + for (const c of item.changes) { + lines.push( + item.action === "create" + ? ` ${c.field}: ${fmt(c.to)}` + : ` ${c.field}: ${fmt(c.from)} -> ${fmt(c.to)}`, + ); + } + } + + if (drifted.length > 0) { + lines.push(""); + lines.push(pc.yellow("Drift detected (changed in ChurchTools since adoption):")); + for (const item of drifted) { + for (const c of item.drift ?? []) { + lines.push( + ` ! ${item.type}.${item.key} (#${item.id}): ${c.field} = ${fmt(c.to)} (last known ${fmt(c.from)})`, + ); + } + } + } + + const s = summarize(plan); + lines.push(""); + lines.push(pc.bold(`Plan: ${s.create} to create, ${s.update} to update, ${s.delete} to delete.`)); + return lines.join("\n"); +} diff --git a/src/engine/types.ts b/src/engine/types.ts new file mode 100644 index 0000000..d9875f5 --- /dev/null +++ b/src/engine/types.ts @@ -0,0 +1,50 @@ +/** + * Shared engine types: the desired-state resources parsed from config, and the + * plan produced by diffing desired vs state vs actual. + */ + +export interface DesiredResource { + type: string; + key: string; + /** Managed fields — same shape as the Phase 2 registry snapshot, so they diff like-for-like against actual. */ + fields: Record; + /** Parent logical key (group hierarchy). Also contributes a dependency edge. */ + parent?: string; + /** Logical keys this resource must be applied after (includes `parent`). */ + dependsOn: string[]; +} + +export type PlanAction = "create" | "update" | "delete" | "no-op"; + +export interface FieldChange { + field: string; + from: unknown; + to: unknown; +} + +export interface PlanItem { + type: string; + key: string; + /** CT id when known (updates/deletes); null for creates. */ + id: number | null; + action: PlanAction; + /** For create: every desired field; update: only the differences; delete/no-op: empty. */ + changes: FieldChange[]; + /** Manual changes in ChurchTools since adoption (last-known snapshot vs actual). */ + drift?: FieldChange[]; + /** A managed resource that has vanished from ChurchTools and will be recreated. */ + recreated?: boolean; +} + +export interface Plan { + /** Items in execution order: creates/updates in dependency order, deletes in reverse. */ + items: PlanItem[]; +} + +export function summarize(plan: Plan): Record { + const counts: Record = { create: 0, update: 0, delete: 0, "no-op": 0 }; + for (const item of plan.items) { + counts[item.action]++; + } + return counts; +} diff --git a/src/index.ts b/src/index.ts index 754e6e8..db9d402 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { authCommand } from "./commands/auth.js"; import { getCommand } from "./commands/get.js"; import { adoptCommand } from "./commands/adopt.js"; import { stateCommand } from "./commands/state.js"; +import { planCommand } from "./commands/plan.js"; import { plannedCommands } from "./commands/placeholders.js"; import { isMainModule } from "./isMain.js"; import { error } from "./ui.js"; @@ -22,6 +23,7 @@ export function buildProgram(): Command { program.addCommand(getCommand()); program.addCommand(adoptCommand()); program.addCommand(stateCommand()); + program.addCommand(planCommand()); for (const cmd of plannedCommands()) { program.addCommand(cmd); } diff --git a/tests/context.test.ts b/tests/context.test.ts new file mode 100644 index 0000000..0e27ad7 --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { createContext, evaluateConfig } from "../src/config/context.js"; + +describe("config context", () => { + it("builds desired resources from DSL calls, separating key/parent from fields", () => { + const { ct, resources } = createContext(); + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + ct.group({ key: "kids", name: "Kids", parent: "mainz", groupTypeId: 3 }); + + expect(resources[0]).toEqual({ + type: "campus", + key: "mainz", + fields: { name: "Mainz", shortName: "MZ" }, + parent: undefined, + dependsOn: [], + }); + expect(resources[1]).toMatchObject({ + type: "group", + key: "kids", + fields: { name: "Kids", groupTypeId: 3 }, + parent: "mainz", + dependsOn: ["mainz"], + }); + }); + + it("merges explicit dependsOn with the parent edge", () => { + const { ct, resources } = createContext(); + ct.group({ key: "team", name: "Team", parent: "lead", dependsOn: ["type_x"] }); + expect(resources[0]?.dependsOn).toEqual(["type_x", "lead"]); + }); + + it("rejects a duplicate logical key", () => { + const { ct } = createContext(); + ct.campus({ key: "mainz", name: "A" }); + expect(() => ct.campus({ key: "mainz", name: "B" })).toThrow(/Duplicate/); + }); + + it("rejects a missing key", () => { + const { ct } = createContext(); + expect(() => ct.campus({ name: "no key" } as never)).toThrow(/key/); + }); + + it("evaluateConfig runs a module against a fresh context (blueprints + loops)", async () => { + const resources = await evaluateConfig((ct) => { + for (const c of ["mainz", "berlin"]) { + ct.campus({ key: c, name: c }); + ct.group({ key: `${c}_kids`, name: `${c} kids`, parent: c }); + } + }); + expect(resources.map((r) => r.key)).toEqual(["mainz", "mainz_kids", "berlin", "berlin_kids"]); + }); +}); diff --git a/tests/fixtures/bad.config.ts b/tests/fixtures/bad.config.ts new file mode 100644 index 0000000..2f0e95e --- /dev/null +++ b/tests/fixtures/bad.config.ts @@ -0,0 +1 @@ +export default { not: "a function" }; diff --git a/tests/fixtures/sample.config.ts b/tests/fixtures/sample.config.ts new file mode 100644 index 0000000..baad797 --- /dev/null +++ b/tests/fixtures/sample.config.ts @@ -0,0 +1,6 @@ +import type { ConfigContext } from "../../src/config/context.js"; + +export default (ct: ConfigContext): void => { + ct.campus({ key: "mainz", name: "Mainz", shortName: "MZ" }); + ct.group({ key: "kids_lead", name: "Kids Leitung", parent: "mainz" }); +}; diff --git a/tests/graph.test.ts b/tests/graph.test.ts new file mode 100644 index 0000000..b323390 --- /dev/null +++ b/tests/graph.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { orderKeys, tierOf } from "../src/engine/graph.js"; +import type { DesiredResource } from "../src/engine/types.js"; + +function res(type: string, key: string, deps: string[] = [], parent?: string): DesiredResource { + return { type, key, fields: {}, parent, dependsOn: parent ? [...deps, parent] : deps }; +} + +describe("tierOf", () => { + it("ranks metadata below groups below the things that reference groups", () => { + expect(tierOf("campus")).toBeLessThan(tierOf("group")); + expect(tierOf("group")).toBeLessThan(tierOf("group-hierarchy")); + expect(tierOf("permission")).toBeLessThan(tierOf("dynamic-group")); + expect(tierOf("unknown")).toBe(0); + }); +}); + +describe("orderKeys", () => { + it("puts metadata before groups even without explicit edges", () => { + const order = orderKeys([res("group", "team"), res("campus", "mainz"), res("group-type", "lead")]); + expect(order.indexOf("mainz")).toBeLessThan(order.indexOf("team")); + expect(order.indexOf("lead")).toBeLessThan(order.indexOf("team")); + }); + + it("orders parents before children regardless of declaration order", () => { + const order = orderKeys([res("group", "child", [], "parent"), res("group", "parent")]); + expect(order).toEqual(["parent", "child"]); + }); + + it("orders a permission after the group it depends on", () => { + const order = orderKeys([res("permission", "perm", ["team"]), res("group", "team")]); + expect(order).toEqual(["team", "perm"]); + }); + + it("ignores dependencies outside the managed set", () => { + expect(orderKeys([res("group", "team", ["nonexistent"])])).toEqual(["team"]); + }); + + it("throws on a dependency cycle", () => { + expect(() => orderKeys([res("group", "a", ["b"]), res("group", "b", ["a"])])).toThrow(/cycle/i); + }); +}); diff --git a/tests/load.test.ts b/tests/load.test.ts new file mode 100644 index 0000000..af1ff01 --- /dev/null +++ b/tests/load.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { loadConfig } from "../src/config/load.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +describe("loadConfig", () => { + it("loads a .ts config that default-exports a function", async () => { + const resources = await loadConfig(join(here, "fixtures/sample.config.ts")); + expect(resources.map((r) => r.key)).toEqual(["mainz", "kids_lead"]); + expect(resources[1]).toMatchObject({ type: "group", parent: "mainz", dependsOn: ["mainz"] }); + }); + + it("rejects a config that does not default-export a function", async () => { + await expect(loadConfig(join(here, "fixtures/bad.config.ts"))).rejects.toThrow( + /default-export a function/, + ); + }); +}); diff --git a/tests/plan.test.ts b/tests/plan.test.ts new file mode 100644 index 0000000..9df60eb --- /dev/null +++ b/tests/plan.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { computePlan } from "../src/engine/plan.js"; +import type { DesiredResource } from "../src/engine/types.js"; +import type { State, ManagedResource } from "../src/state/state.js"; + +const HOST = "https://eqrm.church.tools"; + +function desired( + key: string, + fields: Record, + opts: Partial = {}, +): DesiredResource { + return { type: "campus", key, fields, dependsOn: [], ...opts }; +} + +function managed(key: string, id: number, fields: Record): ManagedResource { + return { type: "campus", id, key, fields, adoptedAt: "t", updatedAt: "t" }; +} + +function stateOf(...entries: ManagedResource[]): State { + return { version: 1, host: HOST, resources: Object.fromEntries(entries.map((e) => [e.key, e])) }; +} + +describe("computePlan", () => { + it("plans a create for a config resource absent from state", () => { + const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); + expect(plan.items).toHaveLength(1); + expect(plan.items[0]).toMatchObject({ action: "create", key: "mainz", id: null }); + expect(plan.items[0]?.changes).toEqual([{ field: "name", from: undefined, to: "Mainz" }]); + }); + + it("is a no-op when desired matches actual (id 0 handled)", () => { + const plan = computePlan( + [desired("mainz", { name: "Mainz" })], + stateOf(managed("mainz", 0, { name: "Mainz" })), + new Map([[0, { name: "Mainz" }]]), + ); + expect(plan.items[0]).toMatchObject({ action: "no-op", key: "mainz", id: 0 }); + }); + + it("plans an update with just the changed fields", () => { + const plan = computePlan( + [desired("mainz", { name: "Mainz HQ", shortName: "MZ" })], + stateOf(managed("mainz", 5, { name: "Mainz", shortName: "MZ" })), + new Map([[5, { name: "Mainz", shortName: "MZ" }]]), + ); + expect(plan.items[0]).toMatchObject({ action: "update", id: 5 }); + expect(plan.items[0]?.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz HQ" }]); + }); + + it("plans a delete for a managed resource dropped from config", () => { + const plan = computePlan( + [], + stateOf(managed("old", 9, { name: "Old" })), + new Map([[9, { name: "Old" }]]), + ); + expect(plan.items[0]).toMatchObject({ action: "delete", key: "old", id: 9 }); + }); + + it("reports drift when ChurchTools differs from the last-known snapshot", () => { + const plan = computePlan( + [desired("mainz", { name: "Mainz" })], + stateOf(managed("mainz", 5, { name: "Mainz", shortName: "MZ" })), + new Map([[5, { name: "Mainz", shortName: "CHANGED" }]]), + ); + expect(plan.items[0]?.drift).toEqual([{ field: "shortName", from: "MZ", to: "CHANGED" }]); + }); + + it("recreates a managed resource that has vanished from ChurchTools", () => { + const plan = computePlan( + [desired("mainz", { name: "Mainz" })], + stateOf(managed("mainz", 5, { name: "Mainz" })), + new Map(), // actual absent → 404 + ); + expect(plan.items[0]).toMatchObject({ action: "create", recreated: true }); + }); + + it("never surfaces unmanaged resources", () => { + // actual contains an id that is neither in config nor state — must be ignored. + const plan = computePlan([], stateOf(), new Map([[42, { name: "Unmanaged" }]])); + expect(plan.items).toHaveLength(0); + }); + + it("orders create items by dependency (parent before child)", () => { + const plan = computePlan( + [ + desired("child", { name: "C" }, { type: "group", parent: "parent", dependsOn: ["parent"] }), + desired("parent", { name: "P" }, { type: "group" }), + ], + stateOf(), + new Map(), + ); + expect(plan.items.map((i) => i.key)).toEqual(["parent", "child"]); + }); +}); diff --git a/tests/render.test.ts b/tests/render.test.ts new file mode 100644 index 0000000..acb0b48 --- /dev/null +++ b/tests/render.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { renderPlan } from "../src/engine/render.js"; + +describe("renderPlan", () => { + it("reports no changes for an empty or all-no-op plan", () => { + expect(renderPlan({ items: [] })).toMatch(/No changes/); + expect( + renderPlan({ items: [{ type: "campus", key: "mz", id: 0, action: "no-op", changes: [] }] }), + ).toMatch(/No changes/); + }); + + it("renders create/update lines and a summary", () => { + const out = renderPlan({ + items: [ + { + type: "campus", + key: "mainz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Mainz" }], + }, + { + type: "group", + key: "kids", + id: 7, + action: "update", + changes: [{ field: "name", from: "K", to: "Kids" }], + }, + ], + }); + expect(out).toMatch(/campus\.mainz/); + expect(out).toMatch(/group\.kids/); + expect(out).toMatch(/1 to create, 1 to update/); + }); + + it("surfaces drift", () => { + const out = renderPlan({ + items: [ + { + type: "campus", + key: "mainz", + id: 0, + action: "no-op", + changes: [], + drift: [{ field: "shortName", from: "MZ", to: "CHANGED" }], + }, + ], + }); + expect(out).toMatch(/Drift detected/); + expect(out).toMatch(/shortName/); + }); +}); From b292e2f663a9a25f92245e8ff8e415ab0eec5cf8 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Tue, 7 Jul 2026 14:37:33 +0200 Subject: [PATCH 2/2] fix(engine): address PR #11 review findings (plan diff correctness) 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). --- README.md | 1 + src/commands/plan.ts | 48 ++++++++++---- src/config/context.ts | 2 + src/config/load.ts | 13 +++- src/engine/graph.ts | 9 +++ src/engine/plan.ts | 136 ++++++++++++++++++++++++++++++-------- src/engine/render.ts | 22 +++++- src/engine/types.ts | 12 +++- src/util/concurrency.ts | 24 +++++++ tests/concurrency.test.ts | 30 +++++++++ tests/context.test.ts | 14 ++++ tests/load.test.ts | 4 ++ tests/plan.test.ts | 85 ++++++++++++++++++++++-- tests/render.test.ts | 34 ++++++++++ 14 files changed, 382 insertions(+), 52 deletions(-) create mode 100644 src/util/concurrency.ts create mode 100644 tests/concurrency.test.ts diff --git a/README.md b/README.md index edc704e..dceab93 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ ct auth status # who am I? ct get campuses # JSON to stdout — pipe into jq ct get groups +ct get group-types ct get raw /groups/42 # arbitrary GET ct adopt campus 0 # bring an existing resource under management (→ state file) diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 9862d30..45ef94f 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -7,6 +7,7 @@ import { loadState, resolveStatePath } from "../state/state.js"; import { loadConfig, resolveConfigPath } from "../config/load.js"; import { computePlan } from "../engine/plan.js"; import { renderPlan } from "../engine/render.js"; +import { mapConcurrent } from "../util/concurrency.js"; import { info, warn, out } from "../ui.js"; interface PlanOptions { @@ -15,6 +16,9 @@ interface PlanOptions { json?: boolean; } +/** How many managed resources to fetch from ChurchTools at once. */ +const FETCH_CONCURRENCY = 8; + export function planCommand(): Command { return new Command("plan") .description("Show the diff between the desired-state config and ChurchTools (read-only)") @@ -23,37 +27,55 @@ export function planCommand(): Command { .option("--json", "emit the raw plan as JSON instead of the rendered diff") .action(async (opts: PlanOptions) => { const config = resolveConfig(); - const desired = await loadConfig(resolveConfigPath(opts.config)); + const configPath = resolveConfigPath(opts.config); + const desired = await loadConfig(configPath); const state = await loadState(resolveStatePath(opts.state), config.host); if (state.host !== config.host) { throw new Error(`State host (${state.host}) does not match CT_HOST (${config.host}).`); } const { client } = await authedSession(); - const actualById = new Map>(); - for (const managed of Object.values(state.resources)) { + // Keyed by logical key (globally unique), not CT id (unique only within a type — the Mainz campus is id 0). + const actual = new Map>(); + const unresolved = new Set(); + const fetchErrors: string[] = []; + + await mapConcurrent(Object.values(state.resources), FETCH_CONCURRENCY, async (managed) => { const spec = RESOURCES[managed.type]; if (!spec) { - warn(`No registry entry for managed type "${managed.type}" (#${managed.id}) — skipping.`); - continue; + unresolved.add(managed.key); + warn( + `No registry entry for managed type "${managed.type}" (${managed.type}.${managed.key} #${managed.id}) — cannot diff; leaving untouched.`, + ); + return; } try { const raw = await client.get>(spec.itemPath(managed.id)); - actualById.set(managed.id, spec.managedFields(raw)); + actual.set(managed.key, spec.managedFields(raw)); } catch (err) { if (err instanceof CtApiError && err.status === 404) { - continue; // vanished in CT — the plan will propose recreating it + return; // vanished in CT — the plan will propose recreating (or pruning) it } - throw err; + // A read-only plan should not abort on one bad fetch: record it, keep going, flag the plan as partial. + const message = err instanceof Error ? err.message : String(err); + fetchErrors.push(`${managed.type}.${managed.key} (#${managed.id}): ${message}`); + warn(`Failed to fetch ${managed.type}.${managed.key} (#${managed.id}): ${message}`); } - } + }); - const plan = computePlan(desired, state, actualById); + const plan = computePlan(desired, state, actual, { unresolved }); if (opts.json) { out(plan); - return; + } else { + info(`config: ${configPath} · state host: ${state.host}`); + process.stdout.write(`${renderPlan(plan)}\n`); + } + + if (fetchErrors.length > 0) { + warn( + `Plan is INCOMPLETE — ${fetchErrors.length} resource(s) could not be fetched; their diff is missing. Re-run to retry.`, + ); + process.exitCode = 1; } - info(`config: ${resolveConfigPath(opts.config)} · state host: ${state.host}`); - process.stdout.write(`${renderPlan(plan)}\n`); }); } diff --git a/src/config/context.ts b/src/config/context.ts index 1c7e541..ace9e59 100644 --- a/src/config/context.ts +++ b/src/config/context.ts @@ -55,6 +55,8 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource seen.add(resource.key); resources.push(resource); }; + // Every type emitted here MUST have an apply tier in engine/graph.ts TYPE_TIER + // (locked by tests/context.test.ts), else computePlan rejects it at plan time. const ct: ConfigContext = { campus: define("campus"), group: define("group"), diff --git a/src/config/load.ts b/src/config/load.ts index 8c8c5f0..b9452d9 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -6,6 +6,7 @@ * {@link ConfigModule}; we run it against a fresh context and return the * declared resources. */ +import { access } from "node:fs/promises"; import { resolve } from "node:path"; import { createJiti } from "jiti"; import { evaluateConfig, type ConfigModule } from "./context.js"; @@ -18,8 +19,18 @@ export function resolveConfigPath(explicit?: string, env: NodeJS.ProcessEnv = pr } export async function loadConfig(path: string): Promise { + const resolved = resolve(path); + // Surface a friendly message rather than jiti's raw ERR_MODULE_NOT_FOUND stack. + try { + await access(resolved); + } catch { + throw new Error( + `Config file not found: ${path} (default: ${DEFAULT_CONFIG_PATH}). ` + + `Create it — it must default-export a function (ct) => { ... }.`, + ); + } const jiti = createJiti(import.meta.url, { moduleCache: false }); - const mod = await jiti.import(resolve(path), { default: true }); + const mod = await jiti.import(resolved, { default: true }); if (typeof mod !== "function") { throw new Error(`Config ${path} must default-export a function (ct) => { ... }.`); } diff --git a/src/engine/graph.ts b/src/engine/graph.ts index ff5af11..a791d37 100644 --- a/src/engine/graph.ts +++ b/src/engine/graph.ts @@ -28,6 +28,15 @@ export function tierOf(type: string): number { return TYPE_TIER[type] ?? 0; } +/** + * Whether `type` has a declared apply tier. Every type the config DSL can emit + * MUST be here (locked by a test), so an unknown type is a registration bug — + * not something to silently order as tier 0. + */ +export function isKnownType(type: string): boolean { + return Object.prototype.hasOwnProperty.call(TYPE_TIER, type); +} + /** * Return logical keys in apply order. Stable: ties break by tier, then by the * original declaration order. Throws on a dependency cycle. diff --git a/src/engine/plan.ts b/src/engine/plan.ts index 05b1ff0..afd70b7 100644 --- a/src/engine/plan.ts +++ b/src/engine/plan.ts @@ -5,16 +5,45 @@ * Managed-guard: only resources in config or the state file are ever * considered. Anything else in ChurchTools is invisible — never diffed, never * proposed for deletion. + * + * `actual` is keyed by **logical key**, not CT id: ids are only unique within a + * type (the Mainz campus is id 0), so a numeric-id map would collide across + * types. Logical keys are globally unique in the state file. */ import type { State } from "../state/state.js"; import type { DesiredResource, FieldChange, Plan, PlanItem } from "./types.js"; -import { orderKeys, tierOf } from "./graph.js"; +import { orderKeys, isKnownType } from "./graph.js"; +/** + * Structural deep-equal. Order-independent for objects, so a mere key-order + * difference between the API's JSON and the config's object is NOT reported as a + * change (a `JSON.stringify` comparison would flag it, proposing an update that + * can never converge). + */ function deepEqual(a: unknown, b: unknown): boolean { - return JSON.stringify(a) === JSON.stringify(b); + if (a === b) { + return true; + } + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") { + return false; + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((v, i) => deepEqual(v, b[i])); + } + const ao = a as Record; + const bo = b as Record; + const aKeys = Object.keys(ao); + const bKeys = Object.keys(bo); + if (aKeys.length !== bKeys.length) { + return false; + } + return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k])); } -/** Field-by-field diff over the desired fields only (the ones we manage). */ +/** Field-by-field diff over the first arg's fields only. `diffFields(fields, {})` yields a full creation change set. */ export function diffFields(desired: Record, actual: Record): FieldChange[] { const changes: FieldChange[] = []; for (const [field, to] of Object.entries(desired)) { @@ -25,10 +54,6 @@ export function diffFields(desired: Record, actual: Record): FieldChange[] { - return Object.entries(fields).map(([field, to]) => ({ field, from: undefined, to })); -} - /** Drift over the managed fields: what changed in ChurchTools since the snapshot (last known → actual). */ export function driftFields( lastKnown: Record, @@ -43,11 +68,27 @@ export function driftFields( return changes; } +export interface ComputePlanOptions { + /** Logical keys whose managed type has no registry entry — cannot be fetched, so left untouched (not recreated/deleted). */ + unresolved?: ReadonlySet; +} + export function computePlan( desired: DesiredResource[], state: State, - actualById: Map>, + actual: Map>, + opts: ComputePlanOptions = {}, ): Plan { + const unresolved = opts.unresolved ?? new Set(); + + for (const d of desired) { + if (!isKnownType(d.type)) { + throw new Error( + `Unknown resource type "${d.type}" for "${d.key}" — no apply tier defined. Add it to TYPE_TIER.`, + ); + } + } + const desiredByKey = new Map(desired.map((d) => [d.key, d])); const creates: PlanItem[] = []; const updates: PlanItem[] = []; @@ -61,24 +102,42 @@ export function computePlan( key: d.key, id: null, action: "create", - changes: creationChanges(d.fields), + changes: diffFields(d.fields, {}), + }); + continue; + } + if (managed.type !== d.type) { + throw new Error( + `Logical key "${d.key}" is a ${d.type} in the config but a ${managed.type} in the state file. ` + + `Rename one to reconcile.`, + ); + } + if (unresolved.has(d.key)) { + // Type has no registry entry — we could not fetch its actual value, so we cannot diff it. + updates.push({ + type: d.type, + key: d.key, + id: managed.id, + action: "no-op", + changes: [], + note: "unresolved-type", }); continue; } - const actual = actualById.get(managed.id); - if (!actual) { + const a = actual.get(d.key); + if (!a) { creates.push({ type: d.type, key: d.key, id: null, action: "create", - changes: creationChanges(d.fields), - recreated: true, + changes: diffFields(d.fields, {}), + note: "recreate", }); continue; } - const changes = diffFields(d.fields, actual); - const drift = driftFields(managed.fields, actual); + const changes = diffFields(d.fields, a); + const drift = driftFields(managed.fields, a); updates.push({ type: d.type, key: d.key, @@ -93,21 +152,44 @@ export function computePlan( if (desiredByKey.has(managed.key)) { continue; } - const actual = actualById.get(managed.id); - deletes.push({ - type: managed.type, - key: managed.key, - id: managed.id, - action: actual ? "delete" : "no-op", - changes: [], - }); + if (unresolved.has(managed.key)) { + deletes.push({ + type: managed.type, + key: managed.key, + id: managed.id, + action: "no-op", + changes: [], + note: "unresolved-type", + }); + continue; + } + const a = actual.get(managed.key); + if (!a) { + // Already gone from ChurchTools but still in the state file — surface it so the user prunes state (not a silent no-op). + deletes.push({ + type: managed.type, + key: managed.key, + id: managed.id, + action: "no-op", + changes: [], + note: "stale", + }); + continue; + } + deletes.push({ type: managed.type, key: managed.key, id: managed.id, action: "delete", changes: [] }); } - const applyOrder = orderKeys(desired); - const rank = new Map(applyOrder.map((key, i) => [key, i])); + const rank = new Map(orderKeys(desired).map((key, i) => [key, i])); const ordered = [...creates, ...updates].sort((a, b) => (rank.get(a.key) ?? 0) - (rank.get(b.key) ?? 0)); - // Deletes run in reverse dependency order (highest tier first). - deletes.sort((a, b) => tierOf(b.type) - tierOf(a.type)); + + // Deletes run in reverse dependency order. Reuse the same topological sort (reversed) rather than a + // coarser tier-only heuristic, so intra-tier edges are honoured once the state file carries them. + const deleteRank = new Map( + orderKeys(deletes.map((it) => ({ type: it.type, key: it.key, fields: {}, dependsOn: [] }))).map( + (key, i) => [key, i], + ), + ); + deletes.sort((a, b) => (deleteRank.get(b.key) ?? 0) - (deleteRank.get(a.key) ?? 0)); return { items: [...ordered, ...deletes] }; } diff --git a/src/engine/render.ts b/src/engine/render.ts index 04bdcb7..3628e44 100644 --- a/src/engine/render.ts +++ b/src/engine/render.ts @@ -25,15 +25,17 @@ function fmt(value: unknown): string { export function renderPlan(plan: Plan): string { const changed = plan.items.filter((i) => i.action !== "no-op"); const drifted = plan.items.filter((i) => i.drift && i.drift.length > 0); + const stale = plan.items.filter((i) => i.note === "stale"); + const unresolved = plan.items.filter((i) => i.note === "unresolved-type"); const lines: string[] = []; - if (changed.length === 0 && drifted.length === 0) { + if (changed.length === 0 && drifted.length === 0 && stale.length === 0 && unresolved.length === 0) { return pc.green("No changes. Desired state matches ChurchTools."); } for (const item of changed) { const id = item.id !== null ? pc.dim(` (#${item.id})`) : ""; - const note = item.recreated ? pc.yellow(" [recreate — missing in ChurchTools]") : ""; + const note = item.note === "recreate" ? pc.yellow(" [recreate — missing in ChurchTools]") : ""; lines.push(` ${sigil(item.action)} ${item.type}.${item.key}${id}${note}`); for (const c of item.changes) { lines.push( @@ -56,6 +58,22 @@ export function renderPlan(plan: Plan): string { } } + if (stale.length > 0) { + lines.push(""); + lines.push(pc.yellow("Stale state entries (already gone from ChurchTools — prune from the state file):")); + for (const item of stale) { + lines.push(` ! ${item.type}.${item.key} (#${item.id})`); + } + } + + if (unresolved.length > 0) { + lines.push(""); + lines.push(pc.yellow("Unresolved types (no registry entry — not diffed, left untouched):")); + for (const item of unresolved) { + lines.push(` ? ${item.type}.${item.key} (#${item.id})`); + } + } + const s = summarize(plan); lines.push(""); lines.push(pc.bold(`Plan: ${s.create} to create, ${s.update} to update, ${s.delete} to delete.`)); diff --git a/src/engine/types.ts b/src/engine/types.ts index d9875f5..66ac111 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -16,6 +16,14 @@ export interface DesiredResource { export type PlanAction = "create" | "update" | "delete" | "no-op"; +/** + * A resource that needs surfacing beyond its plain action: + * - `recreate` — desired + managed, but vanished from ChurchTools (a create that replaces a dead id). + * - `stale` — managed + dropped from config + already gone from ChurchTools (nothing to delete; prune from state). + * - `unresolved-type` — managed but its type has no registry entry, so it cannot be fetched/diffed (left untouched). + */ +export type PlanNote = "recreate" | "stale" | "unresolved-type"; + export interface FieldChange { field: string; from: unknown; @@ -32,8 +40,8 @@ export interface PlanItem { changes: FieldChange[]; /** Manual changes in ChurchTools since adoption (last-known snapshot vs actual). */ drift?: FieldChange[]; - /** A managed resource that has vanished from ChurchTools and will be recreated. */ - recreated?: boolean; + /** A non-standard state the plan must surface (see {@link PlanNote}). */ + note?: PlanNote; } export interface Plan { diff --git a/src/util/concurrency.ts b/src/util/concurrency.ts new file mode 100644 index 0000000..06219e1 --- /dev/null +++ b/src/util/concurrency.ts @@ -0,0 +1,24 @@ +/** + * Run an async mapper over `items` with at most `concurrency` in flight at once. + * Results preserve input order. Errors from `fn` propagate — callers that want + * partial-failure tolerance should catch inside `fn` and return a sentinel. + */ +export async function mapConcurrent( + items: readonly T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results = new Array(items.length); + let cursor = 0; + const workers = Math.max(1, Math.min(concurrency, items.length)); + + const run = async (): Promise => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await fn(items[index]!, index); + } + }; + + await Promise.all(Array.from({ length: workers }, () => run())); + return results; +} diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..2e8b8cd --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { mapConcurrent } from "../src/util/concurrency.js"; + +describe("mapConcurrent", () => { + it("preserves input order and maps every item", async () => { + const result = await mapConcurrent([1, 2, 3, 4], 2, async (n) => n * 2); + expect(result).toEqual([2, 4, 6, 8]); + }); + + it("never runs more than the concurrency limit at once", async () => { + let active = 0; + let peak = 0; + await mapConcurrent( + Array.from({ length: 10 }, (_, i) => i), + 3, + async () => { + active++; + peak = Math.max(peak, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active--; + }, + ); + expect(peak).toBeLessThanOrEqual(3); + expect(peak).toBeGreaterThan(1); // actually parallel, not serialised + }); + + it("handles an empty list", async () => { + expect(await mapConcurrent([], 4, async (x) => x)).toEqual([]); + }); +}); diff --git a/tests/context.test.ts b/tests/context.test.ts index 0e27ad7..fd3375b 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { createContext, evaluateConfig } from "../src/config/context.js"; +import { isKnownType } from "../src/engine/graph.js"; describe("config context", () => { it("builds desired resources from DSL calls, separating key/parent from fields", () => { @@ -40,6 +41,19 @@ describe("config context", () => { expect(() => ct.campus({ name: "no key" } as never)).toThrow(/key/); }); + it("every DSL resource type has an apply tier (context and graph stay in sync)", () => { + const { ct, resources } = createContext(); + ct.campus({ key: "a", name: "a" }); + ct.group({ key: "b", name: "b" }); + ct.groupType({ key: "c", name: "c" }); + ct.ageGroup({ key: "d", name: "d" }); + ct.targetGroup({ key: "e", name: "e" }); + ct.relationshipType({ key: "f", name: "f" }); + for (const r of resources) { + expect(isKnownType(r.type), `type "${r.type}" is missing from TYPE_TIER`).toBe(true); + } + }); + it("evaluateConfig runs a module against a fresh context (blueprints + loops)", async () => { const resources = await evaluateConfig((ct) => { for (const c of ["mainz", "berlin"]) { diff --git a/tests/load.test.ts b/tests/load.test.ts index af1ff01..9efc2c9 100644 --- a/tests/load.test.ts +++ b/tests/load.test.ts @@ -17,4 +17,8 @@ describe("loadConfig", () => { /default-export a function/, ); }); + + it("gives a friendly error when the config file is missing", async () => { + await expect(loadConfig(join(here, "fixtures/does-not-exist.config.ts"))).rejects.toThrow(/not found/i); + }); }); diff --git a/tests/plan.test.ts b/tests/plan.test.ts index 9df60eb..97da59d 100644 --- a/tests/plan.test.ts +++ b/tests/plan.test.ts @@ -17,10 +17,19 @@ function managed(key: string, id: number, fields: Record): Mana return { type: "campus", id, key, fields, adoptedAt: "t", updatedAt: "t" }; } +function managedT(type: string, key: string, id: number, fields: Record): ManagedResource { + return { type, id, key, fields, adoptedAt: "t", updatedAt: "t" }; +} + function stateOf(...entries: ManagedResource[]): State { return { version: 1, host: HOST, resources: Object.fromEntries(entries.map((e) => [e.key, e])) }; } +/** actual is keyed by logical key. */ +function actualOf(entries: Record>): Map> { + return new Map(Object.entries(entries)); +} + describe("computePlan", () => { it("plans a create for a config resource absent from state", () => { const plan = computePlan([desired("mainz", { name: "Mainz" })], stateOf(), new Map()); @@ -33,35 +42,78 @@ describe("computePlan", () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], stateOf(managed("mainz", 0, { name: "Mainz" })), - new Map([[0, { name: "Mainz" }]]), + actualOf({ mainz: { name: "Mainz" } }), ); expect(plan.items[0]).toMatchObject({ action: "no-op", key: "mainz", id: 0 }); }); + it("does not collide two resources of different types that share a CT id", () => { + // campus 'mainz' #0 and group-type 'lead' #0 — a numeric-id map would overwrite one with the other. + const plan = computePlan( + [desired("mainz", { name: "Mainz" }), desired("lead", { name: "Lead" }, { type: "group-type" })], + stateOf(managed("mainz", 0, { name: "Mainz" }), managedT("group-type", "lead", 0, { name: "Lead" })), + actualOf({ mainz: { name: "Mainz" }, lead: { name: "Lead" } }), + ); + const byKey = Object.fromEntries(plan.items.map((i) => [i.key, i])); + expect(byKey.mainz?.action).toBe("no-op"); + expect(byKey.lead?.action).toBe("no-op"); + }); + + it("throws when a config key collides with a different type in state", () => { + expect(() => + computePlan( + [desired("x", { name: "A" }, { type: "campus" })], + stateOf(managedT("group", "x", 1, { name: "A" })), + actualOf({ x: { name: "A" } }), + ), + ).toThrow(/campus.*group|group.*campus/i); + }); + + it("throws for a desired resource whose type has no apply tier", () => { + expect(() => + computePlan([desired("x", { name: "A" }, { type: "made-up" })], stateOf(), new Map()), + ).toThrow(/Unknown resource type/); + }); + it("plans an update with just the changed fields", () => { const plan = computePlan( [desired("mainz", { name: "Mainz HQ", shortName: "MZ" })], stateOf(managed("mainz", 5, { name: "Mainz", shortName: "MZ" })), - new Map([[5, { name: "Mainz", shortName: "MZ" }]]), + actualOf({ mainz: { name: "Mainz", shortName: "MZ" } }), ); expect(plan.items[0]).toMatchObject({ action: "update", id: 5 }); expect(plan.items[0]?.changes).toEqual([{ field: "name", from: "Mainz", to: "Mainz HQ" }]); }); + it("does not flag a mere object-key-order difference as a change", () => { + const plan = computePlan( + [desired("mz", { nameTranslated: { en: "M", de: "M" } })], + stateOf(managed("mz", 1, { nameTranslated: { en: "M", de: "M" } })), + actualOf({ mz: { nameTranslated: { de: "M", en: "M" } } }), // reversed key order + ); + expect(plan.items[0]?.action).toBe("no-op"); + expect(plan.items[0]?.changes).toEqual([]); + }); + it("plans a delete for a managed resource dropped from config", () => { const plan = computePlan( [], stateOf(managed("old", 9, { name: "Old" })), - new Map([[9, { name: "Old" }]]), + actualOf({ old: { name: "Old" } }), ); expect(plan.items[0]).toMatchObject({ action: "delete", key: "old", id: 9 }); }); + it("surfaces a dropped resource already gone from ChurchTools as stale, not a silent no-op", () => { + const plan = computePlan([], stateOf(managed("old", 9, { name: "Old" })), new Map()); + expect(plan.items[0]).toMatchObject({ action: "no-op", key: "old", id: 9, note: "stale" }); + }); + it("reports drift when ChurchTools differs from the last-known snapshot", () => { const plan = computePlan( [desired("mainz", { name: "Mainz" })], stateOf(managed("mainz", 5, { name: "Mainz", shortName: "MZ" })), - new Map([[5, { name: "Mainz", shortName: "CHANGED" }]]), + actualOf({ mainz: { name: "Mainz", shortName: "CHANGED" } }), ); expect(plan.items[0]?.drift).toEqual([{ field: "shortName", from: "MZ", to: "CHANGED" }]); }); @@ -72,12 +124,22 @@ describe("computePlan", () => { stateOf(managed("mainz", 5, { name: "Mainz" })), new Map(), // actual absent → 404 ); - expect(plan.items[0]).toMatchObject({ action: "create", recreated: true }); + expect(plan.items[0]).toMatchObject({ action: "create", note: "recreate" }); + }); + + it("leaves an unresolved-type managed resource untouched instead of recreating it", () => { + const plan = computePlan( + [desired("ag", { name: "A" }, { type: "age-group" })], + stateOf(managedT("age-group", "ag", 3, { name: "A" })), + new Map(), // could not fetch: type has no registry entry + { unresolved: new Set(["ag"]) }, + ); + expect(plan.items[0]).toMatchObject({ action: "no-op", note: "unresolved-type" }); }); it("never surfaces unmanaged resources", () => { - // actual contains an id that is neither in config nor state — must be ignored. - const plan = computePlan([], stateOf(), new Map([[42, { name: "Unmanaged" }]])); + // actual contains a key that is neither in config nor state — must be ignored. + const plan = computePlan([], stateOf(), actualOf({ ghost: { name: "Unmanaged" } })); expect(plan.items).toHaveLength(0); }); @@ -92,4 +154,13 @@ describe("computePlan", () => { ); expect(plan.items.map((i) => i.key)).toEqual(["parent", "child"]); }); + + it("orders deletes in reverse tier order (higher tier first)", () => { + const plan = computePlan( + [], + stateOf(managedT("campus", "c", 1, {}), managedT("group", "g", 2, {})), + actualOf({ c: {}, g: {} }), + ); + expect(plan.items.map((i) => i.key)).toEqual(["g", "c"]); + }); }); diff --git a/tests/render.test.ts b/tests/render.test.ts index acb0b48..5876339 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -49,4 +49,38 @@ describe("renderPlan", () => { expect(out).toMatch(/Drift detected/); expect(out).toMatch(/shortName/); }); + + it("surfaces stale state entries instead of reporting no changes", () => { + const out = renderPlan({ + items: [{ type: "campus", key: "old", id: 9, action: "no-op", changes: [], note: "stale" }], + }); + expect(out).not.toMatch(/No changes/); + expect(out).toMatch(/Stale state entries/); + expect(out).toMatch(/campus\.old/); + }); + + it("surfaces unresolved types", () => { + const out = renderPlan({ + items: [{ type: "age-group", key: "ag", id: 3, action: "no-op", changes: [], note: "unresolved-type" }], + }); + expect(out).not.toMatch(/No changes/); + expect(out).toMatch(/Unresolved types/); + expect(out).toMatch(/age-group\.ag/); + }); + + it("marks a recreate", () => { + const out = renderPlan({ + items: [ + { + type: "campus", + key: "mainz", + id: null, + action: "create", + changes: [{ field: "name", from: undefined, to: "Mainz" }], + note: "recreate", + }, + ], + }); + expect(out).toMatch(/recreate/); + }); });