From 9523d53b3bd10607384b427149b55449aa8acc22 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Wed, 26 Aug 2026 12:16:14 +0200 Subject: [PATCH 1/5] fix(member-fields): normalize live reconciliation --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 21 ++++-- src/engine/member-fields.ts | 96 ++++++++++++++++++++++++---- src/engine/synthetic.ts | 41 ++++++++++-- src/resolve/resolver.ts | 9 ++- tests/member-fields.test.ts | 76 ++++++++++++++++++++++ 6 files changed, 214 insertions(+), 31 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 8a008d0..44431fd 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: a0be8585fb6a48dc +sources_hash: dc4f078f5df02c52 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 0bd29f7..0e28714 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -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. @@ -223,6 +224,18 @@ 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. `ct` normalises those transport variants +before identity matching. The `group` bucket is itself the authoritative scope +marker; a row inside it 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, 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. + `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 diff --git a/src/engine/member-fields.ts b/src/engine/member-fields.ts index 8f91863..9cec828 100644 --- a/src/engine/member-fields.ts +++ b/src/engine/member-fields.ts @@ -24,6 +24,7 @@ */ import { slug } from "../resources/registry.js"; +import type { State } from "../state/state.js"; /** * The synthetic pseudo-field prefix. One declared member field folds into ONE pseudo-field on its @@ -184,6 +185,27 @@ export function memberFieldStateKey(localKey: string): string { return slug(localKey); } +/** Resolve a field id from the current owner-local state map. */ +export function knownMemberFieldId(state: State, groupKey: string, localKey: string): number | undefined { + const group = state.resources[groupKey]; + return group?.memberFields?.[memberFieldStateKey(localKey)]; +} + +/** Prefer a state-bound id; use the portable live key only when no known id is present in the response. */ +export function matchingMemberFieldRows( + rows: MemberFieldRow[], + localKey: string, + knownId?: number, +): MemberFieldRow[] { + if (knownId !== undefined) { + // State-bound identity is authoritative. Falling back to a name when the known id is absent + // can select a different field; falling all the way through to POST can duplicate a live field + // when a response variant was parsed incompletely. + return rows.filter((row) => memberFieldRowId(row) === knownId); + } + return rows.filter((row) => matchesLocalKey(row, localKey)); +} + /** * The local key a live row answers to. `referenceName` is CT's own stable, non-numeric handle * within the group and is what a create sends, so it wins; `name` is the fallback for a row created @@ -205,13 +227,30 @@ export function matchesLocalKey(row: MemberFieldRow, localKey: string): boolean return typeof name === "string" && slug(name) === wanted; } +function numericMemberFieldId(value: unknown): number | undefined { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + return undefined; +} + +/** + * The numeric ChurchTools id of a live row or create response. CT versions differ here: ids may be + * JSON numbers or decimal strings, and create responses may wrap the row in `data` or + * `groupMemberField`. + */ +export function memberFieldId(raw: unknown): number | undefined { + const direct = numericMemberFieldId(raw); + if (direct !== undefined) return direct; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const row = raw as MemberFieldRow; + const fieldId = numericMemberFieldId(row.id ?? row.groupMemberFieldId); + if (fieldId !== undefined) return fieldId; + return memberFieldId(row.data ?? row.groupMemberField); +} + /** The numeric ChurchTools id of a live row, or `undefined` when it carries none. */ export function memberFieldRowId(row: MemberFieldRow): number | undefined { - for (const name of ["id", "groupMemberFieldId"]) { - const value = row[name]; - if (typeof value === "number" && Number.isFinite(value)) return value; - } - return undefined; + return memberFieldId(row); } /** @@ -247,14 +286,43 @@ export function memberFieldItemPath(groupId: number, fieldId: number): string { return `/groups/${groupId}/memberfields/group/${fieldId}`; } -/** Normalise CT's list envelope (bare array or `{ data: [...] }`) to the group-scoped rows only. */ +function memberFieldRows(raw: unknown): MemberFieldRow[] { + if (Array.isArray(raw)) { + return raw.filter( + (row): row is MemberFieldRow => row !== null && typeof row === "object" && !Array.isArray(row), + ); + } + if (raw === null || typeof raw !== "object") return []; + const object = raw as Record; + // Live CT versions have used all four envelopes. Treating an unfamiliar wrapper as an empty list + // could otherwise turn a readable field into a replacement create. + for (const key of ["group", "data", "memberFields", "groupMemberFields"]) { + if (object[key] === undefined) continue; + const rows = memberFieldRows(object[key]); + if (rows.length > 0) return rows; + } + return []; +} + +function explicitlyScopedGroupRows(raw: unknown): MemberFieldRow[] | undefined { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const object = raw as Record; + if (Object.prototype.hasOwnProperty.call(object, "group")) { + return memberFieldRows(object.group); + } + for (const key of ["data", "memberFields", "groupMemberFields"]) { + const nested = explicitlyScopedGroupRows(object[key]); + if (nested !== undefined) return nested; + } + return undefined; +} + +/** Normalise CT's list envelopes to the group-scoped rows only. */ export function groupScopedRows(raw: unknown): MemberFieldRow[] { - const list = Array.isArray(raw) - ? raw - : Array.isArray((raw as { data?: unknown } | null)?.data) - ? ((raw as { data: unknown[] }).data as unknown[]) - : []; - return list - .filter((row): row is MemberFieldRow => row !== null && typeof row === "object" && !Array.isArray(row)) - .filter(isGroupScopedMemberField); + // The `group` bucket is CT's authoritative scope discriminator. Its rows may still carry + // `type: "person"` because they store values on memberships/persons; applying the generic row + // heuristic again would discard exactly the definitions writable through `/memberfields/group`. + const explicit = explicitlyScopedGroupRows(raw); + if (explicit !== undefined) return explicit; + return memberFieldRows(raw).filter(isGroupScopedMemberField); } diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 6b9a02d..99487c6 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -20,14 +20,16 @@ import { slug } from "../resources/registry.js"; import { actualMemberFieldProps, groupScopedRows, + knownMemberFieldId, memberFieldStateKey, localKeyOf, - matchesLocalKey, + memberFieldId, memberFieldIdentity, memberFieldItemPath, memberFieldLocalKey, memberFieldPseudo, memberFieldRowId, + matchingMemberFieldRows, memberFieldsCreatePath, memberFieldsReadPath, MEMBER_FIELD_PREFIX, @@ -236,7 +238,23 @@ const memberFieldsField: SyntheticField = { const outcomes = await mapConcurrent(readable, MEMBER_FIELD_FETCH_CONCURRENCY, async (d) => { const managed = state.resources[d.key]!; try { - return { key: d.key, rows: await readMemberFields(client, managed.id), errors: [] as string[] }; + const rows = await readMemberFields(client, managed.id); + const missing = d.memberFields!.flatMap((spec) => { + const knownId = knownMemberFieldId(state, d.key, spec.key); + if (knownId === undefined || rows.some((row) => memberFieldRowId(row) === knownId)) return []; + return [`${memberFieldIdentity(d.key, spec.key)} (#${knownId})`]; + }); + if (missing.length > 0) { + return { + key: d.key, + rows: undefined, + errors: [ + `member fields ${d.key} (#${managed.id}): the live response did not contain the ` + + `state-bound field(s) ${missing.join(", ")}; refusing to plan replacement POSTs`, + ], + }; + } + return { key: d.key, rows, errors: [] as string[] }; } catch (err) { // Same honesty rule as the dynamic fold (#126): an unread actual is NOT a known-absent one, // so the desired side stays unfolded and the group is reported unreadable rather than having @@ -263,7 +281,7 @@ const memberFieldsField: SyntheticField = { const pseudo = memberFieldPseudo(spec.key); fields[pseudo] = spec.props; if (!a || !rows) continue; - const matches = rows.filter((row) => matchesLocalKey(row, spec.key)); + const matches = matchingMemberFieldRows(rows, spec.key, knownMemberFieldId(state, d.key, spec.key)); // >1 live match means the local key is ambiguous on this host — a blind update would pick // one arbitrarily, so leave the actual side absent and let the ambiguity surface where it // can be acted on (the apply path refuses it by name). @@ -297,7 +315,8 @@ const memberFieldsField: SyntheticField = { if (props === undefined || props === null) return; const rows = await readMemberFieldsForWrite(client, id, reads); - const matches = rows.filter((row) => matchesLocalKey(row, local)); + const knownId = knownMemberFieldId(state, key, local); + const matches = matchingMemberFieldRows(rows, local, knownId); if (matches.length > 1) { throw new Error( `group member field "${memberFieldIdentity(key, local)}": ${matches.length} fields on group ` + @@ -337,17 +356,25 @@ const memberFieldsField: SyntheticField = { return; } + if (knownId !== undefined) { + throw new Error( + `group member field "${memberFieldIdentity(key, local)}": state binds it to #${knownId}, ` + + `but the live response for group #${id} did not contain that id; refusing to create a ` + + `possible duplicate`, + ); + } + const createPath = memberFieldsCreatePath(id); assertNotPeople(createPath); // `referenceName` is the local key, not a managed property: it is CT's own stable, non-numeric // handle within the group, and sending it at create is what lets every later run find this // field by its portable identity instead of by a host-specific id. - const created = await client.request<{ id?: number } | undefined>("POST", createPath, { + const created = await client.request("POST", createPath, { ...props, referenceName: local, }); - const newId = created?.id; - if (typeof newId !== "number") { + const newId = memberFieldId(created); + if (newId === undefined) { throw new Error( `group member field "${memberFieldIdentity(key, local)}": create returned no numeric id ` + `(got ${JSON.stringify(created)}).`, diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 66e1bb2..636d7cf 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -31,8 +31,8 @@ import type { DesiredResource } from "../engine/types.js"; import { slug } from "../resources/registry.js"; import { groupScopedRows, - memberFieldStateKey, - matchesLocalKey, + knownMemberFieldId, + matchingMemberFieldRows, memberFieldRowId, memberFieldsReadPath, } from "../engine/member-fields.js"; @@ -513,7 +513,7 @@ export class Resolver { ); } const rows = await this.memberFieldList(managed.id); - const matches = rows.filter((row) => matchesLocalKey(row, r.field)); + const matches = matchingMemberFieldRows(rows, r.field, knownMemberFieldId(this.state, r.group, r.field)); if (matches.length > 1) { const list = matches .map((row) => `${JSON.stringify(row.name ?? row.referenceName)} (#${String(memberFieldRowId(row))})`) @@ -758,8 +758,7 @@ function pendingIdFromState(r: Ref, state: State): number { // dynamic ruleset (engine/synthetic.ts orders the pseudo-fields ahead of `dynamic`, and // `applySyntheticFields` re-resolves each change immediately before applying it). So by the time // a ruleset carrying this marker is written, the id is already in state. - const group = state.resources[r.group]; - const id = group?.memberFields?.[memberFieldStateKey(r.field)]; + const id = knownMemberFieldId(state, r.group, r.field); if (typeof id !== "number") { throw new Error( `Pending ${refLabel(r)} did not resolve after its group applied — no member field "${r.field}" ` + diff --git a/tests/member-fields.test.ts b/tests/member-fields.test.ts index 3639348..f204a0a 100644 --- a/tests/member-fields.test.ts +++ b/tests/member-fields.test.ts @@ -318,6 +318,26 @@ describe("group member fields — plan (#135)", () => { expect(item.note).toBe("fetch-failed"); expect(renderPlan(plan)).toContain("INCOMPLETE"); }); + + it("refuses to plan a replacement POST when a state-bound field id is absent from the live response", async () => { + const ct = makeCt(); + ct.memberFields[100] = []; + const state = stateWith({ + praktikum_1: { + type: "group", + id: 100, + fields: { name: "Praktikum 1", groupTypeId: 5, groupStatusId: 1 }, + }, + }); + state.resources.praktikum_1!.memberFields = { wahl: 501 }; + + const { plan, fetchErrors } = await buildPlan(ct.client, state, [ + praktikum("praktikum_1", "Praktikum 1"), + ]); + expect(plan.items[0]).toMatchObject({ action: "no-op", note: "fetch-failed" }); + expect(fetchErrors.join("\n")).toMatch(/praktikum_1::wahl \(#501\).*refusing to plan replacement POSTs/); + expect(renderPlan(plan)).toContain("INCOMPLETE"); + }); }); describe("group member fields — apply (#135)", () => { @@ -351,6 +371,62 @@ describe("group member fields — apply (#135)", () => { ); }); + it("normalises live response variants and PATCHes the current state-bound id", async () => { + const ct = makeCt(); + ct.memberFields[100] = [{ id: 501, type: "group", name: "Birkman", fieldTypeCode: "textarea" }]; + const realGet = ct.get.getMockImplementation()!; + ct.get.mockImplementation(async (path: string) => { + if (path === "/groups/100/memberfields") { + // The live endpoint used by the process wraps group-owned rows in `group` and serialises + // their ids as strings. Losing either compatibility makes the list look empty and causes a + // duplicate POST even though the current owner-local state already knows id 501. + return { + group: ct.memberFields[100]!.map((row) => ({ + ...row, + id: String(row.id), + // On the live endpoint this describes where VALUES live, while the `group` bucket + // already identifies the definition's writable scope. + type: "person", + })), + }; + } + return realGet(path); + }); + const state = stateWith({ + praktikum_1: { + type: "group", + id: 100, + fields: { name: "Praktikum 1", groupTypeId: 5, groupStatusId: 1 }, + }, + }); + state.resources.praktikum_1!.memberFields = { birkmann: 501 }; + const { ct: dsl, resources } = createContext(); + dsl.group({ + key: "praktikum_1", + name: "Praktikum 1", + groupTypeId: 5, + groupStatusId: 1, + memberFields: [ + { + key: "birkmann", + name: "Birkman (neu)", + fieldTypeCode: "textarea", + }, + ], + }); + + const { plan } = await buildPlan(ct.client, state, resources); + expect(plan.items[0]!.changes.map((change) => change.field)).toContain("memberField:birkmann"); + ct.calls.length = 0; + await executePlan(plan, { client: ct.client, state, statePath: "s.json", save: async () => {} }); + + expect(ct.calls.filter((call) => call === "POST /groups/100/memberfields/group")).toHaveLength(0); + expect(ct.calls.filter((call) => call === "PATCH /groups/100/memberfields/group/501")).toHaveLength(1); + expect(ct.memberFields[100]).toHaveLength(1); + expect(ct.memberFields[100]![0]!.name).toBe("Birkman (neu)"); + expect(state.resources.praktikum_1!.memberFields).toEqual({ birkmann: 501 }); + }); + it("orders field creation before the dependent dynamic ruleset is installed", async () => { const ct = makeCt(); const state = stateWith({}); From 0b1880ba5fcc63377d39ed8b16b025ff44030292 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Wed, 26 Aug 2026 12:19:23 +0200 Subject: [PATCH 2/5] fix(adopt): persist member field identities --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 9 +++++- docs/handbuch/permissions.md | 2 +- src/commands/adopt-group.ts | 48 ++++++++++++++++++++++------ tests/adopt-group-command.test.ts | 30 ++++++++++++++++- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 44431fd..51c8132 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: dc4f078f5df02c52 +sources_hash: 6a23af967ca53823 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index 0e28714..b278153 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 777badbcc7fbbd7f +sources_hash: 34e868cb911e7b9b title: Group member fields sources: - src/engine/member-fields.ts @@ -110,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. diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 2bda944..727ff2a 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,7 +7,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: becf78e8e94dbfed +sources_hash: 0c51d2abf435007b reviewed: 2026-08-26 --- diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 876696e..1d170dd 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -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"; @@ -56,6 +58,13 @@ interface ResolvedAdoption { snippet: string; } +interface MemberFieldsCapture { + /** Portable declarations for config output; deliberately contain no ChurchTools ids. */ + declarations: Array>; + /** Instance-bound identity map stored only on the owning group's state entry. */ + ids: Record; +} + function isNonNegativeInt(raw: string): boolean { return /^\d+$/.test(raw.trim()); } @@ -211,7 +220,7 @@ interface DynamicCapture { async function captureMemberFields( id: number, client: Pick, -): Promise> | undefined> { +): Promise { let raw: unknown; try { raw = await client.get(memberFieldsReadPath(id)); @@ -231,14 +240,34 @@ async function captureMemberFields( return undefined; } const rows = groupScopedRows(raw); - if (rows.length === 0) return undefined; - return rows.map((row) => { - const declaration: Record = { key: localKeyOf(row) }; + const declarations: Array> = []; + const ids: Record = {}; + for (const row of rows) { + const localKey = localKeyOf(row); + const canonical = memberFieldStateKey(localKey); + const fieldId = memberFieldId(row); + if (!canonical) { + throw new Error(`group #${id}: a group-scoped member field has neither referenceName nor name.`); + } + if (fieldId === undefined) { + throw new Error( + `group #${id} member field "${localKey}": the live response contains no numeric field id.`, + ); + } + if (ids[canonical] !== undefined) { + throw new Error( + `group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}"; ` + + `rename one in ChurchTools before adopting them.`, + ); + } + ids[canonical] = fieldId; + const declaration: Record = { 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. */ @@ -415,9 +444,9 @@ export function adoptGroupCommand(): Command { const snippetFields: Record = 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); @@ -496,6 +525,7 @@ export function adoptGroupCommand(): Command { continue; } const action = upsert(state, { type: "group", id, key, fields }, now); + if (memberFields) state.resources[key]!.memberFields = memberFields.ids; results.push({ id, key, fields, snippet }); reports.push({ action, id, key }); } diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index 30676a0..a91cb80 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -154,7 +154,7 @@ vi.mock("../src/api/session.js", () => ({ })); const { adoptCommand } = await import("../src/commands/adopt.js"); -const { loadState } = await import("../src/state/state.js"); +const { loadState, saveState } = await import("../src/state/state.js"); const { loadConfig } = await import("../src/config/load.js"); const HOST = "https://mychurch.church.tools"; @@ -601,6 +601,28 @@ describe("ct adopt group --with-member-fields (#135)", () => { expect(snippet).not.toContain("vorname"); }); + it("stores field ids only in the owning group's instance state", async () => { + await run(["group", "31", "--with-member-fields", "--state", statePath]); + + const state = await loadState(statePath, HOST); + expect(state.resources.static_group!.memberFields).toEqual({ wahl: 701, notiz: 702 }); + expect(Object.values(state.resources).some((resource) => resource.type === "group-member-field")).toBe( + false, + ); + }); + + it("replaces a stale owner-local map after a successful live read", async () => { + await run(["group", "31", "--with-member-fields", "--state", statePath]); + const state = await loadState(statePath, HOST); + state.resources.static_group!.memberFields = { alt: 999 }; + await saveState(statePath, state); + + await run(["group", "31", "--with-member-fields", "--state", statePath]); + + const refreshed = await loadState(statePath, HOST); + expect(refreshed.resources.static_group!.memberFields).toEqual({ wahl: 701, notiz: 702 }); + }); + it("a 403 on one group's fields does not abort a bulk adoption — it warns and adopts without them", async () => { const warnings: string[] = []; const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { @@ -641,4 +663,10 @@ describe("ct adopt group --with-member-fields (#135)", () => { ]); expect(snippet).not.toContain("memberFields"); }); + + it("records an empty owner-local map when the live read succeeds with no fields", async () => { + await run(["group", "10", "--with-member-fields", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.area_a!.memberFields).toEqual({}); + }); }); From 4ad733c4b42d94d64444ea39020c9763ece8bc75 Mon Sep 17 00:00:00 2001 From: Bernhard Weichel Date: Wed, 26 Aug 2026 12:49:37 +0200 Subject: [PATCH 3/5] fix(member-fields): normalize live API shapes --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 19 ++++++--- src/engine/member-fields.ts | 64 +++++++++++++++++++++++++--- src/engine/synthetic.ts | 2 +- tests/adopt-group-command.test.ts | 34 +++++++++++---- tests/member-fields.test.ts | 45 +++++++++++++++++++ 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 51c8132..0cd6331 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: 6a23af967ca53823 +sources_hash: de7874de18231755 reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index b278153..df20336 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 34e868cb911e7b9b +sources_hash: 887a114f54798292 title: Group member fields sources: - src/engine/member-fields.ts @@ -153,6 +153,13 @@ 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. @@ -233,10 +240,12 @@ Three things follow: 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. `ct` normalises those transport variants -before identity matching. The `group` bucket is itself the authoritative scope -marker; a row inside it may still say `type: "person"` because values live on -memberships, and is not discarded for that reason. +may be numbers or decimal strings. Individual rows may also wrap the definition +as `{ type: "group", field: { ... } }`. `ct` normalises those transport variants +before identity matching. 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, the plan is marked **INCOMPLETE**. `ct` diff --git a/src/engine/member-fields.ts b/src/engine/member-fields.ts index 9cec828..50b8e90 100644 --- a/src/engine/member-fields.ts +++ b/src/engine/member-fields.ts @@ -266,13 +266,60 @@ export function memberFieldRowId(row: MemberFieldRow): number | undefined { */ export function actualMemberFieldProps( row: MemberFieldRow, - declaredProps: readonly string[], + declaredProps: Record, ): Record { const out: Record = {}; - for (const name of declaredProps) out[name] = row[name]; + for (const [name, desired] of Object.entries(declaredProps)) { + const actual = row[name]; + if (name === "defaultValue" && actual !== desired && Array.isArray(row.options)) { + const option = row.options.find( + (candidate) => + candidate !== null && + typeof candidate === "object" && + !Array.isArray(candidate) && + String((candidate as Record).id) === String(actual), + ) as Record | undefined; + if (option?.name === desired) { + out[name] = desired; + continue; + } + } + out[name] = projectDeclaredShape(actual, desired); + } return out; } +/** + * Project server-enriched nested values onto the shape authored in config. CT assigns ids to + * select options, while a portable declaration commonly contains only `{ name }`; those ids are + * transport metadata, not drift. Array length and order remain visible, and every key the config + * does declare is still compared. + */ +function projectDeclaredShape(actual: unknown, desired: unknown): unknown { + if (Array.isArray(actual) && Array.isArray(desired)) { + return actual.map((value, index) => + index < desired.length ? projectDeclaredShape(value, desired[index]) : value, + ); + } + if ( + actual !== null && + desired !== null && + typeof actual === "object" && + typeof desired === "object" && + !Array.isArray(actual) && + !Array.isArray(desired) + ) { + const actualObject = actual as Record; + return Object.fromEntries( + Object.entries(desired as Record).map(([key, value]) => [ + key, + projectDeclaredShape(actualObject[key], value), + ]), + ); + } + return actual; +} + /** `GET`/`POST` path for a group's group-scoped member fields. */ export function memberFieldsReadPath(groupId: number): string { return `/groups/${groupId}/memberfields`; @@ -288,9 +335,16 @@ export function memberFieldItemPath(groupId: number, fieldId: number): string { function memberFieldRows(raw: unknown): MemberFieldRow[] { if (Array.isArray(raw)) { - return raw.filter( - (row): row is MemberFieldRow => row !== null && typeof row === "object" && !Array.isArray(row), - ); + return raw.flatMap((candidate): MemberFieldRow[] => { + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return []; + const wrapper = candidate as MemberFieldRow; + const nested = wrapper.field; + if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return [wrapper]; + // Some CT versions wrap every definition as `{ type: "group", field: { ...definition } }`. + // Keep the wrapper's scope discriminator, but expose the inner definition to every identity, + // diff, adoption, and write-path helper. + return [{ ...(nested as MemberFieldRow), type: wrapper.type ?? (nested as MemberFieldRow).type }]; + }); } if (raw === null || typeof raw !== "object") return []; const object = raw as Record; diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 99487c6..876985f 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -286,7 +286,7 @@ const memberFieldsField: SyntheticField = { // one arbitrarily, so leave the actual side absent and let the ambiguity surface where it // can be acted on (the apply path refuses it by name). if (matches.length === 1) { - a[pseudo] = actualMemberFieldProps(matches[0]!, Object.keys(spec.props)); + a[pseudo] = actualMemberFieldProps(matches[0]!, spec.props); } } if (rows) { diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index a91cb80..6cad4b5 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -77,16 +77,34 @@ function makeClient(childrenResponse: ChildrenResponse = "array") { const memberFields: Record>> = { 31: [ { - id: 701, type: "group", - referenceName: "wahl", - name: "Wahl", - fieldTypeCode: "text", - requiredInRegistrationForm: true, - sortKey: 1, + field: { + id: 701, + referenceName: "wahl", + name: "Wahl", + fieldTypeCode: "text", + requiredInRegistrationForm: true, + sortKey: 1, + }, + }, + { + type: "group", + field: { + id: 702, + referenceName: "notiz", + name: "Notiz", + fieldTypeCode: "textarea", + }, + }, + { + type: "person", + field: { + id: 703, + referenceName: "vorname", + name: "Vorname", + fieldTypeCode: "text", + }, }, - { id: 702, type: "group", referenceName: "notiz", name: "Notiz", fieldTypeCode: "textarea" }, - { id: 703, type: "person", referenceName: "vorname", name: "Vorname", fieldTypeCode: "text" }, ], }; diff --git a/tests/member-fields.test.ts b/tests/member-fields.test.ts index f204a0a..0cda10a 100644 --- a/tests/member-fields.test.ts +++ b/tests/member-fields.test.ts @@ -269,6 +269,51 @@ describe("group member fields — plan (#135)", () => { expect(plain(renderPlan(again.plan))).toBe("No changes. Desired state matches ChurchTools."); }); + it("treats server-assigned option ids and an id-backed default as the portable name declaration", async () => { + const ct = makeCt(); + ct.memberFields[100] = [ + { + id: 501, + type: "group", + referenceName: "wahl", + name: "Wahl", + fieldTypeCode: "select", + defaultValue: "702", + options: [ + { id: "701", name: "A" }, + { id: "702", name: "B" }, + ], + }, + ]; + const state = stateWith({ + praktikum_1: { + type: "group", + id: 100, + fields: { name: "Praktikum 1", groupTypeId: 5, groupStatusId: 1 }, + }, + }); + state.resources.praktikum_1!.memberFields = { wahl: 501 }; + const { ct: dsl, resources } = createContext(); + dsl.group({ + key: "praktikum_1", + name: "Praktikum 1", + groupTypeId: 5, + groupStatusId: 1, + memberFields: [ + { + key: "wahl", + name: "Wahl", + fieldTypeCode: "select", + defaultValue: "B", + options: [{ name: "A" }, { name: "B" }], + }, + ], + }); + + const { plan } = await buildPlan(ct.client, state, resources); + expect(plain(renderPlan(plan))).toBe("No changes. Desired state matches ChurchTools."); + }); + it("surfaces an undeclared live field as a DELETE CANDIDATE and never plans a delete", async () => { const ct = makeCt(); ct.memberFields[100] = [ From 60ec9e1cb846bc9439a2915b8d57791bf92b444d Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 26 Aug 2026 13:29:39 +0200 Subject: [PATCH 4/5] fix(member-fields): scope stale-binding degradation, stop bulk-adopt aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #154: - synthetic: a state-bound member-field id missing from the live response made the WHOLE group undiffable (build.ts turns rows: undefined into fetchFailed), silently freezing name/parents/dynamic and every other member field. Scope it to the affected pseudo-field instead — dropped from the desired side, so no replacement POST — and name the recovery (`ct destroy --member-field `) in the error. - adopt-group: an id-less row or two rows slugging to the same local key threw, and saveState only runs after the whole --children-of loop, so one bad group discarded every group already adopted. Warn and skip the group's member fields, matching the read-failure path. - member-fields: guard the defaultValue↔option-id match on both sides being non-null; "undefined" === "undefined" reported the field converged forever and never wrote the declared default. - member-fields: merge the { type, field } wrapper over the nested definition instead of keeping only `type`, so a wrapper-held id survives, and gate the unwrap on the nested object looking like a definition. - resolver: a stale state binding filtered by id alone, then errored "group #12 has no member field \"wahl\" (available: \"Wahl\")" — name the stale binding and how to clear it instead. - adopt-group: don't write `memberFields: {}`; destroy deletes the key when the map empties, so an empty map churned the state file. Claude-Session: https://claude.ai/code/session_016JBGhwKsanNFAK9cs7N9Uv --- src/commands/adopt-group.ts | 34 ++++++++++++---- src/engine/member-fields.ts | 31 +++++++++++++-- src/engine/synthetic.ts | 45 ++++++++++++++-------- src/resolve/resolver.ts | 17 +++++++- tests/adopt-group-command.test.ts | 59 +++++++++++++++++++++++++++- tests/member-fields.test.ts | 64 ++++++++++++++++++++++++++++--- 6 files changed, 214 insertions(+), 36 deletions(-) diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 1d170dd..0f082e2 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -246,19 +246,32 @@ async function captureMemberFields( 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) { - throw new Error(`group #${id}: a group-scoped member field has neither referenceName nor name.`); + 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) { - throw new Error( - `group #${id} member field "${localKey}": the live response contains no numeric field id.`, + 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) { - throw new Error( - `group #${id}: multiple group-scoped member fields resolve to the local key "${canonical}"; ` + - `rename one in ChurchTools before adopting them.`, + 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 = { key: localKey }; @@ -525,7 +538,14 @@ export function adoptGroupCommand(): Command { continue; } const action = upsert(state, { type: "group", id, key, fields }, now); - if (memberFields) state.resources[key]!.memberFields = memberFields.ids; + // 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 }); } diff --git a/src/engine/member-fields.ts b/src/engine/member-fields.ts index 50b8e90..88f53a4 100644 --- a/src/engine/member-fields.ts +++ b/src/engine/member-fields.ts @@ -271,12 +271,17 @@ export function actualMemberFieldProps( const out: Record = {}; for (const [name, desired] of Object.entries(declaredProps)) { const actual = row[name]; - if (name === "defaultValue" && actual !== desired && Array.isArray(row.options)) { + // CT may answer `defaultValue` as the chosen OPTION'S ID where the declaration names the + // option by name. Both sides must actually carry a value before they are compared as strings: + // an absent `defaultValue` next to an id-less option would otherwise match `"undefined"` to + // `"undefined"`, report the field converged forever, and never write the declared default. + if (name === "defaultValue" && actual !== desired && actual != null && Array.isArray(row.options)) { const option = row.options.find( (candidate) => candidate !== null && typeof candidate === "object" && !Array.isArray(candidate) && + (candidate as Record).id != null && String((candidate as Record).id) === String(actual), ) as Record | undefined; if (option?.name === desired) { @@ -333,6 +338,15 @@ export function memberFieldItemPath(groupId: number, fieldId: number): string { return `/groups/${groupId}/memberfields/group/${fieldId}`; } +/** Does this nested object carry a member field's own identity — i.e. is it a wrapped definition? */ +function looksLikeMemberFieldDefinition(nested: MemberFieldRow): boolean { + return ( + typeof nested.name === "string" || + typeof nested.referenceName === "string" || + memberFieldId(nested) !== undefined + ); +} + function memberFieldRows(raw: unknown): MemberFieldRow[] { if (Array.isArray(raw)) { return raw.flatMap((candidate): MemberFieldRow[] => { @@ -340,10 +354,19 @@ function memberFieldRows(raw: unknown): MemberFieldRow[] { const wrapper = candidate as MemberFieldRow; const nested = wrapper.field; if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return [wrapper]; + const inner = nested as MemberFieldRow; + // Only a nested object that actually looks like a DEFINITION is a wrapper. A plain row that + // merely carries an unrelated `field` sub-object keeps its own shape rather than being + // replaced by it. + if (!looksLikeMemberFieldDefinition(inner)) return [wrapper]; // Some CT versions wrap every definition as `{ type: "group", field: { ...definition } }`. - // Keep the wrapper's scope discriminator, but expose the inner definition to every identity, - // diff, adoption, and write-path helper. - return [{ ...(nested as MemberFieldRow), type: wrapper.type ?? (nested as MemberFieldRow).type }]; + // Merge wrapper-under-nested rather than dropping the wrapper: the inner definition wins on + // every key it names, while a scope discriminator — or an id — that the variant parked on the + // wrapper still reaches every identity, diff, adoption, and write-path helper. Losing a + // wrapper-held id would make adopt skip the group and apply refuse the update. + const outer = { ...wrapper }; + delete outer.field; + return [{ ...outer, ...inner, type: wrapper.type ?? inner.type }]; }); } if (raw === null || typeof raw !== "object") return []; diff --git a/src/engine/synthetic.ts b/src/engine/synthetic.ts index 876985f..c742d5f 100644 --- a/src/engine/synthetic.ts +++ b/src/engine/synthetic.ts @@ -239,22 +239,30 @@ const memberFieldsField: SyntheticField = { const managed = state.resources[d.key]!; try { const rows = await readMemberFields(client, managed.id); - const missing = d.memberFields!.flatMap((spec) => { - const knownId = knownMemberFieldId(state, d.key, spec.key); - if (knownId === undefined || rows.some((row) => memberFieldRowId(row) === knownId)) return []; - return [`${memberFieldIdentity(d.key, spec.key)} (#${knownId})`]; + // A state-bound id the live response no longer carries is a STALE BINDING on ONE field, not + // an unreadable group: the read succeeded and every other field on this group still has a + // trustworthy actual side. So the affected fields are dropped from the desired side (no key, + // no change, no replacement POST — the same mechanism that makes a dropped declaration a + // no-op) while `name`, `parents`, `dynamic` and the group's other member fields keep + // reconciling. The error still names the field, and names the command that clears it. + const stale = new Set( + d.memberFields!.flatMap((spec) => { + const knownId = knownMemberFieldId(state, d.key, spec.key); + if (knownId === undefined || rows.some((row) => memberFieldRowId(row) === knownId)) return []; + return [spec.key]; + }), + ); + const errors = [...stale].map((localKey) => { + const identity = memberFieldIdentity(d.key, localKey); + return ( + `member field ${identity}: state binds it to #${knownMemberFieldId(state, d.key, localKey)}, ` + + `but the live response for group #${managed.id} no longer contains that id; leaving this ` + + `field unreconciled rather than planning a replacement POST. If it was deleted or ` + + `re-created in ChurchTools, drop the stale binding with ` + + `\`ct destroy --member-field ${identity}\` and re-run.` + ); }); - if (missing.length > 0) { - return { - key: d.key, - rows: undefined, - errors: [ - `member fields ${d.key} (#${managed.id}): the live response did not contain the ` + - `state-bound field(s) ${missing.join(", ")}; refusing to plan replacement POSTs`, - ], - }; - } - return { key: d.key, rows, errors: [] as string[] }; + return { key: d.key, rows, stale, errors }; } catch (err) { // Same honesty rule as the dynamic fold (#126): an unread actual is NOT a known-absent one, // so the desired side stays unfolded and the group is reported unreadable rather than having @@ -262,6 +270,7 @@ const memberFieldsField: SyntheticField = { return { key: d.key, rows: undefined, + stale: new Set(), errors: [`member fields ${d.key} (#${managed.id}): ${formatError(err)}`], }; } @@ -270,6 +279,7 @@ const memberFieldsField: SyntheticField = { const unreadable = outcomes.filter((o) => o.rows === undefined).map((o) => o.key); const unreadableKeys = new Set(unreadable); const rowsByKey = new Map(outcomes.filter((o) => o.rows !== undefined).map((o) => [o.key, o.rows!])); + const staleByKey = new Map(outcomes.map((o) => [o.key, o.stale])); const augmented = desired.map((d) => { if (d.type !== "group" || d.memberFields === undefined) return d; @@ -277,7 +287,9 @@ const memberFieldsField: SyntheticField = { const rows = rowsByKey.get(d.key); const a = actual.get(d.key); const fields = { ...d.fields }; + const stale = staleByKey.get(d.key); for (const spec of d.memberFields) { + if (stale?.has(spec.key)) continue; // stale state binding — reported above, left unreconciled const pseudo = memberFieldPseudo(spec.key); fields[pseudo] = spec.props; if (!a || !rows) continue; @@ -360,7 +372,8 @@ const memberFieldsField: SyntheticField = { throw new Error( `group member field "${memberFieldIdentity(key, local)}": state binds it to #${knownId}, ` + `but the live response for group #${id} did not contain that id; refusing to create a ` + - `possible duplicate`, + `possible duplicate. If the field was deleted or re-created in ChurchTools, drop the stale ` + + `binding with \`ct destroy --member-field ${memberFieldIdentity(key, local)}\` and re-run.`, ); } diff --git a/src/resolve/resolver.ts b/src/resolve/resolver.ts index 636d7cf..b77c95d 100644 --- a/src/resolve/resolver.ts +++ b/src/resolve/resolver.ts @@ -33,6 +33,7 @@ import { groupScopedRows, knownMemberFieldId, matchingMemberFieldRows, + memberFieldIdentity, memberFieldRowId, memberFieldsReadPath, } from "../engine/member-fields.js"; @@ -513,7 +514,8 @@ export class Resolver { ); } const rows = await this.memberFieldList(managed.id); - const matches = matchingMemberFieldRows(rows, r.field, knownMemberFieldId(this.state, r.group, r.field)); + const knownId = knownMemberFieldId(this.state, r.group, r.field); + const matches = matchingMemberFieldRows(rows, r.field, knownId); if (matches.length > 1) { const list = matches .map((row) => `${JSON.stringify(row.name ?? row.referenceName)} (#${String(memberFieldRowId(row))})`) @@ -538,6 +540,19 @@ export class Resolver { const available = rows .map((row) => (typeof row.name === "string" ? JSON.stringify(row.name) : "?")) .join(", "); + // A STATE-BOUND lookup that found nothing filtered by id alone, so the field may well be sitting + // right there in `available` under a fresh id (deleted and re-created in the ChurchTools UI). + // Saying "no member field" there would contradict the very list this message prints, so the + // stale binding is named instead — together with the command that clears it. + if (knownId !== undefined) { + throw new Error( + `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: state binds ` + + `"${memberFieldIdentity(r.group, r.field)}" to #${knownId}, but group #${managed.id} no ` + + `longer has that field${available ? ` (available: ${available})` : ""}. If it was deleted ` + + `and re-created in ChurchTools, drop the stale binding with ` + + `\`ct destroy --member-field ${memberFieldIdentity(r.group, r.field)}\` and re-run.`, + ); + } throw new Error( `Cannot resolve ${refLabel(r)} referenced at ${site} on ${this.host}: group #${managed.id} has ` + `no member field "${r.field}"${available ? ` (available: ${available})` : ""}, and this config ` + diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index 6cad4b5..951c2e9 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -670,6 +670,59 @@ describe("ct adopt group --with-member-fields (#135)", () => { } }); + it("two fields sharing one local key do not abort the run — it warns and adopts without them", async () => { + // `saveState` runs only after the whole `--children-of` loop, so throwing here would discard + // every group already adopted in this run. Same rule as the unreadable-fields path above. + const warnings: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + warnings.push(String(chunk)); + return true; + }); + const original = client.get.getMockImplementation()!; + client.get.mockImplementation((async (path: string) => { + if (path === "/groups/31/memberfields") { + return [ + { type: "group", field: { id: 701, name: "Wahl 1" } }, + { type: "group", field: { id: 702, name: "wahl-1" } }, + ]; + } + return original(path); + }) as never); + try { + await run(["group", "31", "--with-member-fields", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.static_group).toBeDefined(); // the run completed and wrote state + expect(state.resources.static_group!.memberFields).toBeUndefined(); + expect(warnings.join("")).toMatch(/resolve to the local key "wahl_1"/); + } finally { + client.get.mockImplementation(original as never); + spy.mockRestore(); + } + }); + + it("a member field with no numeric id does not abort the run either", async () => { + const warnings: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + warnings.push(String(chunk)); + return true; + }); + const original = client.get.getMockImplementation()!; + client.get.mockImplementation((async (path: string) => { + if (path === "/groups/31/memberfields") return [{ type: "group", field: { name: "Wahl" } }]; + return original(path); + }) as never); + try { + await run(["group", "31", "--with-member-fields", "--state", statePath]); + const state = await loadState(statePath, HOST); + expect(state.resources.static_group).toBeDefined(); + expect(state.resources.static_group!.memberFields).toBeUndefined(); + expect(warnings.join("")).toMatch(/contains no numeric field id/); + } finally { + client.get.mockImplementation(original as never); + spy.mockRestore(); + } + }); + it("emits no memberFields block for a group that has none", async () => { const snippet = await snippetFor([ "group", @@ -682,9 +735,11 @@ describe("ct adopt group --with-member-fields (#135)", () => { expect(snippet).not.toContain("memberFields"); }); - it("records an empty owner-local map when the live read succeeds with no fields", async () => { + it("records no owner-local map when the live read succeeds with no fields", async () => { + // `ct destroy --member-field` DELETES the key once the last id is forgotten, so an empty map + // here would make a no-op re-adoption churn the state file against that same contract. await run(["group", "10", "--with-member-fields", "--state", statePath]); const state = await loadState(statePath, HOST); - expect(state.resources.area_a!.memberFields).toEqual({}); + expect(state.resources.area_a!.memberFields).toBeUndefined(); }); }); diff --git a/tests/member-fields.test.ts b/tests/member-fields.test.ts index 0cda10a..58bfbca 100644 --- a/tests/member-fields.test.ts +++ b/tests/member-fields.test.ts @@ -6,7 +6,12 @@ import { renderPlan } from "../src/engine/render.js"; import { createContext, evaluateConfig, ref } from "../src/config/context.js"; import { emptyState, type State } from "../src/state/state.js"; import type { DesiredResource } from "../src/engine/types.js"; -import { memberFieldPseudo, isGroupScopedMemberField } from "../src/engine/member-fields.js"; +import { + memberFieldPseudo, + isGroupScopedMemberField, + actualMemberFieldProps, + groupScopedRows, +} from "../src/engine/member-fields.js"; const HOST = "https://mychurch.church.tools"; @@ -364,7 +369,7 @@ describe("group member fields — plan (#135)", () => { expect(renderPlan(plan)).toContain("INCOMPLETE"); }); - it("refuses to plan a replacement POST when a state-bound field id is absent from the live response", async () => { + it("leaves a stale-bound field unreconciled without blocking the rest of its group", async () => { const ct = makeCt(); ct.memberFields[100] = []; const state = stateWith({ @@ -376,12 +381,24 @@ describe("group member fields — plan (#135)", () => { }); state.resources.praktikum_1!.memberFields = { wahl: 501 }; + // The group itself has real drift (a rename): a stale binding on ONE member field must not stop + // the group's own properties — or its other sub-resources — from being planned. const { plan, fetchErrors } = await buildPlan(ct.client, state, [ - praktikum("praktikum_1", "Praktikum 1"), + praktikum("praktikum_1", "Praktikum 2"), ]); - expect(plan.items[0]).toMatchObject({ action: "no-op", note: "fetch-failed" }); - expect(fetchErrors.join("\n")).toMatch(/praktikum_1::wahl \(#501\).*refusing to plan replacement POSTs/); - expect(renderPlan(plan)).toContain("INCOMPLETE"); + const item = plan.items.find((i) => i.key === "praktikum_1")!; + expect(item.action).toBe("update"); + expect(item.note).toBeUndefined(); + expect(item.changes.map((c) => c.field)).toEqual(["name"]); + // …and no replacement POST is planned for the stale field. + expect(item.changes.some((c) => c.field.startsWith("memberField:"))).toBe(false); + expect(fetchErrors.join("\n")).toMatch( + /praktikum_1::wahl: state binds it to #501.*ct destroy --member-field praktikum_1::wahl/s, + ); + // A non-empty `fetchErrors` still makes `ct plan` INCOMPLETE and exit 1 (see commands/plan.ts); + // what changed is that the group is no longer rendered as an unreadable resource. + expect(fetchErrors).toHaveLength(1); + expect(renderPlan(plan)).not.toContain("could not be read"); }); }); @@ -686,3 +703,38 @@ describe("isGroupScopedMemberField (#135 review)", () => { expect(isGroupScopedMemberField({ id: 1, fieldSource: "group-type" })).toBe(false); }); }); + +describe("actualMemberFieldProps — defaultValue ↔ option id (#154 review)", () => { + it("treats the option id CT echoes for a by-name default as no drift", () => { + const row = { defaultValue: 7, options: [{ id: 7, name: "Ja" }, { id: 8, name: "Nein" }] }; + expect(actualMemberFieldProps(row, { defaultValue: "Ja" })).toEqual({ defaultValue: "Ja" }); + }); + + it("does not match an ABSENT default against an id-less option", () => { + // Both sides stringify to "undefined": coercing them would report the field converged forever + // and the declared default would never be written. + const row = { options: [{ name: "Ja" }, { name: "Nein" }] }; + expect(actualMemberFieldProps(row, { defaultValue: "Ja" })).toEqual({ defaultValue: undefined }); + expect(actualMemberFieldProps({ defaultValue: null, options: [{ id: null, name: "Ja" }] }, { + defaultValue: "Ja", + })).toEqual({ defaultValue: null }); + }); +}); + +describe("groupScopedRows — { type, field } wrapper (#154 review)", () => { + it("keeps a wrapper-held id when the inner definition carries none", () => { + // Losing it would make adopt skip the group and apply refuse the update for want of a row id. + const rows = groupScopedRows([{ type: "group", id: 42, field: { name: "Wahl", referenceName: "wahl" } }]); + expect(rows).toEqual([{ type: "group", id: 42, name: "Wahl", referenceName: "wahl" }]); + }); + + it("lets the inner definition win on every key it names", () => { + const rows = groupScopedRows([{ type: "group", id: 42, name: "Outer", field: { id: 7, name: "Wahl" } }]); + expect(rows).toEqual([{ type: "group", id: 7, name: "Wahl" }]); + }); + + it("leaves a plain row that merely carries an unrelated `field` object alone", () => { + const row = { id: 3, name: "Wahl", referenceName: "wahl", field: { label: "irrelevant" } }; + expect(groupScopedRows([row])).toEqual([row]); + }); +}); From 976f89f0a2bb719a0d1c57fe860c8c16a7d49cc0 Mon Sep 17 00:00:00 2001 From: Felix Kotschenreuther Date: Wed, 26 Aug 2026 13:32:01 +0200 Subject: [PATCH 5/5] docs(member-fields): re-sign handbook for the review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group member fields: adoption now warns and skips on rows it cannot capture cleanly (no id, colliding local keys) instead of aborting the run; a stale state binding degrades to that one field rather than the whole group, and names `ct destroy --member-field` as the way out; the `{ type, field }` wrapper keeps whatever only the wrapper carries. dynamic-groups.md and permissions.md are re-signed unchanged — the synthetic and resolver edits are confined to the member-field paths those pages do not document. Claude-Session: https://claude.ai/code/session_016JBGhwKsanNFAK9cs7N9Uv --- docs/handbuch/dynamic-groups.md | 2 +- docs/handbuch/group-member-fields.md | 35 ++++++++++++++++++++-------- docs/handbuch/permissions.md | 2 +- tests/member-fields.test.ts | 19 +++++++++++---- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/docs/handbuch/dynamic-groups.md b/docs/handbuch/dynamic-groups.md index 0cd6331..b85b585 100644 --- a/docs/handbuch/dynamic-groups.md +++ b/docs/handbuch/dynamic-groups.md @@ -6,7 +6,7 @@ sources: - src/engine/dynamic.ts - src/engine/synthetic.ts - src/commands/adopt-group.ts -sources_hash: de7874de18231755 +sources_hash: 02bcb67b91c897de reviewed: 2026-08-17 --- diff --git a/docs/handbuch/group-member-fields.md b/docs/handbuch/group-member-fields.md index df20336..e4d8a11 100644 --- a/docs/handbuch/group-member-fields.md +++ b/docs/handbuch/group-member-fields.md @@ -1,5 +1,5 @@ --- -sources_hash: 887a114f54798292 +sources_hash: 00dca3c03dacc9ff title: Group member fields sources: - src/engine/member-fields.ts @@ -134,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 @@ -164,9 +169,10 @@ declared property remain managed. 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 @@ -241,16 +247,25 @@ Three things follow: 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: { ... } }`. `ct` normalises those transport variants -before identity matching. The outer wrapper or `group` bucket is the +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, 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. +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 ::` (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 diff --git a/docs/handbuch/permissions.md b/docs/handbuch/permissions.md index 727ff2a..2ce8587 100644 --- a/docs/handbuch/permissions.md +++ b/docs/handbuch/permissions.md @@ -7,7 +7,7 @@ sources: - src/resolve/resolver.ts - src/resolve/refs.ts - src/config/context.ts -sources_hash: 0c51d2abf435007b +sources_hash: b5486f5d014a5912 reviewed: 2026-08-26 --- diff --git a/tests/member-fields.test.ts b/tests/member-fields.test.ts index 58bfbca..0c5f84a 100644 --- a/tests/member-fields.test.ts +++ b/tests/member-fields.test.ts @@ -706,7 +706,13 @@ describe("isGroupScopedMemberField (#135 review)", () => { describe("actualMemberFieldProps — defaultValue ↔ option id (#154 review)", () => { it("treats the option id CT echoes for a by-name default as no drift", () => { - const row = { defaultValue: 7, options: [{ id: 7, name: "Ja" }, { id: 8, name: "Nein" }] }; + const row = { + defaultValue: 7, + options: [ + { id: 7, name: "Ja" }, + { id: 8, name: "Nein" }, + ], + }; expect(actualMemberFieldProps(row, { defaultValue: "Ja" })).toEqual({ defaultValue: "Ja" }); }); @@ -715,9 +721,14 @@ describe("actualMemberFieldProps — defaultValue ↔ option id (#154 review)", // and the declared default would never be written. const row = { options: [{ name: "Ja" }, { name: "Nein" }] }; expect(actualMemberFieldProps(row, { defaultValue: "Ja" })).toEqual({ defaultValue: undefined }); - expect(actualMemberFieldProps({ defaultValue: null, options: [{ id: null, name: "Ja" }] }, { - defaultValue: "Ja", - })).toEqual({ defaultValue: null }); + expect( + actualMemberFieldProps( + { defaultValue: null, options: [{ id: null, name: "Ja" }] }, + { + defaultValue: "Ja", + }, + ), + ).toEqual({ defaultValue: null }); }); });