fix: preserve reconciliation authorities

This commit is contained in:
DongHyeonka
2026-08-02 03:15:22 +09:00
parent d9afccdd60
commit 184bd98d92
9 changed files with 287 additions and 22 deletions
+81
View File
@@ -33,6 +33,7 @@ function FormHarness(props: Readonly<{
| Readonly<{ ok: true; value: string }>
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
>;
resetOnSuccess?: boolean;
}>) {
const form = useAppForm({
schema,
@@ -45,6 +46,7 @@ function FormHarness(props: Readonly<{
};
},
submit: props.submit,
resetOnSuccess: props.resetOnSuccess,
});
return (
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
@@ -63,6 +65,10 @@ function FormHarness(props: Readonly<{
<Button onClick={() => form.reset()} disabled={!form.dirty}>
Reset
</Button>
<Button onClick={() => form.settleApplied()}>Confirm applied</Button>
<Button onClick={() => form.settleNotApplied()}>
Confirm not applied
</Button>
<output data-testid="dirty">{String(form.dirty)}</output>
<output data-testid="result">{form.result}</output>
</Form>
@@ -152,6 +158,81 @@ describe("local form facade", () => {
expect(window.location.href).not.toContain(secretLike);
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
});
it("blocks a second submit while the prior effect remains unknown", async () => {
const user = userEvent.setup();
const submit = vi.fn(async () => ({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
}));
render(<FormHarness submit={submit} resetOnSuccess={false} />);
const name = screen.getByRole("textbox", { name: /Name/ });
await user.type(name, "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByTestId("result")).toHaveTextContent(
"effect-unknown",
);
await user.clear(name);
await user.type(name, "Beta");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).toHaveBeenCalledOnce();
expect(screen.getByTestId("result")).toHaveTextContent("effect-unknown");
});
it("settles the submitted unknown snapshot without accepting later edits", async () => {
const user = userEvent.setup();
const submit = vi.fn(async () => ({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
}));
render(<FormHarness submit={submit} resetOnSuccess={false} />);
const name = screen.getByRole("textbox", { name: /Name/ });
await user.type(name, "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("effect-unknown");
await user.clear(name);
await user.type(name, "Beta");
await user.click(screen.getByRole("button", { name: "Confirm applied" }));
expect(name).toHaveValue("Beta");
expect(screen.getByTestId("result")).toHaveTextContent("success");
expect(screen.getByTestId("dirty")).toHaveTextContent("true");
await user.clear(name);
await user.type(name, "Alpha");
expect(screen.getByTestId("dirty")).toHaveTextContent("false");
});
it("releases an unknown submission only after explicit not-applied settlement", async () => {
const user = userEvent.setup();
const submit = vi
.fn()
.mockResolvedValueOnce({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
})
.mockResolvedValueOnce({ ok: true as const, value: "saved" });
render(<FormHarness submit={submit} resetOnSuccess={false} />);
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("effect-unknown");
await user.click(
screen.getByRole("button", { name: "Confirm not applied" }),
);
expect(screen.getByTestId("result")).toHaveTextContent("idle");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).toHaveBeenCalledTimes(2);
});
});
describe("dirty navigation guard", () => {
@@ -15,7 +15,12 @@ import type {
ReferenceFeatureInput,
ReferenceResult,
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
import { REFERENCE_FEATURE_ID } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import {
REFERENCE_FEATURE_ID,
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
REFERENCE_RESOURCE_QUERY_NAMESPACE,
} from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
import { INVALIDATION_REGISTRY } from "../../../src/features/installed-feature-contracts.ts";
import type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
import { createFailure } from "../../../src/contracts/errors.ts";
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.ts";
@@ -68,7 +73,7 @@ function renderReference(
});
},
});
return render(
return Object.assign(render(
<MutationIntentProvider factory={mutationIntentFactory}>
<QueryClientProvider client={client}>
<ServerStateScopeProvider runtime={serverStateScope}>
@@ -85,7 +90,7 @@ function renderReference(
</ServerStateScopeProvider>
</QueryClientProvider>
</MutationIntentProvider>,
);
), { client });
}
function inputWith(
@@ -114,6 +119,44 @@ function inputWith(
}
describe("reference feature page states", () => {
it("mounts list and detail keys under the installed governed namespace", async () => {
expect(REFERENCE_RESOURCE_QUERY_NAMESPACE).toEqual({
namespaceId: "reference-resource",
namespaceVersion: 1,
});
const installedEdge = INVALIDATION_REGISTRY.edges.find(
(edge) => edge.topicId === REFERENCE_RESOURCE_INVALIDATION_TOPIC,
);
expect(installedEdge?.namespace).toEqual(
REFERENCE_RESOURCE_QUERY_NAMESPACE,
);
const list = renderReference(inputWith());
await screen.findByRole("heading", { name: "표시할 항목이 없습니다." });
const listKey = list.client.getQueryCache().getAll()[0]?.queryKey;
expect(listKey?.slice(0, 4)).toEqual([
"query",
2,
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
]);
list.unmount();
const detail = renderReference(
inputWith(),
"/examples/reference-resources/reference-1",
);
await screen.findByText("Detail");
const detailKey = detail.client.getQueryCache().getAll()[0]?.queryKey;
expect(detailKey?.slice(0, 4)).toEqual([
"query",
2,
installedEdge?.namespace.namespaceId,
installedEdge?.namespace.namespaceVersion,
]);
detail.unmount();
});
it("renders loading, success and empty states through the installed route", async () => {
let resolveList:
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
@@ -25,6 +25,82 @@ function scope() {
}
describe("revision-safe optimistic layer runtime", () => {
it.each(["ROLLBACK", "RECONCILE"] as const)(
"rejects a composite-invalid candidate without losing prior %s authority",
(authority) => {
const client = new QueryClient();
const key = ["query", "composite-candidate", authority];
client.setQueryData(key, ["base"]);
const selectedScope = scope();
const runtime = createOptimisticLayerRuntime(client);
const first = runtime.begin(
key,
"first",
(previous, input) => [...(previous as string[]), input],
selectedScope.snapshot,
);
if (authority === "RECONCILE") first?.markUncertain();
const second = runtime.begin(
key,
"second",
(previous, input) => {
const values = previous as string[];
if (values.includes("first")) {
throw new Error("candidate composes only over the base");
}
return [...values, input];
},
selectedScope.snapshot,
);
expect(second).toBeNull();
expect(client.getQueryData(key)).toEqual(["base", "first"]);
if (authority === "ROLLBACK") {
first?.rollback();
} else {
first?.reconcile("NOT_APPLIED");
}
expect(client.getQueryData(key)).toEqual(["base"]);
},
);
it("writes a prevalidated candidate without invoking its updater twice", () => {
const client = new QueryClient();
const key = ["query", "single-candidate-invocation"];
client.setQueryData(key, ["base"]);
const selectedScope = scope();
const runtime = createOptimisticLayerRuntime(client);
const first = runtime.begin(
key,
"first",
(previous, input) => [...(previous as string[]), input],
selectedScope.snapshot,
);
let candidateCalls = 0;
const second = runtime.begin(
key,
"second",
(previous, input) => {
candidateCalls += 1;
if (candidateCalls > 1) {
throw new Error("candidate updater must not be replayed on admission");
}
return [...(previous as string[]), input];
},
selectedScope.snapshot,
);
expect(second).not.toBeNull();
expect(candidateCalls).toBe(1);
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
second?.rollback();
expect(client.getQueryData(key)).toEqual(["base", "first"]);
first?.rollback();
expect(client.getQueryData(key)).toEqual(["base"]);
});
it("removes only the failed layer when commands settle out of order", () => {
const client = new QueryClient();
const key = ["query", "resources"];