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
15 changes: 10 additions & 5 deletions apps/editor/src/YourPersonPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI
// The grant predates People consent or the optional identity permission was declined.
const [identityDenied, setIdentityDenied] = useState(false);
const [revision, setRevision] = useState(0);
// The engine kept the Person type because no declared starter baseline applies.
const [starterKept, setStarterKept] = useState(false);
const [setup, setSetup] = useState<{ provision: TypePackProvision; assessment: TypePackAssessment; upgrade?: ReturnType<typeof requireGuidedPersonSetup>["upgrade"]; controller: AbortController }>();
const setupPanel = useRef<HTMLElement>(null);
const setupButton = useRef<HTMLButtonElement>(null);
Expand All @@ -56,7 +58,7 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI
useEffect(() => {
const controller = new AbortController();
lifecycle.current = controller;
setDirectory(undefined); setContacts([]); setConversion(undefined); setSetup(undefined); setError(""); setIdentityDenied(false); setConfirmClaimed(false);
setDirectory(undefined); setContacts([]); setConversion(undefined); setSetup(undefined); setStarterKept(false); setError(""); setIdentityDenied(false); setConfirmClaimed(false);
void (async () => {
try {
if (!gateway.peopleDirectory) throw new Error("This collection has no Connect account identity.");
Expand Down Expand Up @@ -104,8 +106,9 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI
const assessment = await gateway.assessTypePack(provision);
if (controller.signal.aborted) return;
assertCurrent();
const { upgrade } = requireGuidedPersonSetup(provision, assessment);
setSetup({ provision, assessment, upgrade, controller });
const { upgrade, kept } = requireGuidedPersonSetup(provision, assessment);
if (kept) setStarterKept(true);
else setSetup({ provision, assessment, upgrade, controller });
} catch (reason) {
if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Could not review person setup.");
} finally { setBusy(false); }
Expand Down Expand Up @@ -218,10 +221,12 @@ export function YourPersonPanel({ gateway, description, canCreate, canEdit, canI
{conversion && <div className="settings-review" role="region" aria-label="Review contact conversion"><p>This changes only <code>{conversion.path}</code> to the <strong>{conversion.typeName}</strong> type, which implements both Person and Contact. Its contact information and Markdown body are retained. No other contacts or type definitions are changed.</p><details className="settings-details"><summary><span>Review fields to write</span><ChevronRight aria-hidden="true" /></summary><pre>{JSON.stringify(conversion.patch, null, 2)}</pre></details><div className="settings-review-actions"><button className="settings-secondary-action" type="button" disabled={busy || !canEdit} onClick={() => void convertContact()}>Convert and link this contact</button><button className="settings-quiet-action" type="button" disabled={busy} onClick={() => setConversion(undefined)}>Cancel conversion</button></div></div>}
{!canEdit && <p className="settings-note">Ask a collection editor to link your person record.</p>}
{(implementations.length === 0 || outdatedStarter) && <>
<div className="setting-row">{implementations.length === 0
<div className="setting-row">{starterKept
? <div role="status"><h3>Person type kept as it is</h3><p>This collection's Person type was not added from a Person starter that can be upgraded automatically, so it has not been changed. {implementations.length === 0 ? "Review its Person mappings in Types before creating a person record." : "You can still use it, or compare it with the current starter in Types."}</p></div>
: implementations.length === 0
? <div><h3>Person definitions needed</h3><p>This collection needs person definitions before you can create or link a person record. Nothing will be added without your approval.</p></div>
: <div><h3>Person type update available</h3><p>The <strong>{outdatedStarter!.typeName}</strong> type is an earlier Person starter. Review the current starter before creating your record. Nothing will change without your approval.</p></div>}
{!setup && <button ref={setupButton} className="settings-secondary-action" type="button" disabled={busy || !canInstall || !onRefreshDescription} onClick={() => void reviewSetup()}>{busy ? "Checking person setup…" : implementations.length === 0 ? "Set up person records" : "Review Person type update"}</button>}</div>
{!setup && !starterKept && <button ref={setupButton} className="settings-secondary-action" type="button" disabled={busy || !canInstall || !onRefreshDescription} onClick={() => void reviewSetup()}>{busy ? "Checking person setup…" : implementations.length === 0 ? "Set up person records" : "Review Person type update"}</button>}</div>
{!canInstall && <p className="settings-note">Adding definitions requires permission to manage this collection's types.</p>}
{setup && <section ref={setupPanel} className="settings-review" tabIndex={-1} aria-label="Review person setup">
{setup.upgrade ? <>
Expand Down
4 changes: 2 additions & 2 deletions apps/editor/src/person-setup-pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ it("loads the exact SHA-pinned canonical People/Contact provision without a cata
expect(person).toContain(" required: [name]\n");
expect(person).not.toMatch(/^ {6}type:$/m);
// Person v3 upgrades the exact Person v2 seed from 1.2.0.
expect(provision.manifest.resources.find(({ target }) => target === "_types/person.md")?.upgrade_from?.digest)
.toBe("sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362");
expect(provision.manifest.resources.find(({ target }) => target === "_types/person.md")?.upgrade_from)
.toMatchObject({ digest: "sha256:aa81c2964285ecc69fcb96bca4b6ae80bbddeb648908d840d7c7be50df15b362" });
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls[0][0]).not.toContain("mdbase.dev/contracts");
});
Expand Down
33 changes: 30 additions & 3 deletions apps/editor/src/person-setup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { loadPersonSetup, requireGuidedPersonSetup } from "./person-setup";
import bundled from "./person-setup.pack.json";
// Real `mdbase packs assess` results (CLI 0.1.0-beta.123) for the bundled 1.3.0
// provision in a collection with mdbase.contact 1.2.0 applied: `unedited` keeps
// the Person v2 seed as installed; `merged` has an edited Markdown body.
// the Person v2 seed as installed; `merged` has an edited Markdown body and a
// lock recording that seed's origin. `upgradeBaseline`, `originDigest`, and the
// whole `unknownOrigin` case (an edited seed under a lock without an origin) are
// hand-constructed from mdbase-spec 05A until the engines report them.
import upgrades from "./test/person-starter-upgrade.assessment.json";
import { chooseOption } from "./test/select";

Expand Down Expand Up @@ -156,13 +159,37 @@ it("sends every other replacement, removal, conflict, or downgrade to Types", ()
expect(() => requireGuidedPersonSetup(provision, managed)).toThrow("No files have been changed");
const downgrade = upgrade("unedited"); downgrade.status = "downgrade";
expect(() => requireGuidedPersonSetup(provision, downgrade)).toThrow("No files have been changed");
// A seed update the pack does not declare: no upgrade_from, or a different installed baseline.
// A seed update the pack does not declare: no upgrade_from, no reported baseline, or another one.
const undeclared = structuredClone(provision);
delete undeclared.manifest.resources.find((r) => r.target === "_types/person.md")!.upgrade_from;
expect(() => requireGuidedPersonSetup(undeclared, upgrade("unedited"))).toThrow("No files have been changed");
const otherBaseline = upgrade("merged"); person(otherBaseline).installedDigest = "sha256:other";
const unreported = upgrade("merged"); delete person(unreported).upgradeBaseline;
expect(() => requireGuidedPersonSetup(provision, unreported)).toThrow("No files have been changed");
const otherBaseline = upgrade("merged"); person(otherBaseline).upgradeBaseline = { digest: "sha256:other" };
expect(() => requireGuidedPersonSetup(provision, otherBaseline)).toThrow("No files have been changed");
});
it("accepts an upgrade from any baseline the pack lists, as the engine reports it", () => {
const first = "---\nkind: mdbase.type\nname: person\nversion: 1\n---\n";
const listed = structuredClone(provision);
const seed = listed.manifest.resources.find((r) => r.target === "_types/person.md")!;
seed.upgrade_from = [{ digest: "sha256:person-v1", document: first, version: 1 }, ...[seed.upgrade_from ?? []].flat()];
const fromFirst = upgrade("merged");
Object.assign(fromFirst.resources.find((r) => r.target === "_types/person.md")!, { upgradeBaseline: { digest: "sha256:person-v1", version: 1 } });
expect(requireGuidedPersonSetup(listed, fromFirst)).toEqual({ upgrade: { target: "_types/person.md", merged: true } });
expect(requireGuidedPersonSetup(listed, upgrade("unedited"))).toEqual({ upgrade: { target: "_types/person.md", merged: false } });
expect(() => requireGuidedPersonSetup(provision, fromFirst)).toThrow("No files have been changed");
});
it("reports a Person type the engine keeps without a known starter, instead of offering it again", async () => {
expect(requireGuidedPersonSetup(provision, upgrade("unknownOrigin"))).toEqual({ kept: true });
const f = fixture(true, personAt(2), upgrade("unknownOrigin"));
fireEvent.click(await screen.findByRole("button", { name: "Review Person type update" }));
expect(await screen.findByRole("heading", { name: "Person type kept as it is" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Review Person type update" })).toBeNull();
expect(screen.queryByRole("region", { name: "Review person setup" })).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByRole("button", { name: "Create my person record" })).toBeInTheDocument();
expect(f.gateway.applyTypePack).not.toHaveBeenCalled();
});
it("reviews an earlier Person starter as an upgrade that keeps collection edits", async () => {
const f = fixture(true, personAt(2), upgrade("merged"));
f.refresh.mockImplementationOnce(async () => { f.view.rerender(f.panel(personAt(3))); return personAt(3); });
Expand Down
16 changes: 10 additions & 6 deletions apps/editor/src/person-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,29 @@ interface PersonStarterUpgrade {
merged: boolean;
}

/** A seed type replaced through the provision's own reviewed `upgrade_from` baseline. */
/** A seed type the engine upgrades from one of the bundled provision's own reviewed baselines. */
function starterUpgrade(provision: TypePackProvision, resource: TypePackResourceDiff): PersonStarterUpgrade | undefined {
const seed = provision.manifest.resources.find((candidate) => candidate.source === resource.source && candidate.target === resource.target);
if (resource.action !== "update" || resource.kind !== "type" || resource.mode !== "seed" || seed?.kind !== "type" || seed.mode !== "seed") return undefined;
if (!seed.upgrade_from || resource.installedDigest !== seed.upgrade_from.digest) return undefined;
return { target: resource.target, merged: resource.currentDigest !== resource.installedDigest };
const baseline = resource.upgradeBaseline;
if (!baseline || ![seed.upgrade_from ?? []].flat().some(({ digest }) => digest === baseline.digest)) return undefined;
return { target: resource.target, merged: resource.currentDigest !== baseline.digest };
}

export function requireGuidedPersonSetup(provision: TypePackProvision, assessment: TypePackAssessment) {
export function requireGuidedPersonSetup(provision: TypePackProvision, assessment: TypePackAssessment): { upgrade?: PersonStarterUpgrade; kept?: true } {
// This guided flow adds missing definitions, records ownership of identical
// existing bytes, or upgrades an older Person starter the pack declares it
// supersedes. Other updates, adoptions, and downgrades belong in Types.
// existing bytes, or upgrades an older Person starter from a baseline the pack
// declares. Other updates, adoptions, and downgrades belong in Types.
const unsafe = assessment.resources.find((resource) =>
(["update", "delete", "conflict"].includes(resource.action) && !starterUpgrade(provision, resource))
|| (resource.action === "adopt" && resource.currentDigest !== resource.digest));
if (!assessment.applicable || assessment.status === "downgrade" || unsafe) {
throw new Error(unsafe?.reason ?? "These definitions need review in Types. No files have been changed.");
}
const person = assessment.resources.find((resource) => resource.target === PERSON_STARTER.target);
// The engine keeps a Person type it cannot trace to a declared starter, with a
// reason. Retrying would only repeat that, so setup reports it and stops.
if (person?.action === "preserve" && person.mode === "seed" && person.reason) return { kept: true };
const upgrade = person && starterUpgrade(provision, person);
if (person?.action !== "create" && !upgrade) {
throw new Error("A Person type already exists but needs compatible mappings. Review it in Types; this setup will not replace it.");
Expand Down
Loading
Loading