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
47 changes: 47 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,53 @@ intentional-duplicate case. If a create 400s on this guard without the flag set,
that should be adopted with `ct adopt group <id> --key <key>`) and the opt-in as
the alternative.

### Changing a group's type

`groupTypeId` is managed, but changing it on an existing group is a **migration**,
not a field update. ChurchTools refuses the field on the ordinary group update
(`HTTP 400 groupTypeId: validation.always.invalid`) and exposes
`POST /groups/{id}/grouptype` instead, which takes a mapping from every role of the
old type onto a role of the new one. That mapping decides **where existing
memberships land**: whoever holds a role mapping to `X` holds `X` afterwards.

`ct plan` resolves the mapping up front and renders it, so the diff shows what the
apply will actually do:

```
~ group.youth_team (#42)
groupTypeId: 5 -> 4
via POST /groups/42/grouptype — role mapping (members follow their role):
Mitglied -> Mitglied (7 members, matched by name)
Leiter -> Leiter (2 members, matched by name)
Supporter -> Mitglied (0 members, matched by empty-role)
```

ct derives a mapping only where the answer cannot cost anyone their role:

| matched by | when |
| ------------ | ------------------------------------------------------------- |
| `name` | exactly one role of the target type carries the same name |
| `empty-role` | the old role holds no members here, so no membership can move |
| `declared` | you said so (below) — always wins |

A role that **holds members** and has no same-named target is a decision ct will
not make for you. The plan refuses, naming the role, its member count and the
target type's roles, and you answer it on the group:

```ts
ct.group({
key: "youth_team",
name: "Youth Team",
groupType: "merkmal",
roleMapping: { Supporter: "Mitglied" },
});
```

`roleMapping` is old role **name** → new role name (so one config stays portable
across hosts), compared as slugs. Like `allowDuplicateName` it is never a managed
field: not diffed, not in state, never adopted, and read only when the type
actually changes.

## Output conventions

Machine-readable output goes to **stdout** (pipe/`jq` it); human status lines go
Expand Down
28 changes: 14 additions & 14 deletions docs/group-field-decisions.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/handbuch/blueprints.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ sources:
- src/config/context.ts
- src/engine/graph.ts
- src/engine/hierarchy.ts
sources_hash: 636599577c600553
sources_hash: 57e2a48f85e93d55
reviewed: 2026-08-28
---

Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: ffdca84fefe83fde
sources_hash: 802017b4678885e0
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sources:
- src/resolve/resolver.ts
- src/resolve/refs.ts
- src/config/context.ts
sources_hash: ac44a97574ed9ba9
sources_hash: 398256b5381fd827
reviewed: 2026-08-28
---

Expand Down
28 changes: 28 additions & 0 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ export interface ResourceInput {
* NEVER force by default — omit (or `false`) to keep CT's guard on.
*/
allowDuplicateName?: boolean;
/**
* Group-only (#171): where existing memberships land when this group's `groupTypeId` changes, as
* old role NAME → new role NAME. Consulted ONLY on a type change; never diffed, never in state.
*
* ct derives the mapping itself wherever the answer cannot cost anyone their role — a same-named
* role in the target type, or a role holding no members — and refuses at plan time otherwise. This
* is how that refusal is answered. Names, not ids, so one config stays portable across hosts.
*/
roleMapping?: Record<string, string>;
[field: string]: unknown;
}

Expand Down Expand Up @@ -537,6 +546,7 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir
dynamic,
memberFields,
allowDuplicateName,
roleMapping,
...fields
} = input;
if (!key || typeof key !== "string") {
Expand All @@ -553,6 +563,23 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir
throw new Error(`${type} "${key}": "allowDuplicateName" must be a boolean.`);
}
}
// Group-only role mapping for a type migration (#171). Destructured out above like
// `allowDuplicateName`, so it never reaches `fields` and never trips the unknown-field warning.
if (roleMapping !== undefined) {
if (type !== "group") {
throw new Error(`${type} "${key}": "roleMapping" is only valid on a group.`);
}
if (typeof roleMapping !== "object" || roleMapping === null || Array.isArray(roleMapping)) {
throw new Error(`${type} "${key}": "roleMapping" must be an object of old role name -> new role name.`);
}
for (const [from, to] of Object.entries(roleMapping)) {
if (typeof to !== "string" || to.trim() === "") {
throw new Error(
`${type} "${key}": "roleMapping.${from}" must be a non-empty target role name (got ${JSON.stringify(to)}).`,
);
}
}
}
// 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.`);
Expand Down Expand Up @@ -668,6 +695,7 @@ function toDesired(type: string, input: ResourceInput, location?: string): Desir
dependsOn: edges,
preventDestroy,
allowDuplicateName,
roleMapping,
};
}

