diff --git a/docs/dynamic-groups.md b/docs/dynamic-groups.md index cc742a2..ac5e966 100644 --- a/docs/dynamic-groups.md +++ b/docs/dynamic-groups.md @@ -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 --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/.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 diff --git a/src/commands/adopt-group.ts b/src/commands/adopt-group.ts index 1a010c7..d00e64d 100644 --- a/src/commands/adopt-group.ts +++ b/src/commands/adopt-group.ts @@ -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"; @@ -30,6 +32,7 @@ interface AdoptGroupOptions { type?: string; childrenOf?: string; withDynamic?: boolean; + portableRulesets?: boolean; } const GROUP_SPEC = RESOURCES.group!; @@ -179,6 +182,11 @@ export function adoptGroupCommand(): Command { "--with-dynamic", "also capture each dynamic group's ruleset to rulesets/.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 ` ` action. Commander does not merge same-named options declared on @@ -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>> = {}; + 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>(GROUP_SPEC.itemPath(id)); const key = @@ -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(); + 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", ); } diff --git a/src/config/query-refs.ts b/src/config/query-refs.ts new file mode 100644 index 0000000..0c8a524 --- /dev/null +++ b/src/config/query-refs.ts @@ -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> = { + "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>>; +} + +export interface PortablizeResult { + ruleset: Record; + 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; + 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: }` + * leaf whose `` 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, + { 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 = {}; + for (const [k, v] of Object.entries(node as Record)) out[k] = walk(v); + return out; + } + return node; + }; + + return { ruleset: walk(ruleset) as Record, warnings }; +} diff --git a/src/resolve/reverse.ts b/src/resolve/reverse.ts index a5e9858..c3eb5bf 100644 --- a/src/resolve/reverse.ts +++ b/src/resolve/reverse.ts @@ -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"; /** @@ -28,6 +29,18 @@ const REVERSE_ID_FIELDS: Record = { 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> = { + campus: "/campuses", + "group-type": "/group/grouptypes", + "role-def": "/group/roles", +}; + interface CatalogRecord { id: number; name?: string; @@ -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> { + const path = PORTABLE_CATALOG_PATHS[kind]; + return path ? this.index(path) : new Map(); + } + /** * 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 diff --git a/tests/adopt-group-command.test.ts b/tests/adopt-group-command.test.ts index 63ddc35..8e277a0 100644 --- a/tests/adopt-group-command.test.ts +++ b/tests/adopt-group-command.test.ts @@ -29,6 +29,7 @@ function makeClient() { 30: { id: 30, name: "All Mainz", information: { groupTypeId: 5, groupStatusId: 1 } }, // campusId 0 (Mainz) exercises campus reverse-resolution — including the id-0 edge — end to end. 31: { id: 31, name: "Static Group", information: { groupTypeId: 5, groupStatusId: 1, campusId: 0 } }, + 33: { id: 33, name: "Portable Dynamic", information: { groupTypeId: 5, groupStatusId: 1 } }, }; const children: Record = { 40: [41, 42], @@ -47,10 +48,26 @@ function makeClient() { // plain group adopt (see the assertion in the --with-dynamic capture test below). const campuses = [{ id: 0, name: "Mainz" }]; const memberStatuses = [{ id: 1, name: "Aktiv" }]; + // Global role catalog (/group/roles) — the `role-def` kind's catalog, used to portablize `role.id`. + const roles = [{ id: 7, name: "Leiter" }]; const rulesets: Record> = { 30: { description: "x", query: { "==": [{ var: "a" }, "1"] }, process: {} }, + // A ruleset carrying every portablizable var shape (#76): a managed group id (10) + an unmanaged + // one (999); a campus id (0); a role id (7); and a catalog-less groupStatusId list (untouched). + 33: { + description: "portable", + query: { + and: [ + { oneof: [{ var: "ctgroup.id" }, ["10", "999"]] }, + { "==": [{ var: "ctgroup.campusId" }, "0"] }, + { oneof: [{ var: "role.id" }, ["7"]] }, + { oneof: [{ var: "ctgroup.groupStatusId" }, ["1", "2"]] }, + ], + }, + process: {}, + }, }; - const statuses: Record = { 30: "active" }; + const statuses: Record = { 30: "active", 33: "active" }; const get = vi.fn(async (path: string): Promise => { let m = /^\/groups\/(\d+)$/.exec(path); @@ -63,6 +80,7 @@ function makeClient() { if (m) return (children[Number(m[1])] ?? []).map((id) => ({ id })); if (path === "/group/grouptypes") return groupTypes; if (path === "/campuses") return campuses; + if (path === "/group/roles") return roles; if (path === "/group/memberstatus") return memberStatuses; m = /^\/dynamicgroups\/(\d+)\/ruleset$/.exec(path); if (m) { @@ -333,6 +351,61 @@ describe("ct adopt group --with-dynamic", () => { }); }); +describe("ct adopt group --with-dynamic --portable-rulesets (#76 Stage 3)", () => { + /** Adopt group 10 first (→ managed key "area_a") so the dynamic ruleset's ctgroup.id 10 is managed. */ + async function adoptWithManagedGroup(extraArgs: string[]): Promise { + await run(["group", "10", "--state", statePath]); + await run(["group", "33", "--with-dynamic", ...extraArgs, "--state", statePath]); + } + + it("rewrites managed group/campus/role ids to ref markers and leaves groupStatusId numeric", async () => { + await adoptWithManagedGroup(["--portable-rulesets"]); + const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); + const and = (written.query as { and: Array> }).and; + + // ctgroup.id: 10 is managed (→ ref.group("area_a")); 999 is unmanaged (→ stays numeric). + expect(and[0]!.oneof![1]).toEqual([{ __ctRef: true, kind: "group", key: "area_a" }, 999]); + // campusId 0 → the Mainz campus marker (slug of the catalog name). + expect(and[1]!["=="]![1]).toEqual({ __ctRef: true, kind: "campus", key: "mainz" }); + // role.id 7 → role-def marker (global /group/roles catalog), NOT group-role. + expect(and[2]!.oneof![1]).toEqual([{ __ctRef: true, kind: "role-def", key: "leiter" }]); + // groupStatusId has no catalog (#67) → left numeric, untouched. + expect(and[3]!.oneof![1]).toEqual([1, 2]); + }); + + it("warns once naming the file for the unmanaged id it left numeric (escape hatch)", async () => { + const errs: string[] = []; + const spy = vi.spyOn(process.stderr, "write").mockImplementation((s) => { + errs.push(String(s)); + return true; + }); + try { + await adoptWithManagedGroup(["--portable-rulesets"]); + } finally { + spy.mockRestore(); + } + const warned = errs.join(""); + expect(warned).toMatch(/left 1 unmanaged id\(s\) numeric in portable_dynamic\.json/); + expect(warned).toContain("escape hatch"); + }); + + it("is OFF by default: a plain --with-dynamic capture keeps raw numeric ids (no markers)", async () => { + await adoptWithManagedGroup([]); // no --portable-rulesets + const written = JSON.parse(await readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8")); + const and = (written.query as { and: Array> }).and; + expect(and[0]!.oneof![1]).toEqual([10, 999]); // raw ids, no rewrite + expect(JSON.stringify(written)).not.toContain("__ctRef"); + }); + + it("--dry-run --portable-rulesets writes no file", async () => { + await run(["group", "10", "--state", statePath]); + await run(["group", "33", "--with-dynamic", "--portable-rulesets", "--dry-run", "--state", statePath]); + await expect( + readFile(join(workDir, "rulesets", "portable_dynamic.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + describe("ct adopt group — idiomatic snippet round-trips to a no-op (#52 item A acceptance)", () => { it("pasting the VERBATIM emitted snippet into a config plans as a no-op, zero hand edits", async () => { // Adopt a real group, capturing exactly what the command prints to stdout. diff --git a/tests/fixtures/dynamic/portablize-sintegrationmeeting.json b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json new file mode 100644 index 0000000..cc454b9 --- /dev/null +++ b/tests/fixtures/dynamic/portablize-sintegrationmeeting.json @@ -0,0 +1,181 @@ +{ + "description": "Automatische Mitgliedschaft für Gruppe #1597", + "shorty": "Autom. Mitgliedschaft Gruppe #1597", + "personIdFieldName": "person.id", + "importance": 0, + "query": { + "method": "ChurchQuery", + "params": { + "groupBy": [ + "person.id" + ], + "filter": { + "and": [ + { + "dterm": [ + { + "title": "group.automatic-membership.default-archived.title" + }, + { + "==": [ + { + "var": "person.isArchived" + }, + 0 + ] + } + ] + }, + { + "or": [ + { + "dterm": [ + { + "title": "nur LEITER", + "stereotype": [ + "groupmembership" + ] + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1, + 2, + 4 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "148", + "1228", + "32", + "1237", + "1243", + "27", + "119" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "16", + "84", + "85", + "17", + "90", + "91" + ] + ] + } + ] + } + ] + } + ] + }, + { + "dterm": [ + { + "title": "ALLE", + "stereotype": [ + "groupmembership" + ] + }, + { + "and": [ + { + "and": [ + { + "oneof": [ + { + "var": "ctgroup.groupStatusId" + }, + [ + 1, + 2, + 4 + ] + ] + }, + { + "oneof": [ + { + "var": "ctgroup.id" + }, + [ + "1974" + ] + ] + } + ] + }, + { + "and": [ + { + "oneof": [ + { + "var": "role.id" + }, + [ + "15", + "16", + "17" + ] + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "primaryEntityAlias": "person", + "responseFields": [ + "person.id", + "person.firstName", + "person.lastName" + ] + } + }, + "process": { + "groupOnly": { + "active": { + "handleMembership": { + "groupMemberStatus": "none" + } + } + }, + "queryResultOnly": { + "none": { + "handleMembership": { + "groupMemberStatus": "active" + } + } + }, + "groupAndQueryResult": {} + } +} \ No newline at end of file diff --git a/tests/query-refs.test.ts b/tests/query-refs.test.ts new file mode 100644 index 0000000..6b2f94b --- /dev/null +++ b/tests/query-refs.test.ts @@ -0,0 +1,138 @@ +/** + * Portable-ruleset ergonomics (#76, Stages 1–2): the `var → RefKind` catalog and the pure + * `portablizeRuleset` reverse-rewrite helper. Everything here is offline and deterministic — the + * caller supplies the per-kind id→key maps; no network, no live writes. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { VAR_REF_KINDS, portablizeRuleset } from "../src/config/query-refs.js"; +import { q, churchQuery } from "../src/config/query.js"; +import { normalizeRuleset } from "../src/engine/dynamic.js"; +import { deepMapRefs, type Ref, type RefKind } from "../src/resolve/refs.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +describe("VAR_REF_KINDS catalog (#76 Stage 1)", () => { + it("maps every ChurchQuery var observed in the captured prod rulesets to a real RefKind", () => { + // Exactly the entity-bearing vars present in ct-structure/rulesets/*.json, verified against the + // real files (2026-07-11): the four kinds are the canonical RefKind strings from src/resolve/refs.ts. + expect(VAR_REF_KINDS).toEqual({ + "ctgroup.id": "group", + "ctgroup.campusId": "campus", + "ctgroup.groupTypeId": "group-type", + "person.campusId": "campus", + "role.id": "role-def", + }); + }); + + it("leaves non-entity and catalog-less vars OUT of the table (escape hatch)", () => { + // groupStatusId has no REST catalog (#67) → never a managed entity; isArchived/dateOfDeath are + // boolean/date literals. All three must be absent so portablize leaves their numbers untouched. + expect(VAR_REF_KINDS["ctgroup.groupStatusId"]).toBeUndefined(); + expect(VAR_REF_KINDS["person.isArchived"]).toBeUndefined(); + expect(VAR_REF_KINDS["person.dateOfDeath"]).toBeUndefined(); + }); +}); + +describe("portablizeRuleset (#76 Stage 2)", () => { + it("rewrites a managed id in a `==` var position to the exact ref marker shape", () => { + const ruleset = { query: churchQuery(q.eq("ctgroup.campusId", 7)) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: { campus: new Map([[7, "mainz"]]) }, + }); + const filter = (out.query as { params: { filter: { "==": unknown[] } } }).params.filter; + // Byte-identical to JSON.stringify(ref.campus("mainz")) — the marker isRef() already resolves. + expect(filter["=="][1]).toEqual({ __ctRef: true, kind: "campus", key: "mainz" }); + expect(warnings).toEqual([]); + }); + + it("rewrites every managed id in a `oneof` id list and keeps operand order", () => { + const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 1228])) }; + const { ruleset: out } = portablizeRuleset(ruleset, { + idToKeyByKind: { group: new Map([[148, "jugend-mainz"], [1228, "jugend-berlin"]]) }, + }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[0]).toEqual({ var: "ctgroup.id" }); + expect(filter.oneof[1]).toEqual([ + { __ctRef: true, kind: "group", key: "jugend-mainz" }, + { __ctRef: true, kind: "group", key: "jugend-berlin" }, + ]); + }); + + it("leaves an unmanaged id numeric and collects a { var, id } warning", () => { + const ruleset = { query: churchQuery(q.oneof("ctgroup.id", [148, 999])) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: { group: new Map([[148, "jugend-mainz"]]) }, + }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[1]).toEqual([{ __ctRef: true, kind: "group", key: "jugend-mainz" }, 999]); + expect(warnings).toEqual([{ var: "ctgroup.id", id: 999 }]); + }); + + it("never touches a catalog-less var (groupStatusId) — no rewrite, no warning", () => { + const ruleset = { query: churchQuery(q.oneof("ctgroup.groupStatusId", [1, 2, 4])) }; + const { ruleset: out, warnings } = portablizeRuleset(ruleset, { + idToKeyByKind: { group: new Map([[1, "nope"]]) }, + }); + const filter = (out.query as { params: { filter: { oneof: unknown[] } } }).params.filter; + expect(filter.oneof[1]).toEqual([1, 2, 4]); + expect(warnings).toEqual([]); + }); + + it("does not mutate its input ruleset", () => { + const ruleset = { query: churchQuery(q.eq("ctgroup.campusId", 7)) }; + const before = JSON.stringify(ruleset); + portablizeRuleset(ruleset, { idToKeyByKind: { campus: new Map([[7, "mainz"]]) } }); + expect(JSON.stringify(ruleset)).toEqual(before); + }); + + describe("real captured prod ruleset round-trip (sintegrationmeeting.json)", () => { + const raw = JSON.parse( + readFileSync(join(here, "fixtures/dynamic/portablize-sintegrationmeeting.json"), "utf8"), + ); + // captureDynamic normalizes first (numeric-string ids → numbers), so Stage 2 runs on numbers. + const normalized = normalizeRuleset(raw); + + // Every group/role id present in the fixture, mapped to a logical key. + const groupIds = [148, 1228, 32, 1237, 1243, 27, 119, 1974]; + const roleIds = [16, 84, 85, 17, 90, 91, 15]; + const idToKeyByKind: Partial>> = { + group: new Map(groupIds.map((id) => [id, `group-${id}`])), + "role-def": new Map(roleIds.map((id) => [id, `role-${id}`])), + }; + + it("resolves byte-faithfully back to the original ids (round-trip)", () => { + const { ruleset: portable } = portablizeRuleset(normalized, { idToKeyByKind }); + // Inverse map, applied with the SAME deepMapRefs the resolver uses — markers sit exactly where + // the ids were, so the whole ruleset restores byte-identical to the normalized original. + const keyToId = new Map(); + for (const [id, key] of [...groupIds.map((id) => [id, `group-${id}`] as const)]) keyToId.set(`group:${key}`, id); + for (const [id, key] of [...roleIds.map((id) => [id, `role-${id}`] as const)]) keyToId.set(`role-def:${key}`, id); + const back = deepMapRefs(portable, (r: Ref) => keyToId.get(`${r.kind}:${(r as { key: string }).key}`)); + expect(back).toEqual(normalized); + }); + + it("leaves groupStatusId ids numeric with no warnings for them", () => { + const { ruleset: portable, warnings } = portablizeRuleset(normalized, { idToKeyByKind }); + expect(JSON.stringify(portable)).toContain("[1,2,4]"); // the two groupStatusId oneof lists survive + expect(warnings).toEqual([]); // every group/role id was mapped, statuses are never candidates + }); + + it("warns for each unmanaged group/role id when the maps are partial", () => { + const partial: Partial>> = { + group: new Map([[148, "group-148"]]), + "role-def": new Map(), + }; + const { warnings } = portablizeRuleset(normalized, { idToKeyByKind: partial }); + // Deterministic query-traversal order; groupStatusId ids (1,2,4) never appear. + const ids = warnings.map((w) => w.id); + expect(ids).toContain(1228); + expect(ids).toContain(16); + expect(ids).not.toContain(1); // groupStatusId never warns + expect(warnings.every((w) => w.var === "ctgroup.id" || w.var === "role.id")).toBe(true); + }); + }); +});