diff --git a/README.md b/README.md index 2ccb850..dceab93 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ 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 @@ -53,7 +53,22 @@ 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..45ef94f --- /dev/null +++ b/src/commands/plan.ts @@ -0,0 +1,81 @@ +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 { mapConcurrent } from "../util/concurrency.js"; +import { info, warn, out } from "../ui.js"; + +interface PlanOptions { + config?: string; + state?: string; + 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)") + .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 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(); + // 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) { + 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)); + actual.set(managed.key, spec.managedFields(raw)); + } catch (err) { + if (err instanceof CtApiError && err.status === 404) { + return; // vanished in CT — the plan will propose recreating (or pruning) it + } + // 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, actual, { unresolved }); + if (opts.json) { + out(plan); + } 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; + } + }); +} diff --git a/src/config/context.ts b/src/config/context.ts new file mode 100644 index 0000000..ace9e59 --- /dev/null +++ b/src/config/context.ts @@ -0,0 +1,76 @@ +/** + * 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); + }; + // 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"), + 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..b9452d9 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,38 @@ +/** + * 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 { access } from "node:fs/promises"; +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 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(resolved, { 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..a791d37 --- /dev/null +++ b/src/engine/graph.ts @@ -0,0 +1,89 @@ +/** + * 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; +} + +/** + * 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. + */ +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..afd70b7 --- /dev/null +++ b/src/engine/plan.ts @@ -0,0 +1,195 @@ +/** + * 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. + * + * `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, 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 { + 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 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)) { + if (!deepEqual(actual[field], to)) { + changes.push({ field, from: actual[field], to }); + } + } + return changes; +} + +/** 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 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, + 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[] = []; + 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: 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 a = actual.get(d.key); + if (!a) { + creates.push({ + type: d.type, + key: d.key, + id: null, + action: "create", + changes: diffFields(d.fields, {}), + note: "recreate", + }); + continue; + } + const changes = diffFields(d.fields, a); + const drift = driftFields(managed.fields, a); + 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; + } + 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 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. 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 new file mode 100644 index 0000000..3628e44 --- /dev/null +++ b/src/engine/render.ts @@ -0,0 +1,81 @@ +/** + * 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 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 && 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.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( + 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)})`, + ); + } + } + } + + 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.`)); + return lines.join("\n"); +} diff --git a/src/engine/types.ts b/src/engine/types.ts new file mode 100644 index 0000000..66ac111 --- /dev/null +++ b/src/engine/types.ts @@ -0,0 +1,58 @@ +/** + * 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"; + +/** + * 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; + 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 non-standard state the plan must surface (see {@link PlanNote}). */ + note?: PlanNote; +} + +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/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 new file mode 100644 index 0000000..fd3375b --- /dev/null +++ b/tests/context.test.ts @@ -0,0 +1,66 @@ +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", () => { + 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("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"]) { + 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..9efc2c9 --- /dev/null +++ b/tests/load.test.ts @@ -0,0 +1,24 @@ +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/, + ); + }); + + 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 new file mode 100644 index 0000000..97da59d --- /dev/null +++ b/tests/plan.test.ts @@ -0,0 +1,166 @@ +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 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()); + 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" })), + 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" })), + 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" })), + 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" })), + actualOf({ mainz: { 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", 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 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); + }); + + 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"]); + }); + + 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 new file mode 100644 index 0000000..5876339 --- /dev/null +++ b/tests/render.test.ts @@ -0,0 +1,86 @@ +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/); + }); + + 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/); + }); +});