Expand Down
8 changes: 8 additions & 0 deletions src/engine/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ManagedResource, State } from "../state/state.js";
import type { DesiredResource, Plan } from "./types.js";
import { RESOURCES, type CtWriteClient } from "../resources/registry.js";
import { computePlan } from "./plan.js";
import { resolveGroupTypeMigrations } from "./grouptype.js";
import { foldSynthetic } from "./synthetic.js";
import { Resolver } from "../resolve/resolver.js";
import { collectPendingRefKeys } from "../resolve/refs.js";
Expand Down Expand Up @@ -162,6 +163,13 @@ export async function buildPlan(
});

const plan = computePlan(ordered, state, actual, { unresolved, fetchFailed });

// A `groupTypeId` change is a MIGRATION, not a field update (#171): CT refuses the field on the
// ordinary update and wants `POST /groups/{id}/grouptype` with a role mapping. Resolved here, while
// a client is in hand, so the plan renders the mapping the apply will send — and so an underivable
// mapping fails now instead of halfway through a write.
await resolveGroupTypeMigrations(client, plan, new Map(ordered.map((d) => [d.key, d.roleMapping])));

const warnings = [...fetchWarnings, ...(folded.warnings ?? [])];
return { plan, actual, fetchErrors, ...(warnings.length > 0 ? { warnings } : {}) };
}
20 changes: 18 additions & 2 deletions src/engine/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { upsert, saveState } from "../state/state.js";
import type { FieldChange, Plan, PlanItem } from "./types.js";
import { RESOURCES, type CtWriteClient } from "../resources/registry.js";
import { assertNotPeople } from "./guard.js";
import { roleMappingPayload } from "./grouptype.js";
import { isSyntheticField, syntheticField } from "./synthetic.js";
import { reresolvePendingValue } from "../resolve/resolver.js";
import { hasPendingRef } from "../resolve/refs.js";
Expand Down Expand Up @@ -234,7 +235,22 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise<Execut
// exactly that regardless of verb.
const actualFields = item.actual ?? state.resources[item.key]?.fields ?? {};
const snapshot = snapshotFromChanges(actualFields, changes);
const hasFieldChange = changes.some((c) => !isSyntheticField(c.field));
// A group-type change cannot ride the ordinary update — CT answers 400
// `groupTypeId: validation.always.invalid` — so it goes through the migration endpoint FIRST
// and is then withheld from the field write (#171). Ordering matters: the migration rewrites
// the group's role instances, and a PATCH of the remaining fields is safe either side of it,
// but doing the migration first means a failure here leaves every other field untouched.
const migration = item.groupTypeMigration;
if (migration) {
const migrationPath = `/groups/${id}/grouptype`;
assertNotPeople(migrationPath);
await client.request("POST", migrationPath, {
groupTypeId: migration.toGroupTypeId,
roleMapping: roleMappingPayload(migration),
});
}
const fieldChanges = migration ? changes.filter((c) => c.field !== "groupTypeId") : changes;
const hasFieldChange = fieldChanges.some((c) => !isSyntheticField(c.field));
if (hasFieldChange) {
const path = spec.itemPath(id);
assertNotPeople(path);
Expand All @@ -246,7 +262,7 @@ export async function executePlan(plan: Plan, deps: ExecuteDeps): Promise<Execut
} else {
// PATCH resources take only the changed fields (unchanged/drifted siblings are left alone);
// PUT resources replace the whole object, so send actual ∪ changes to preserve those siblings.
const body = spec.updateMethod === "PATCH" ? snapshotFromChanges({}, changes) : snapshot;
const body = spec.updateMethod === "PATCH" ? snapshotFromChanges({}, fieldChanges) : snapshot;
await client.request(spec.updateMethod, path, body);
}
}
Expand Down
Loading
Loading