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
2 changes: 1 addition & 1 deletion docs/handbuch/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ sources:
- src/engine/dynamic.ts
- src/engine/synthetic.ts
- src/commands/adopt-group.ts
sources_hash: a0be8585fb6a48dc
sources_hash: 02bcb67b91c897de
reviewed: 2026-08-17
---

Expand Down
62 changes: 53 additions & 9 deletions docs/handbuch/group-member-fields.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sources_hash: 777badbcc7fbbd7f
sources_hash: 00dca3c03dacc9ff
title: Group member fields
sources:
- src/engine/member-fields.ts
Expand Down Expand Up @@ -90,10 +90,11 @@ Two readable/writable properties are deliberately **not** managed:

- **`id`** — host-specific; see above.
- **`referenceName`** — this _is_ the local identity, not a diffable property.
It is what a create sends and what every later run matches on, so managing it
would let a rename silently re-key the resource and re-create the field
instead of updating it. (A field created in the ChurchTools UI, where CT may
mint its own `referenceName`, is matched by its slugged `name` as a fallback.)
It is what a create sends and, when no state-bound id exists, what a later run
matches on. Managing it would let a rename silently re-key the resource and
re-create the field instead of updating it. (A field created in the
ChurchTools UI, where CT may mint its own `referenceName`, is matched by its
slugged `name` as a fallback.)

A property outside the managed list still passes through to ChurchTools
unchanged — it only earns a warning, and it is never diffed.
Expand All @@ -109,6 +110,13 @@ The emitted snippet carries a `memberFields:` block with every group-scoped
field and **no ChurchTools ids** — paste it, re-key it for the next year, and
`ct plan` proposes fresh groups and fresh fields.

The same adoption stores each live field id in the owning group's
instance-specific `memberFields` state map. This is deliberately separate from
the portable snippet: re-adopting a group refreshes identity from ChurchTools
without leaking host-specific ids into config. A successful read replaces that
map (including with an empty map when the group has no fields); a failed read
leaves an existing map untouched.

Rows the group's member form shows but that are not group-scoped (person master
data, group-type defaults) are not emitted: only `/memberfields/group` rows can
be created or updated.
Expand All @@ -126,7 +134,12 @@ absent `memberFields:` block still means "unmanaged".

Rows that could not be read (a 403, a rate-limited 429) are reported and the
group is adopted **without** them, so one unreadable group never aborts a
bulk adoption — re-run with `--with-member-fields` once the read succeeds.
bulk adoption — re-run with `--with-member-fields` once the read succeeds. The
same holds for rows that can be read but not captured cleanly: a row with no
numeric id, or two rows whose names slug to one local key (`Wahl 1` and
`wahl-1`). Adoption writes its state only after the whole `--children-of`
subtree has been walked, so aborting on one group would discard every group
already adopted in that run.

## Plan and apply

Expand All @@ -145,13 +158,21 @@ The actual side is narrowed to exactly the properties the declaration names, so
a server default ChurchTools returns can never make the two sides differ
forever: **a clean apply re-plans as a no-op.**

The same projection applies inside `options`: ChurchTools assigns host-specific
ids to select options, while a portable config can declare `{ name }`. Those
server ids are ignored unless the config explicitly declares them. If CT stores
`defaultValue` as one of those option ids, the comparison resolves it back to
the declared option name. Option order, count, names, and every explicitly
declared property remain managed.

`apply` creates a field only after its owning group exists (the group's own
create runs first, then its owned sub-resources), and updates an existing field
in place — matched by identity, never re-created.

If the member-field read fails with anything other than a 404, the group is
reported as `fetch-failed` and the plan says it is INCOMPLETE. It never
manufactures "create every field" out of a transient error.
If the member-field read itself fails with anything other than a 404, the group
is reported as `fetch-failed` and the plan says it is INCOMPLETE. It never
manufactures "create every field" out of a transient error. A stale state
binding is a narrower fault and degrades more narrowly — see below.

## Nothing is ever deleted implicitly

Expand Down Expand Up @@ -223,6 +244,29 @@ Three things follow:
| update | `PATCH /groups/{groupId}/memberfields/group/{fieldId}` |
| delete | `DELETE /groups/{groupId}/memberfields/group/{fieldId}` |

Depending on the ChurchTools version, the read response is a bare array or is
wrapped under `group`, `data`, `memberFields` or `groupMemberFields`; field ids
may be numbers or decimal strings. Individual rows may also wrap the definition
as `{ type: "group", field: { ... } }`, with the id sitting on either half.
`ct` normalises those transport variants before identity matching: the inner
definition wins on every key it names, and anything the wrapper alone carries
(a scope discriminator, an id) is kept rather than dropped. The outer wrapper or `group` bucket is the
authoritative scope marker; a row inside the bucket may still say
`type: "person"` because values live on memberships, and is not discarded for
that reason.

When state already binds a portable field identity to a ChurchTools id but a
live response does not contain that id, **that one field** is left
unreconciled and the plan is marked **INCOMPLETE**. `ct` will not turn an
uncertain read into a replacement `POST`, because doing so can duplicate a
field that is still present on the host — but the read itself succeeded, so
the rest of the group keeps reconciling normally: its `name`, its `parents`,
its ruleset, and its other member fields all still diff. The error names the
field and the way out: if it was deleted or re-created in the ChurchTools UI,
drop the stale binding with
`ct destroy --member-field <group>::<field>` (which forgets an
already-absent field) and re-run.

