Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ 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" });
ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 });
// Hierarchy is opt-in and multi-parent: `parents` are managed group keys, each declared
// in this config. Omit it to leave a group's hierarchy unmanaged; edges to unmanaged
// groups stay invisible. (`parent:` is unrelated — an ordering hint only, not hierarchy.)
ct.group({ key: "mainz_kids_lead", name: "Mainz · Kids Leitung", groupTypeId: 2, parents: ["mainz_area"] });
};
```

Expand Down
22 changes: 21 additions & 1 deletion src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { parentIdsByGroupId, applyHierarchy, type HierarchyEntry } from "../engine/hierarchy.js";
import { mapConcurrent } from "../util/concurrency.js";
import { info, warn, out } from "../ui.js";

Expand Down Expand Up @@ -63,7 +64,26 @@ export function planCommand(): Command {
}
});

const plan = computePlan(desired, state, actual, { unresolved });
// Group hierarchy: one bulk call, folded into each opted-in group's `parents` set-field.
let parentIds = new Map<number, number[]>();
let hierarchyOk = true;
const hasManagedGroups = Object.values(state.resources).some((m) => m.type === "group");
if (hasManagedGroups) {
try {
const raw = await client.get<HierarchyEntry[]>("/groups/hierarchies");
parentIds = parentIdsByGroupId(Array.isArray(raw) ? raw : []);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
fetchErrors.push(`group hierarchies: ${message}`);
warn(`Failed to fetch group hierarchies: ${message}`);
hierarchyOk = false;
}
}
// On a hierarchy-fetch failure, leave `parents` undiffed rather than folding an empty map
// (which would fabricate spurious "add all parents" changes). The plan is flagged INCOMPLETE below.
const desiredWithHierarchy = hierarchyOk ? applyHierarchy(desired, state, actual, parentIds) : desired;

const plan = computePlan(desiredWithHierarchy, state, actual, { unresolved });
if (opts.json) {
out(plan);
} else {
Expand Down
56 changes: 50 additions & 6 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*
* 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" });
* ct.group({ key: "mainz_area", name: "Mainz · Bereiche", groupTypeId: 2 });
* ct.group({ key: "mainz_kids", name: "Mainz · Kids", groupTypeId: 2, parents: ["mainz_area"] });
* };
*
* The context is injected (no global state), so blueprints are just functions
Expand All @@ -14,7 +15,14 @@ import type { DesiredResource } from "../engine/types.js";

export interface ResourceInput {
key: string;
/** Ordering hint: apply this resource after `parent`. A dependency edge only — NOT managed hierarchy. */
parent?: string;
/**
* Managed parent groups (group→group hierarchy). Opt-in: omit to leave a group's hierarchy
* unmanaged; `[]` means "managed with no parents". Each key must reference a group declared
* in the same config.
*/
parents?: string[];
dependsOn?: string[];
[field: string]: unknown;
}
Expand All @@ -31,15 +39,50 @@ export interface ConfigContext {
export type ConfigModule = (ct: ConfigContext) => void | Promise<void>;

function toDesired(type: string, input: ResourceInput): DesiredResource {
const { key, parent, dependsOn = [], ...fields } = input;
const { key, parent, parents, 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);
// A nullish/empty `parent` is "no parent", not an opt-in to managed-empty hierarchy.
if (parent != null && typeof parent !== "string") {
throw new Error(`${type} "${key}": "parent" must be a string key.`);
}
if (parents !== undefined && (!Array.isArray(parents) || parents.some((p) => typeof p !== "string"))) {
throw new Error(`${type} "${key}": "parents" must be an array of string group keys.`);
}
// `parent` is an ordering hint only — a dependency edge, never a diffed/managed field
// (its pre-hierarchy meaning; a `parent` may point at a campus). Group hierarchy is
// managed opt-in via `parents`: `undefined` → unmanaged, `[]` → managed with no parents.
const parentKey = typeof parent === "string" && parent !== "" ? parent : undefined;
const parentKeys = parents !== undefined ? [...new Set(parents)] : undefined;
const edges = [...new Set([...dependsOn, ...(parentKey ? [parentKey] : []), ...(parentKeys ?? [])])];
return { type, key, fields, parent: parentKey, parents: parentKeys, dependsOn: edges };
}

/**
* Every managed hierarchy parent must reference a group declared in the same config.
* A parent that resolves to nothing (typo, unmanaged group) or to a non-group would
* diff forever against the managed-only actual side, so reject it up front rather than
* emit a plan that can never converge.
*/
function validateReferences(resources: DesiredResource[]): void {
const byKey = new Map(resources.map((r) => [r.key, r]));
for (const r of resources) {
for (const parentKey of r.parents ?? []) {
const target = byKey.get(parentKey);
if (!target) {
throw new Error(
`Group "${r.key}" declares hierarchy parent "${parentKey}", which is not declared in this config. ` +
`Managed parents must reference a group by its key (omit unmanaged parents entirely).`,
);
}
if (target.type !== "group") {
throw new Error(
`Group "${r.key}" declares hierarchy parent "${parentKey}", but "${parentKey}" is a ${target.type}, not a group.`,
);
}
}
}
return { type, key, fields, parent, dependsOn: edges };
}

export function createContext(): { ct: ConfigContext; resources: DesiredResource[] } {
Expand Down Expand Up @@ -72,5 +115,6 @@ export function createContext(): { ct: ConfigContext; resources: DesiredResource
export async function evaluateConfig(mod: ConfigModule): Promise<DesiredResource[]> {
const { ct, resources } = createContext();
await mod(ct);
validateReferences(resources);
return resources;
}
97 changes: 97 additions & 0 deletions src/engine/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Group hierarchy (parent/child) is a many-to-many DAG in ChurchTools: a group
* can have several parents. `GET /groups/hierarchies` returns, per group, the
* ids of its parents and children.
*
* We surface hierarchy in the plan as an opt-in `parents` set-field on a group,
* resolved to logical keys and **restricted to managed groups** — an edge to an
* unmanaged group is invisible (managed-guard), never diffed or proposed for
* removal.
*/

import type { State } from "../state/state.js";
import type { DesiredResource } from "./types.js";

export interface HierarchyEntry {
groupId: number;
parents?: number[];
children?: number[];
}

/** Map each group id to its parent group ids. */
export function parentIdsByGroupId(entries: HierarchyEntry[]): Map<number, number[]> {
const map = new Map<number, number[]>();
for (const entry of entries) {
if (typeof entry.groupId === "number") {
map.set(entry.groupId, Array.isArray(entry.parents) ? entry.parents : []);
}
}
return map;
}

/**
* The managed parent keys for a group: its parent ids that are themselves under
* management, mapped to their logical keys and sorted (stable for set diffing).
*/
export function managedParentKeys(parentIds: number[], groupIdToKey: Map<number, string>): string[] {
const keys: string[] = [];
for (const id of parentIds) {
const key = groupIdToKey.get(id);
if (key !== undefined) {
keys.push(key);
}
}
return keys.sort();
}

/**
* Fold group hierarchy into the plan inputs as a `parents` set-field.
*
* Only groups that opted into hierarchy (`parents !== undefined` on the desired
* side) are touched: their `actual` record gets a `parents` set of managed
* parent keys (from `parentIdsByGroup`), and their desired gets a sorted
* `parents` field so `computePlan` diffs it generically. Groups that did not opt
* in are left untouched on both sides — their hierarchy is not managed.
*
* Two caveats `parents` inherits from being a synthetic field:
* - It is a *pseudo-field* of logical keys, NOT a real ChurchTools group column.
* Apply must route it to the per-edge endpoint (`PUT`/`DELETE
* /groups/{id}/parents/{parentId}`) — never PATCH it onto the group object.
* - The adopt snapshot (`managed.fields`) never carries `parents`, so a
* third-party re-parent surfaces as an ordinary `update`, not under drift.
*/
export function applyHierarchy(
desired: DesiredResource[],
state: State,
actual: Map<string, Record<string, unknown>>,
parentIdsByGroup: Map<number, number[]>,
): DesiredResource[] {
const groupIdToKey = new Map<number, string>();
for (const managed of Object.values(state.resources)) {
if (managed.type === "group") {
groupIdToKey.set(managed.id, managed.key);
}
}

// Only groups whose desired opted into hierarchy are managed — mirror the desired-side guard
// below so we never write a `parents` field onto the actual of an opted-out group.
const optedIn = new Set(
desired.filter((d) => d.type === "group" && d.parents !== undefined).map((d) => d.key),
);

for (const managed of Object.values(state.resources)) {
if (managed.type !== "group" || !optedIn.has(managed.key)) {
continue;
}
const a = actual.get(managed.key);
if (a) {
a.parents = managedParentKeys(parentIdsByGroup.get(managed.id) ?? [], groupIdToKey);
}
}

return desired.map((d) =>
d.type === "group" && d.parents !== undefined
? { ...d, fields: { ...d.fields, parents: [...d.parents].sort() } }
: d,
);
}
6 changes: 4 additions & 2 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ export interface DesiredResource {
key: string;
/** Managed fields — same shape as the Phase 2 registry snapshot, so they diff like-for-like against actual. */
fields: Record<string, unknown>;
/** Parent logical key (group hierarchy). Also contributes a dependency edge. */
/** Ordering hint: a dependency edge only (may point at a campus). NOT managed hierarchy — see `parents`. */
parent?: string;
/** Logical keys this resource must be applied after (includes `parent`). */
/** Managed parent group keys (hierarchy). `undefined` = hierarchy not managed for this group; `[]` = should have no managed parents. */
parents?: string[];
/** Logical keys this resource must be applied after (includes `parent`/`parents`). */
dependsOn: string[];
}

Expand Down
58 changes: 58 additions & 0 deletions tests/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,64 @@ describe("config context", () => {
expect(resources[0]?.dependsOn).toEqual(["type_x", "lead"]);
});

it("captures multiple parents (hierarchy is a DAG) as an opt-in set, not in fields", () => {
const { ct, resources } = createContext();
ct.group({ key: "team", name: "Team", parents: ["area_a", "area_b"] });
expect(resources[0]?.parents).toEqual(["area_a", "area_b"]);
expect(resources[0]?.dependsOn).toEqual(["area_a", "area_b"]);
expect(resources[0]?.fields).not.toHaveProperty("parents");
});

it("treats parents as opt-in: omitted → undefined, explicit [] → managed-empty", () => {
const { ct, resources } = createContext();
ct.group({ key: "no_hierarchy", name: "N" });
ct.group({ key: "empty_hierarchy", name: "E", parents: [] });
expect(resources[0]?.parents).toBeUndefined();
expect(resources[1]?.parents).toEqual([]);
});

it("de-duplicates the `parents` set", () => {
const { ct, resources } = createContext();
ct.group({ key: "team", name: "T", parents: ["lead", "other", "lead"] });
expect(resources[0]?.parents).toEqual(["lead", "other"]);
});

it("keeps `parent` out of managed hierarchy: it is an ordering edge only, never in `parents`", () => {
const { ct, resources } = createContext();
ct.group({ key: "team", name: "T", parent: "lead", parents: ["other"] });
expect(resources[0]?.parent).toBe("lead");
expect(resources[0]?.parents).toEqual(["other"]); // `lead` is NOT folded into managed parents
expect(resources[0]?.dependsOn).toEqual(["lead", "other"]); // both still contribute ordering edges
});

it("treats a falsy `parent` as absent, not as opt-in to managed-empty hierarchy", () => {
const { ct, resources } = createContext();
ct.group({ key: "g", name: "G", parent: "" });
expect(resources[0]?.parents).toBeUndefined(); // no hierarchy management
expect(resources[0]?.dependsOn).toEqual([]);
});

it("rejects a non-array / non-string `parents`", () => {
const { ct } = createContext();
expect(() => ct.group({ key: "g", name: "G", parents: "area" as never })).toThrow(/array of string/);
expect(() => ct.group({ key: "h", name: "H", parents: [1] as never })).toThrow(/array of string/);
});

it("rejects a hierarchy parent that is not a declared group", async () => {
await expect(
evaluateConfig((ct) => {
ct.campus({ key: "mz", name: "Mainz" });
ct.group({ key: "kids", name: "Kids", parents: ["mz"] }); // mz is a campus
}),
).rejects.toThrow(/is a campus, not a group/);

await expect(
evaluateConfig((ct) => {
ct.group({ key: "kids", name: "Kids", parents: ["ghost"] }); // never declared
}),
).rejects.toThrow(/not declared in this config/);
});

it("rejects a duplicate logical key", () => {
const { ct } = createContext();
ct.campus({ key: "mainz", name: "A" });
Expand Down
Loading
Loading