fix: preserve reconciliation authorities
This commit is contained in:
@@ -81,3 +81,30 @@ The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scop
|
|||||||
## Final review
|
## Final review
|
||||||
|
|
||||||
The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge.
|
The scoped reviewer completed two fix rounds covering durable ownership, FIFO/races, global bounds, scope fences, and production form settlement. The final verdict reported no findings, independently passed 4 files / 84 tests, confirmed `git diff --check`, and assessed the change ready to merge.
|
||||||
|
|
||||||
|
## Runtime final-review fix round 3
|
||||||
|
|
||||||
|
The runtime-wide final review identified three additional Task 5 authorities. This round addresses only those findings; the provider-neutral HTTP operation port remains deferred to its separately owned remediation plans.
|
||||||
|
|
||||||
|
### RED evidence
|
||||||
|
|
||||||
|
- Composite optimistic admission: `tests/unit/optimistic-layer-runtime.test.ts` failed 2 / 8 cases because a base-valid candidate that threw only after the prior layer returned a lease and orphaned both rollback and reconciliation authority.
|
||||||
|
- Candidate replay: the isolated admission test failed with candidate updater call count `2` instead of `1`; replay through `project()` could still delete the entry after successful preflight.
|
||||||
|
- Form reconciliation: `tests/component/form-foundation.test.tsx` failed 3 / 8 cases. The hook admitted a second submit during unknown effect, settled edited value B instead of submitted snapshot A, and exposed no explicit not-applied release authority.
|
||||||
|
- Production namespace parity: the mounted reference-page regression failed because `REFERENCE_RESOURCE_QUERY_NAMESPACE` was not exported; production list/detail hooks could only duplicate its id/version literals.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. `OptimisticLayerRuntime.begin()` now computes the complete ordered projection before admission. A composite failure returns pessimistic fallback `null` without changing the existing entry, cache projection, layer IDs, or earlier lease authority. The admitted candidate is written from that precomputed value, so its updater runs exactly once during admission.
|
||||||
|
2. `useAppForm` retains the exact parsed values for a `MAYBE_APPLIED` submission. The ref is the hook-level admission lock until `settleApplied`, `settleNotApplied`, or `reset` releases it; later edits preserve the unknown result and cannot trigger another command. With `resetOnSuccess: false`, APPLIED makes submitted A the baseline while edited B remains dirty. Success, applied-confirmed, validation/conflict/unavailable outcomes, reset, and explicit not-applied settlement clear the retained snapshot.
|
||||||
|
3. The reference form routes both reconciliation outcomes into the corresponding form settlement authority.
|
||||||
|
4. `REFERENCE_RESOURCE_QUERY_NAMESPACE` is exported from the governed feature contract. Both production list and detail query definitions consume its fields, while the mounted-key regression compares both real query prefixes with the installed invalidation edge.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- Focused runtime/form/reference command: 5 files / 87 tests — PASS.
|
||||||
|
- `corepack pnpm check:types` — PASS for app, node, test, recipes, web worker, and service worker.
|
||||||
|
- `corepack pnpm lint` — PASS with zero warnings.
|
||||||
|
- `corepack pnpm test:all` — PASS: runtime schema 40, unit 744, component 126, integration 23, reference feature 25, recipes 17.
|
||||||
|
- `git diff --check` — PASS.
|
||||||
|
- Scoped re-review by the existing Task 5 reviewer: no findings, ready to merge. The reviewer independently passed the 5-file scoped suite (96 / 96), confirmed `git diff --check`, verified all three reconciliation authorities plus candidate single-invocation, and confirmed the deferred HTTP adapter remained untouched.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { REFERENCE_RUNTIME_SCHEMA_CODECS } from "./reference-schemas.ts";
|
|||||||
import { REFERENCE_BOUNDARY_MAPPERS } from "./reference-mapper.ts";
|
import { REFERENCE_BOUNDARY_MAPPERS } from "./reference-mapper.ts";
|
||||||
|
|
||||||
export const REFERENCE_FEATURE_ID = "reference-feature";
|
export const REFERENCE_FEATURE_ID = "reference-feature";
|
||||||
const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity(
|
export const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity(
|
||||||
"reference-resource",
|
"reference-resource",
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ export default function ReferenceResourceFormPage() {
|
|||||||
state={mutation.state}
|
state={mutation.state}
|
||||||
onReconcileUnknownEffect={(resolution) => {
|
onReconcileUnknownEffect={(resolution) => {
|
||||||
void mutation.reconcileUnknownEffect(resolution).then(() => {
|
void mutation.reconcileUnknownEffect(resolution).then(() => {
|
||||||
if (resolution === "APPLIED") form.settleApplied();
|
if (resolution === "APPLIED") {
|
||||||
|
form.settleApplied();
|
||||||
|
} else {
|
||||||
|
form.settleNotApplied();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
|
|||||||
import {
|
import {
|
||||||
REFERENCE_FEATURE_ID,
|
REFERENCE_FEATURE_ID,
|
||||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||||
|
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||||
} from "../contracts/reference-feature-contract.ts";
|
} from "../contracts/reference-feature-contract.ts";
|
||||||
import type {
|
import type {
|
||||||
ReferenceCreateCommand,
|
ReferenceCreateCommand,
|
||||||
@@ -62,8 +63,9 @@ export function useReferenceDetail(resourceId: string) {
|
|||||||
definitionId: "reference-resource-detail-v1",
|
definitionId: "reference-resource-detail-v1",
|
||||||
definitionVersion: 1,
|
definitionVersion: 1,
|
||||||
owner: REFERENCE_FEATURE_ID,
|
owner: REFERENCE_FEATURE_ID,
|
||||||
namespace: "reference-resource",
|
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||||
namespaceVersion: 1,
|
namespaceVersion:
|
||||||
|
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||||
operationId: "GET_REFERENCE_RESOURCE",
|
operationId: "GET_REFERENCE_RESOURCE",
|
||||||
profileId: "DETAIL_STANDARD",
|
profileId: "DETAIL_STANDARD",
|
||||||
measureResult: measureResourceView,
|
measureResult: measureResourceView,
|
||||||
@@ -108,8 +110,9 @@ export function useReferenceFeature() {
|
|||||||
definitionId: "reference-resource-list-v1",
|
definitionId: "reference-resource-list-v1",
|
||||||
definitionVersion: 1,
|
definitionVersion: 1,
|
||||||
owner: REFERENCE_FEATURE_ID,
|
owner: REFERENCE_FEATURE_ID,
|
||||||
namespace: "reference-resource",
|
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||||
namespaceVersion: 1,
|
namespaceVersion:
|
||||||
|
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||||
operationId: "LIST_REFERENCE_RESOURCES",
|
operationId: "LIST_REFERENCE_RESOURCES",
|
||||||
profileId: "LIST_STANDARD",
|
profileId: "LIST_STANDARD",
|
||||||
measureResult: measureResourceList,
|
measureResult: measureResourceList,
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
entry.disposeScopeListener = null;
|
entry.disposeScopeListener = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeProjection(entry: EntryState, value: unknown): void {
|
||||||
|
writing = true;
|
||||||
|
try {
|
||||||
|
queryClient.setQueryData(entry.queryKey, value);
|
||||||
|
} finally {
|
||||||
|
writing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.getQueryCache().subscribe((event) => {
|
queryClient.getQueryCache().subscribe((event) => {
|
||||||
if (
|
if (
|
||||||
writing ||
|
writing ||
|
||||||
@@ -70,12 +79,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
removeEntry(key, entry);
|
removeEntry(key, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writing = true;
|
writeProjection(entry, value);
|
||||||
try {
|
|
||||||
queryClient.setQueryData(entry.queryKey, value);
|
|
||||||
} finally {
|
|
||||||
writing = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapse(key: string, entry: EntryState): void {
|
function collapse(key: string, entry: EntryState): void {
|
||||||
@@ -132,15 +136,20 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const layer: Layer = {
|
const layer: Layer = {
|
||||||
id: nextId++,
|
id: nextId,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
apply: (value) => update(value, input),
|
apply: (value) => update(value, input),
|
||||||
};
|
};
|
||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
try {
|
try {
|
||||||
projected = update(entry.base, input);
|
projected = entry.base;
|
||||||
|
for (const existingLayer of entry.layers) {
|
||||||
|
projected = existingLayer.apply(projected);
|
||||||
|
}
|
||||||
|
projected = layer.apply(projected);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (entry.layers.length === 0) removeEntry(key, entry);
|
if (entry.layers.length > 0) return null;
|
||||||
|
removeEntry(key, entry);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -150,8 +159,9 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
if (entry.layers.length === 0) removeEntry(key, entry);
|
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
nextId += 1;
|
||||||
entry.layers.push(layer);
|
entry.layers.push(layer);
|
||||||
project(key, entry);
|
writeProjection(entry, projected);
|
||||||
let state: "pending" | "uncertain" | "settled" = "pending";
|
let state: "pending" | "uncertain" | "settled" = "pending";
|
||||||
const selectedLayer = () => {
|
const selectedLayer = () => {
|
||||||
if (entries.get(key) !== entry) return undefined;
|
if (entries.get(key) !== entry) return undefined;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export function useAppForm<
|
|||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [result, setResult] = useState<FormResultState>("idle");
|
const [result, setResult] = useState<FormResultState>("idle");
|
||||||
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
|
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
|
||||||
|
const unknownSubmissionRef = useRef<Values | null>(null);
|
||||||
|
|
||||||
const dirty = useMemo(
|
const dirty = useMemo(
|
||||||
() => JSON.stringify(values) !== JSON.stringify(initialValues),
|
() => JSON.stringify(values) !== JSON.stringify(initialValues),
|
||||||
@@ -101,7 +102,9 @@ export function useAppForm<
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setFormErrors([]);
|
setFormErrors([]);
|
||||||
setResult("idle");
|
setResult((current) =>
|
||||||
|
unknownSubmissionRef.current ? current : "idle",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -125,6 +128,7 @@ export function useAppForm<
|
|||||||
|
|
||||||
const reset = useCallback(
|
const reset = useCallback(
|
||||||
(nextValues: Values = defaultValues) => {
|
(nextValues: Values = defaultValues) => {
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
setValues(nextValues);
|
setValues(nextValues);
|
||||||
setInitialValues(nextValues);
|
setInitialValues(nextValues);
|
||||||
setTouched(new Set());
|
setTouched(new Set());
|
||||||
@@ -137,6 +141,7 @@ export function useAppForm<
|
|||||||
|
|
||||||
const settleSuccessfulValues = useCallback(
|
const settleSuccessfulValues = useCallback(
|
||||||
(settledValues: Values) => {
|
(settledValues: Values) => {
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
setFieldErrors({} as FieldErrors<Values>);
|
setFieldErrors({} as FieldErrors<Values>);
|
||||||
setFormErrors([]);
|
setFormErrors([]);
|
||||||
setResult("success");
|
setResult("success");
|
||||||
@@ -152,13 +157,24 @@ export function useAppForm<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const settleApplied = useCallback(() => {
|
const settleApplied = useCallback(() => {
|
||||||
settleSuccessfulValues(values);
|
const submittedValues = unknownSubmissionRef.current;
|
||||||
}, [settleSuccessfulValues, values]);
|
if (!submittedValues) return;
|
||||||
|
settleSuccessfulValues(submittedValues);
|
||||||
|
}, [settleSuccessfulValues]);
|
||||||
|
|
||||||
|
const settleNotApplied = useCallback(() => {
|
||||||
|
if (!unknownSubmissionRef.current) return;
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
|
setFieldErrors({} as FieldErrors<Values>);
|
||||||
|
setFormErrors([]);
|
||||||
|
setResult("idle");
|
||||||
|
}, []);
|
||||||
|
|
||||||
const submitForm = useCallback(
|
const submitForm = useCallback(
|
||||||
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
|
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
if (pendingRef.current) return pendingRef.current;
|
if (pendingRef.current) return pendingRef.current;
|
||||||
|
if (unknownSubmissionRef.current) return null;
|
||||||
setFieldErrors({} as FieldErrors<Values>);
|
setFieldErrors({} as FieldErrors<Values>);
|
||||||
setFormErrors([]);
|
setFormErrors([]);
|
||||||
|
|
||||||
@@ -194,6 +210,7 @@ export function useAppForm<
|
|||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
const mapped = mapValidationFailureToFields<Values>(
|
const mapped = mapValidationFailureToFields<Values>(
|
||||||
outcome.error,
|
outcome.error,
|
||||||
allowedServerFields,
|
allowedServerFields,
|
||||||
@@ -204,16 +221,19 @@ export function useAppForm<
|
|||||||
setResult("validation-error");
|
setResult("validation-error");
|
||||||
focusFirstError(mapped.fieldErrors);
|
focusFirstError(mapped.fieldErrors);
|
||||||
} else if (outcome.error.effect === "MAYBE_APPLIED") {
|
} else if (outcome.error.effect === "MAYBE_APPLIED") {
|
||||||
|
unknownSubmissionRef.current = parsed.data;
|
||||||
setFormErrors([]);
|
setFormErrors([]);
|
||||||
setResult("effect-unknown");
|
setResult("effect-unknown");
|
||||||
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
|
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
|
||||||
settleSuccessfulValues(parsed.data);
|
settleSuccessfulValues(parsed.data);
|
||||||
} else if (outcome.error.kind === "CONFLICT") {
|
} else if (outcome.error.kind === "CONFLICT") {
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
setFormErrors([
|
setFormErrors([
|
||||||
message("form.conflict"),
|
message("form.conflict"),
|
||||||
]);
|
]);
|
||||||
setResult("conflict");
|
setResult("conflict");
|
||||||
} else {
|
} else {
|
||||||
|
unknownSubmissionRef.current = null;
|
||||||
setFormErrors([message("form.unavailable")]);
|
setFormErrors([message("form.unavailable")]);
|
||||||
setResult("unavailable");
|
setResult("unavailable");
|
||||||
}
|
}
|
||||||
@@ -251,6 +271,7 @@ export function useAppForm<
|
|||||||
submitForm,
|
submitForm,
|
||||||
reset,
|
reset,
|
||||||
settleApplied,
|
settleApplied,
|
||||||
|
settleNotApplied,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function FormHarness(props: Readonly<{
|
|||||||
| Readonly<{ ok: true; value: string }>
|
| Readonly<{ ok: true; value: string }>
|
||||||
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
||||||
>;
|
>;
|
||||||
|
resetOnSuccess?: boolean;
|
||||||
}>) {
|
}>) {
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
schema,
|
schema,
|
||||||
@@ -45,6 +46,7 @@ function FormHarness(props: Readonly<{
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
submit: props.submit,
|
submit: props.submit,
|
||||||
|
resetOnSuccess: props.resetOnSuccess,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
|
<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}>
|
<Button onClick={() => form.reset()} disabled={!form.dirty}>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</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="dirty">{String(form.dirty)}</output>
|
||||||
<output data-testid="result">{form.result}</output>
|
<output data-testid="result">{form.result}</output>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -152,6 +158,81 @@ describe("local form facade", () => {
|
|||||||
expect(window.location.href).not.toContain(secretLike);
|
expect(window.location.href).not.toContain(secretLike);
|
||||||
expect(JSON.stringify(localStorage)).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", () => {
|
describe("dirty navigation guard", () => {
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import type {
|
|||||||
ReferenceFeatureInput,
|
ReferenceFeatureInput,
|
||||||
ReferenceResult,
|
ReferenceResult,
|
||||||
} from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
} 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 type { ReferenceResourceView } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
import { createFailure } from "../../../src/contracts/errors.ts";
|
||||||
import type { QueryInvalidationCoordinator } from "../../../src/contracts/query-invalidation.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}>
|
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<ServerStateScopeProvider runtime={serverStateScope}>
|
<ServerStateScopeProvider runtime={serverStateScope}>
|
||||||
@@ -85,7 +90,7 @@ function renderReference(
|
|||||||
</ServerStateScopeProvider>
|
</ServerStateScopeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MutationIntentProvider>,
|
</MutationIntentProvider>,
|
||||||
);
|
), { client });
|
||||||
}
|
}
|
||||||
|
|
||||||
function inputWith(
|
function inputWith(
|
||||||
@@ -114,6 +119,44 @@ function inputWith(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("reference feature page states", () => {
|
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 () => {
|
it("renders loading, success and empty states through the installed route", async () => {
|
||||||
let resolveList:
|
let resolveList:
|
||||||
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
| ((result: ReferenceResult<readonly ReferenceResourceView[]>) => void)
|
||||||
|
|||||||
@@ -25,6 +25,82 @@ function scope() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("revision-safe optimistic layer runtime", () => {
|
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", () => {
|
it("removes only the failed layer when commands settle out of order", () => {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const key = ["query", "resources"];
|
const key = ["query", "resources"];
|
||||||
|
|||||||
Reference in New Issue
Block a user