`PATCH` is used because it is a partial update, so unmanaged sibling properties
are left alone. An instance whose endpoint implements only `PUT` answers
405/501, and the update falls back to `PUT` rather than failing an apply over a
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: becf78e8e94dbfed
sources_hash: b5486f5d014a5912
reviewed: 2026-08-26
---

Expand Down
68 changes: 59 additions & 9 deletions src/commands/adopt-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
groupScopedRows,
localKeyOf,
MEMBER_FIELD_PROPS,
memberFieldId,
memberFieldStateKey,
memberFieldsReadPath,
} from "../engine/member-fields.js";
import type { DynamicStatus } from "../engine/types.js";
Expand Down Expand Up @@ -56,6 +58,13 @@ interface ResolvedAdoption {
snippet: string;
}

interface MemberFieldsCapture {
/** Portable declarations for config output; deliberately contain no ChurchTools ids. */
declarations: Array<Record<string, unknown>>;
/** Instance-bound identity map stored only on the owning group's state entry. */
ids: Record<string, number>;
}

function isNonNegativeInt(raw: string): boolean {
return /^\d+$/.test(raw.trim());
}
Expand Down Expand Up @@ -211,7 +220,7 @@ interface DynamicCapture {
async function captureMemberFields(
id: number,
client: Pick<CtClient, "get">,
): Promise<Array<Record<string, unknown>> | undefined> {
): Promise<MemberFieldsCapture | undefined> {
let raw: unknown;
try {
raw = await client.get(memberFieldsReadPath(id));
Expand All @@ -231,14 +240,47 @@ async function captureMemberFields(
return undefined;
}
const rows = groupScopedRows(raw);
if (rows.length === 0) return undefined;
return rows.map((row) => {
const declaration: Record<string, unknown> = { key: localKeyOf(row) };
const declarations: Array<Record<string, unknown>> = [];
const ids: Record<string, number> = {};
for (const row of rows) {
const localKey = localKeyOf(row);
const canonical = memberFieldStateKey(localKey);
const fieldId = memberFieldId(row);
// Same rule as the read failure above: a group whose member fields cannot be captured CLEANLY
// is not a reason to abort a bulk adoption. `saveState` runs only after the whole `--children-of`
// loop, so throwing here would discard every group already processed in this run.
if (!canonical) {
warn(
`group #${id}: a group-scoped member field has neither referenceName nor name — adopted ` +
`WITHOUT member fields. Give it a name in ChurchTools, then re-run ` +
`\`ct adopt group ${id} --with-member-fields\`.`,
);
return undefined;
}
if (fieldId === undefined) {
warn(
`group #${id} member field "${localKey}": the live response contains no numeric field id — ` +
`adopted WITHOUT member fields. Re-run \`ct adopt group ${id} --with-member-fields\` once ` +
`the response carries ids.`,
);
return undefined;
}
if (ids[canonical] !== undefined) {
warn(
`group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}" — ` +
`adopted WITHOUT member fields. Rename one in ChurchTools, then re-run ` +
`\`ct adopt group ${id} --with-member-fields\`.`,
);
return undefined;
}
ids[canonical] = fieldId;
const declaration: Record<string, unknown> = { key: localKey };
for (const prop of MEMBER_FIELD_PROPS) {
if (row[prop] !== undefined) declaration[prop] = row[prop];
}
return declaration;
});
declarations.push(declaration);
}
return { declarations, ids };
}

/** Fetch + normalize a group's ruleset and status. `undefined` (never throws) if the group isn't dynamic. */
Expand Down Expand Up @@ -415,9 +457,9 @@ export function adoptGroupCommand(): Command {
const snippetFields: Record<string, unknown> = sugared;
// Emitted BEFORE `dynamic` so the snippet reads in apply order — the fields a ruleset may
// reference are declared above the ruleset that references them (#135).
if (opts.withMemberFields) {
const memberFields = await captureMemberFields(id, client);
if (memberFields) snippetFields.memberFields = memberFields;
const memberFields = opts.withMemberFields ? await captureMemberFields(id, client) : undefined;
if (memberFields && memberFields.declarations.length > 0) {
snippetFields.memberFields = memberFields.declarations;
}
if (opts.withDynamic) {
const captured = await captureDynamic(id, client);
Expand Down Expand Up @@ -496,6 +538,14 @@ export function adoptGroupCommand(): Command {
continue;
}
const action = upsert(state, { type: "group", id, key, fields }, now);
// An empty map is not the same as no map: `ct destroy --member-field` DELETES the key when
// the last id is forgotten (see destroy.ts), so writing `memberFields: {}` here would make
// a no-op re-adoption churn the state file against the two paths' shared contract.
if (memberFields) {
const managed = state.resources[key]!;
if (Object.keys(memberFields.ids).length > 0) managed.memberFields = memberFields.ids;
else delete managed.memberFields;
}
results.push({ id, key, fields, snippet });
reports.push({ action, id, key });
}
Expand Down
Loading
Loading