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
39 changes: 38 additions & 1 deletion docs/dynamic-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,44 @@ resolve against) can keep the plain number; you then own its per-environment
correctness. This mirrors the permission scope escape hatch (#49): prefer a
reference, fall back to a number where no managed key exists.

> **Interim caveat (until adopt auto-rewrites ids — #76):** applying a
#### Auto-rewrite on capture: `--portable-rulesets` (opt-in, #76)

Rather than hand-editing markers into a freshly captured file, let adopt do it:

```sh
ct adopt group <id> --with-dynamic --portable-rulesets
```

With the flag, `ct adopt group --with-dynamic` runs the captured (normalized)
ruleset through `portablizeRuleset` (`src/config/query-refs.ts`) before writing
`rulesets/<key>.json`: every numeric id sitting in a known ChurchQuery `var`
position that maps to a **managed** logical key is rewritten to its `{ __ctRef }`
marker; every other id is left numeric. The `var → RefKind` catalog it keys off
(`VAR_REF_KINDS`) is:

| ChurchQuery `var` | marker `kind` | source catalog / state |
| ---------------------- | ------------- | ------------------------------- |
| `ctgroup.id` | `group` | managed state (no REST catalog) |
| `ctgroup.campusId` | `campus` | `/campuses` |
| `person.campusId` | `campus` | `/campuses` |
| `ctgroup.groupTypeId` | `group-type` | `/group/grouptypes` |
| `role.id` | `role-def` | `/group/roles` |

`role.id` maps to **`role-def`** (the global role catalog `/group/roles`, a
single numeric id) — not `group-role`, which is a compound (group, role)
permission _domain_ a lone `role.id` number cannot express.

The flag is **default OFF**: auto-rewriting an id you _thought_ was managed would
silently change query semantics, so you opt in per invocation. Ids that don't map
to a managed key (an operational group outside the scaffold, or the catalog-less
`ctgroup.groupStatusId`, #67) stay numeric — the escape hatch — and adopt emits a
warning naming the file:

```text
! left 2 unmanaged id(s) numeric in jugend.json — operational/unmanaged refs, not portable (escape hatch)
```

> **Interim caveat (when NOT using `--portable-rulesets`):** applying a
> raw-id prod snapshot to a _different_ environment is mechanically fine (CT
> accepts it; unknown ids → empty matches) but **semantically wrong** for that
> environment's memberships. Treat it as a known, documented gap — not a silent
Expand Down
38 changes: 37 additions & 1 deletion src/commands/adopt-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { normalizeRuleset } from "../engine/dynamic.js";
import type { DynamicStatus } from "../engine/types.js";
import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js";
import { ReverseResolver } from "../resolve/reverse.js";
import type { RefKind } from "../resolve/refs.js";
import { portablizeRuleset } from "../config/query-refs.js";
import { loadState, saveState, upsert, type State } from "../state/state.js";
import { success, info, warn, out } from "../ui.js";

Expand All @@ -30,6 +32,7 @@ interface AdoptGroupOptions {
type?: string;
childrenOf?: string;
withDynamic?: boolean;
portableRulesets?: boolean;
}

const GROUP_SPEC = RESOURCES.group!;
Expand Down Expand Up @@ -179,6 +182,11 @@ export function adoptGroupCommand(): Command {
"--with-dynamic",
"also capture each dynamic group's ruleset to rulesets/<key>.json and emit the dynamic: block",
)
.option(
"--portable-rulesets",
"when capturing rulesets (--with-dynamic), rewrite managed entity ids in the query into portable " +
"logical ref markers; unmanaged ids stay numeric with a warning (opt-in; #76)",
)
.action(async (ids: string[], _localOpts: AdoptGroupOptions, command: Command) => {
// `adopt` (the parent) also declares `-k/--key`, `-s/--state`, `-e/--env`, and `--dry-run` —
// for its own `<type> <id>` action. Commander does not merge same-named options declared on
Expand Down Expand Up @@ -241,6 +249,15 @@ export function adoptGroupCommand(): Command {
const results: ResolvedAdoption[] = [];
const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = [];

// Portable-ruleset catalogs (#76): fetch the catalog-backed id→key maps ONCE (campus/group-type/
// role-def). The `group` map is state-derived and rebuilt per capture (it grows as this run adopts).
const portableCatalogMaps: Partial<Record<RefKind, Map<number, string>>> = {};
if (opts.withDynamic && opts.portableRulesets) {
portableCatalogMaps.campus = await reverse.idToKeyByKind("campus");
portableCatalogMaps["group-type"] = await reverse.idToKeyByKind("group-type");
portableCatalogMaps["role-def"] = await reverse.idToKeyByKind("role-def");
}

for (const id of resolvedIds) {
const resource = await client.get<Record<string, unknown>>(GROUP_SPEC.itemPath(id));
const key =
Expand All @@ -258,11 +275,30 @@ export function adoptGroupCommand(): Command {
const captured = await captureDynamic(id, client);
if (captured) {
const relPath = `rulesets/${key}.json`;
let rulesetToWrite = captured.normalizedRuleset;
if (opts.portableRulesets) {
// Managed group ids come from state (no catalog for `group`), including any group this
// same run already adopted; the master-data kinds come from the catalog maps above.
const groupMap = new Map<number, string>();
for (const r of Object.values(state.resources)) {
if (r.type === "group") groupMap.set(r.id, r.key);
}
const { ruleset, warnings } = portablizeRuleset(captured.normalizedRuleset, {
idToKeyByKind: { ...portableCatalogMaps, group: groupMap },
});
rulesetToWrite = ruleset;
if (warnings.length > 0) {
warn(
`left ${warnings.length} unmanaged id(s) numeric in ${key}.json — ` +
`operational/unmanaged refs, not portable (escape hatch)`,
);
}
}
if (!opts.dryRun) {
await mkdir(join(process.cwd(), "rulesets"), { recursive: true });
await writeFile(
join(process.cwd(), relPath),
`${JSON.stringify(captured.normalizedRuleset, null, 2)}\n`,
`${JSON.stringify(rulesetToWrite, null, 2)}\n`,
"utf8",
);
}
Expand Down
125 changes: 125 additions & 0 deletions src/config/query-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Portable-ruleset ergonomics (#76). A captured `dynamic: true` ruleset embeds ChurchQuery filters
* that reference entities by raw numeric id (`ctgroup.id ∈ [148, …]`, `role.id ∈ [16, …]`). Those
* ids are instance-specific, so a snapshot taken on prod is not portable to dev. The resolve/diff
* engine ALREADY turns a logical `{ __ctRef }` marker anywhere in a ruleset into the per-host id
* before the diff (src/resolve/refs.ts `collectRefs`/`deepMapRefs`, wired through src/engine/build.ts),
* and the resolved form diffs byte-faithfully. The only missing piece is ergonomics: rewriting the
* known-entity numeric ids a fresh capture carries into those markers. This module supplies the two
* pure, offline pieces for that:
* - {@link VAR_REF_KINDS}: which ChurchQuery `var` maps to which {@link RefKind} (Stage 1), and
* - {@link portablizeRuleset}: the reverse-rewrite over a caller-supplied id→key map (Stage 2).
*/
import type { RefKind, SimpleRef } from "../resolve/refs.js";

/**
* ChurchQuery `var` name → the {@link RefKind} its id operand denotes. The kinds are the canonical
* RefKind strings the resolver already speaks (src/resolve/refs.ts) — do NOT invent new ones.
*
* Verified against the real captured prod rulesets (ct-structure/rulesets/*.json, 2026-07-11); the
* full set of entity-bearing vars present there is exactly these five:
* - `ctgroup.id` → `group` (a group's own id)
* - `ctgroup.campusId` → `campus`
* - `ctgroup.groupTypeId` → `group-type`
* - `person.campusId` → `campus`
* - `role.id` → `role-def`
*
* `role.id` is `role-def`, NOT `group-role`: the query filters on the GLOBAL role-catalog id
* (`/group/roles`, exactly the catalog the resolver's `role-def` kind reads — see CATALOG_PATH in
* src/resolve/resolver.ts), a single numeric id. `group-role` is a compound (group, role) permission
* DOMAIN, addressed by a pair — a different currency that a lone `role.id` number cannot express.
*
* Deliberately absent (the escape hatch — unknown vars are left untouched by {@link portablizeRuleset}):
* - `ctgroup.groupStatusId` — group statuses have NO REST catalog (#67; `/group/memberstatus` is a
* different dimension). Not a managed entity → never rewritten, stays a plain number.
* - `person.isArchived`, `person.dateOfDeath` — boolean/date literals, not entity refs.
*/
export const VAR_REF_KINDS: Readonly<Record<string, RefKind>> = {
"ctgroup.id": "group",
"ctgroup.campusId": "campus",
"ctgroup.groupTypeId": "group-type",
"person.campusId": "campus",
"role.id": "role-def",
};

/** An id left numeric because no managed logical key mapped to it — collected, not thrown (escape hatch). */
export interface PortablizeWarning {
var: string;
id: number;
}

export interface PortablizeOptions {
/** Per-kind numeric-id → logical-key maps, supplied by the caller (from state/catalogs). Deterministic. */
idToKeyByKind: Partial<Record<RefKind, Map<number, string>>>;
}

export interface PortablizeResult {
ruleset: Record<string, unknown>;
warnings: PortablizeWarning[];
}

/** A JSONLogic `{ var: "name" }` leaf — the sole shape that anchors an id operand to a known kind. */
function varNameOf(node: unknown): string | undefined {
if (node === null || typeof node !== "object" || Array.isArray(node)) return undefined;
const obj = node as Record<string, unknown>;
return Object.keys(obj).length === 1 && typeof obj.var === "string" ? obj.var : undefined;
}

/**
* Rewrite the known-entity numeric ids a captured ruleset carries into logical `{ __ctRef }` markers,
* so the same snapshot resolves per-host (#76 Stage 2). Pure and deterministic: the caller supplies
* `idToKeyByKind` (from managed state / master-data catalogs) — no network, no mutation of the input.
*
* The walk keys off JSONLogic operand arrays: an operator whose operands include a `{ var: <name> }`
* leaf whose `<name>` is in {@link VAR_REF_KINDS} has its OTHER operands treated as id values (a
* scalar for `==`, an array for `oneof`). Each numeric id in that position is replaced with the ref
* marker when it maps to a managed key, else left numeric and reported in `warnings`. Every other
* position — unknown vars (the escape hatch: `groupStatusId`, `isArchived`, …), string labels,
* booleans — passes through structurally unchanged. Expects a normalized ruleset (numeric-string ids
* already coerced to numbers by src/engine/dynamic.ts `normalizeRuleset`, as the adopt path does).
*/
export function portablizeRuleset(
ruleset: Record<string, unknown>,
{ idToKeyByKind }: PortablizeOptions,
): PortablizeResult {
const warnings: PortablizeWarning[] = [];

const marker = (kind: RefKind, key: string): SimpleRef => ({ __ctRef: true, kind, key } as SimpleRef);

const mapScalar = (value: unknown, kind: RefKind, varName: string): unknown => {
if (typeof value !== "number") return value; // booleans/strings/nulls are literals, never entity ids
const key = idToKeyByKind[kind]?.get(value);
if (key !== undefined) return marker(kind, key);
warnings.push({ var: varName, id: value });
return value;
};

const mapValueOperand = (value: unknown, kind: RefKind, varName: string): unknown =>
Array.isArray(value)
? value.map((el) => mapScalar(el, kind, varName)) // oneof id list
: mapScalar(value, kind, varName); // == scalar

const walk = (node: unknown): unknown => {
if (Array.isArray(node)) {
const varIdx = node.findIndex((el) => varNameOf(el) !== undefined);
if (varIdx >= 0) {
const varName = varNameOf(node[varIdx])!;
const kind = VAR_REF_KINDS[varName];
if (kind !== undefined) {
// Known entity var: keep the `{ var }` leaf, rewrite the sibling id operand(s).
return node.map((el, i) => (i === varIdx ? el : mapValueOperand(el, kind, varName)));
}
// Unknown var (escape hatch) — recurse structurally, leaving its numeric ids untouched.
}
return node.map(walk);
}
if (node !== null && typeof node === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) out[k] = walk(v);
return out;
}
return node;
};

return { ruleset: walk(ruleset) as Record<string, unknown>, warnings };
}
24 changes: 24 additions & 0 deletions src/resolve/reverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* the caller keeps the numeric id and flags it with a `// TODO: no logical match` comment.
*/
import type { CtClient } from "../api/ctClient.js";
import type { RefKind } from "./refs.js";
import { slug } from "../resources/registry.js";

/**
Expand All @@ -28,6 +29,18 @@ const REVERSE_ID_FIELDS: Record<string, { catalog: string; sugar: string }> = {
groupTypeId: { catalog: "/group/grouptypes", sugar: "groupType" },
};

/**
* RefKind → master-data catalog path, for portablizing captured ruleset ids (#76). Mirrors the
* forward resolver's CATALOG_PATH (src/resolve/resolver.ts) so the id→key map here inverts exactly
* what the resolver's slug-primary lookup maps back on any host. `group` is deliberately absent — it
* has no catalog (managed-only); its id→key map comes from managed state, supplied by the caller.
*/
const PORTABLE_CATALOG_PATHS: Partial<Record<RefKind, string>> = {
campus: "/campuses",
"group-type": "/group/grouptypes",
"role-def": "/group/roles",
};

interface CatalogRecord {
id: number;
name?: string;
Expand Down Expand Up @@ -71,6 +84,17 @@ export class ReverseResolver {
return (await this.index(catalog)).get(id);
}

/**
* id → logical-key map for a catalog-backed {@link RefKind}, for portablizing captured ruleset ids
* (#76 Stage 3). Reuses the same cached master-data index as {@link sugarFields} (campus/group-type)
* plus the global role catalog. A kind with no catalog (e.g. `group` — managed-only) yields an empty
* map; the caller supplies those ids from managed state instead.
*/
async idToKeyByKind(kind: RefKind): Promise<Map<number, string>> {
const path = PORTABLE_CATALOG_PATHS[kind];
return path ? this.index(path) : new Map<number, string>();
}

/**
* Reverse-sugar a managed-field bag for emission (#52 item A): each numeric id field with a catalog
* match becomes its logical `campus`/`groupType` key (dropping the numeric field); an id with NO
Expand Down
Loading
Loading