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
|
||||
|
||||
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";
|
||||
|
||||
export const REFERENCE_FEATURE_ID = "reference-feature";
|
||||
const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity(
|
||||
export const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity(
|
||||
"reference-resource",
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -131,7 +131,11 @@ export default function ReferenceResourceFormPage() {
|
||||
state={mutation.state}
|
||||
onReconcileUnknownEffect={(resolution) => {
|
||||
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 {
|
||||
REFERENCE_FEATURE_ID,
|
||||
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE,
|
||||
} from "../contracts/reference-feature-contract.ts";
|
||||
import type {
|
||||
ReferenceCreateCommand,
|
||||
@@ -62,8 +63,9 @@ export function useReferenceDetail(resourceId: string) {
|
||||
definitionId: "reference-resource-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: REFERENCE_FEATURE_ID,
|
||||
namespace: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||
namespaceVersion:
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: measureResourceView,
|
||||
@@ -108,8 +110,9 @@ export function useReferenceFeature() {
|
||||
definitionId: "reference-resource-list-v1",
|
||||
definitionVersion: 1,
|
||||
owner: REFERENCE_FEATURE_ID,
|
||||
namespace: "reference-resource",
|
||||
namespaceVersion: 1,
|
||||
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
|
||||
namespaceVersion:
|
||||
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
profileId: "LIST_STANDARD",
|
||||
measureResult: measureResourceList,
|
||||
|
||||
@@ -38,6 +38,15 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
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) => {
|
||||
if (
|
||||
writing ||
|
||||
@@ -70,12 +79,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
removeEntry(key, entry);
|
||||
return;
|
||||
}
|
||||
writing = true;
|
||||
try {
|
||||
queryClient.setQueryData(entry.queryKey, value);
|
||||
} finally {
|
||||
writing = false;
|
||||
}
|
||||
writeProjection(entry, value);
|
||||
}
|
||||
|
||||
function collapse(key: string, entry: EntryState): void {
|
||||
@@ -132,15 +136,20 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
return null;
|
||||
}
|
||||
const layer: Layer = {
|
||||
id: nextId++,
|
||||
id: nextId,
|
||||
status: "pending",
|
||||
apply: (value) => update(value, input),
|
||||
};
|
||||
let projected: unknown;
|
||||
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) {
|
||||
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||
if (entry.layers.length > 0) return null;
|
||||
removeEntry(key, entry);
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
@@ -150,8 +159,9 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||
return null;
|
||||
}
|
||||
nextId += 1;
|
||||
entry.layers.push(layer);
|
||||
project(key, entry);
|
||||
writeProjection(entry, projected);
|
||||
let state: "pending" | "uncertain" | "settled" = "pending";
|
||||
const selectedLayer = () => {
|
||||
if (entries.get(key) !== entry) return undefined;
|
||||
|
||||
@@ -64,6 +64,7 @@ export function useAppForm<
|
||||
const [pending, setPending] = useState(false);
|
||||
const [result, setResult] = useState<FormResultState>("idle");
|
||||
const pendingRef = useRef<Promise<FormResult<Output>> | null>(null);
|
||||
const unknownSubmissionRef = useRef<Values | null>(null);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => JSON.stringify(values) !== JSON.stringify(initialValues),
|
||||
@@ -101,7 +102,9 @@ export function useAppForm<
|
||||
return next;
|
||||
});
|
||||
setFormErrors([]);
|
||||
setResult("idle");
|
||||
setResult((current) =>
|
||||
unknownSubmissionRef.current ? current : "idle",
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -125,6 +128,7 @@ export function useAppForm<
|
||||
|
||||
const reset = useCallback(
|
||||
(nextValues: Values = defaultValues) => {
|
||||
unknownSubmissionRef.current = null;
|
||||
setValues(nextValues);
|
||||
setInitialValues(nextValues);
|
||||
setTouched(new Set());
|
||||
@@ -137,6 +141,7 @@ export function useAppForm<
|
||||
|
||||
const settleSuccessfulValues = useCallback(
|
||||
(settledValues: Values) => {
|
||||
unknownSubmissionRef.current = null;
|
||||
setFieldErrors({} as FieldErrors<Values>);
|
||||
setFormErrors([]);
|
||||
setResult("success");
|
||||
@@ -152,13 +157,24 @@ export function useAppForm<
|
||||
);
|
||||
|
||||
const settleApplied = useCallback(() => {
|
||||
settleSuccessfulValues(values);
|
||||
}, [settleSuccessfulValues, values]);
|
||||
const submittedValues = unknownSubmissionRef.current;
|
||||
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(
|
||||
async (event?: FormEvent<HTMLFormElement>): Promise<FormResult<Output> | null> => {
|
||||
event?.preventDefault();
|
||||
if (pendingRef.current) return pendingRef.current;
|
||||
if (unknownSubmissionRef.current) return null;
|
||||
setFieldErrors({} as FieldErrors<Values>);
|
||||
setFormErrors([]);
|
||||
|
||||
@@ -194,6 +210,7 @@ export function useAppForm<
|
||||
return outcome;
|
||||
}
|
||||
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
||||
unknownSubmissionRef.current = null;
|
||||
const mapped = mapValidationFailureToFields<Values>(
|
||||
outcome.error,
|
||||
allowedServerFields,
|
||||
@@ -204,16 +221,19 @@ export function useAppForm<
|
||||
setResult("validation-error");
|
||||
focusFirstError(mapped.fieldErrors);
|
||||
} else if (outcome.error.effect === "MAYBE_APPLIED") {
|
||||
unknownSubmissionRef.current = parsed.data;
|
||||
setFormErrors([]);
|
||||
setResult("effect-unknown");
|
||||
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
|
||||
settleSuccessfulValues(parsed.data);
|
||||
} else if (outcome.error.kind === "CONFLICT") {
|
||||
unknownSubmissionRef.current = null;
|
||||
setFormErrors([
|
||||
message("form.conflict"),
|
||||
]);
|
||||
setResult("conflict");
|
||||
} else {
|
||||
unknownSubmissionRef.current = null;
|
||||
setFormErrors([message("form.unavailable")]);
|
||||
setResult("unavailable");
|
||||
}
|
||||
@@ -251,6 +271,7 @@ export function useAppForm<
|
||||
submitForm,
|
||||
reset,
|
||||
settleApplied,
|
||||
settleNotApplied,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user