fix: retain uncertain optimistic mutations
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
# Task 5 Report: Retain and reconcile uncertain optimistic mutations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Task 5 is implemented. Mutation settlement now follows explicit effect certainty, preserves unknown optimistic projections as ordered uncertain layers, and exposes one-at-a-time reconciliation bound to the original mutation record. Missing post-dispatch certainty is fail-safe `MAYBE_APPLIED`; only controller-owned pre-dispatch failures are marked `NOT_STARTED`.
|
||||||
|
|
||||||
|
## RED evidence
|
||||||
|
|
||||||
|
- Initial focused command: `corepack pnpm exec vitest run tests/unit/optimistic-layer-runtime.test.ts tests/component/application-query.test.tsx`.
|
||||||
|
- Initial result: exit `1`, 2 files, 11 failed / 28 passed. Missing lease/controller APIs failed directly; `APPLIED_CONFIRMED` and `MAYBE_APPLIED` were rolled back; effectless failures retained generic retry semantics.
|
||||||
|
- Review-driven RED: the three-file focused command including `tests/component/async-surface.test.tsx` exited `1` with 7 failed / 56 passed. It exposed applied-confirmed retry actions, active-submit reset, double reconciliation, stale-scope queue retention, overlay priority, and the incorrect refreshing copy.
|
||||||
|
- A final isolated RED proved a synchronous `NOT_APPLIED` double action could consume two FIFO records in one event turn.
|
||||||
|
- The production-form RED exited `1` with 2 failed / 8 passed: applied reconciliation left the original command dirty/retryable, while an `APPLIED_CONFIRMED` failure rendered generic unavailable.
|
||||||
|
- The final durability/lifecycle RED failed 2 / 2: an anonymous non-optimistic legacy channel did not survive remount, and render-time registry allocation exhausted the definition cap during an abandoned server render.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
1. `OptimisticLayerLease` now supports `markUncertain()` and `reconcile("APPLIED" | "NOT_APPLIED")`. Layers are `pending | uncertain | committed`; only a committed prefix collapses into the base, while projection continues to apply every later layer in order.
|
||||||
|
2. Reconciliation is single-settlement and idempotent. `APPLIED` converts the uncertain layer to committed; `NOT_APPLIED` removes only that layer; both then collapse/reproject later committed or pending layers. Scope expiry removes stale cache instead of restoring it.
|
||||||
|
3. Legacy optimistic mutations use the same reusable always-current ordered runtime. This prevents an old manual snapshot from erasing a later successful mutation or authoritative projection. The runtime also supports optimistic entries whose base data was absent and removes them on a not-applied rollback.
|
||||||
|
4. The mutation bridge derives effect before touching optimistic state:
|
||||||
|
- `NOT_STARTED` / `NOT_APPLIED`: rollback;
|
||||||
|
- `APPLIED_CONFIRMED`: commit, then best-effort invalidate;
|
||||||
|
- `MAYBE_APPLIED`: retain as uncertain, do not invalidate, and enqueue explicit reconciliation.
|
||||||
|
5. Missing or `NOT_APPLICABLE` command effects, returned failures after dispatch, and thrown execution failures normalize to `MAYBE_APPLIED`. Unknown effects are non-retryable with `contact-support`; applied-confirmed failures are non-retryable with no resend action. Controller-owned stale scope, duplicate admission, identity/preparation, and other known pre-dispatch failures carry `NOT_STARTED`.
|
||||||
|
6. Unknown records retain their original intent, scope, layer lease, invalidation topics, and coordinator. A FIFO queue prevents parallel `ALLOW_PARALLEL` failures from overwriting each other. Reconciliation is locked through the event turn so a double action cannot consume the next intent, and it does not reset a newer active submit.
|
||||||
|
7. Scope abort discards every queued record from that scope, settles only its local stale layers, performs no invalidation, and cannot later overwrite new-generation cache data.
|
||||||
|
8. Async state adds the mutually exclusive `mutation-effect-unknown` overlay with priority `unknown > conflict > pending > stale-degraded > refreshing`. `AsyncSurface` uses dedicated safe copy and only `APPLIED` / `NOT_APPLIED` actions; it does not expose generic retry or mark the surface busy.
|
||||||
|
9. Unknown-effect admissions live in a bounded QueryClient-owned registry, so bound and legacy controllers can remount without losing reconciliation state. Channels include definition version and generation, validate the exact scope owner, count active admissions globally in O(1), release after late settlement, and preserve FIFO order even when executions finish in reverse.
|
||||||
|
10. Channel creation and scope-abort listener registration occur only in a committed React effect. An abandoned/server render performs no registry mutation and consumes no channel capacity. Non-optimistic legacy callers must provide a stable `definitionId`; optimistic legacy callers also include their query identity.
|
||||||
|
11. The reference create form blocks all generic resubmission while effect certainty is unknown. `NOT_APPLIED` preserves input and re-enables submission; `APPLIED` reconciliation and `APPLIED_CONFIRMED` settlement use the form's success-equivalent reset path so the same create command cannot be resent.
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
- Certainty matrix for all four mutation effects, missing effect, `NOT_APPLICABLE`, thrown execution, ambiguous conflicts, and non-retry semantics.
|
||||||
|
- Applied-confirmed commit-before-invalidate ordering and retained commit when invalidation fails.
|
||||||
|
- Out-of-order later commits behind uncertain layers; both reconciliation outcomes; duplicate/reversed lease transitions; external cache projection; expired scope.
|
||||||
|
- Bound and legacy reconciliation, no-layer legacy fallback, parallel unknown queues, same-turn double actions, active newer submit preservation, scope-wide stale cleanup, and no-prior-cache rollback.
|
||||||
|
- Bound and non-optimistic legacy remount durability, unrelated legacy isolation, generation isolation, exact scope-owner collision handling, abandoned-render capacity, late empty-channel cleanup, QueryClient-global admission bounds, reverse completion, and fence-during-invalidation races.
|
||||||
|
- Production create-form coverage for the `MAYBE_APPLIED` block, `NOT_APPLIED` input preservation, and success-equivalent `APPLIED` / `APPLIED_CONFIRMED` settlement.
|
||||||
|
- Unknown overlay derivation, mutual-exclusion priority, dedicated localized copy, non-busy state, and reconciliation-only actions.
|
||||||
|
- The negative async-overlay type fixture now includes `mutationEffectUnknown: false`, so it continues to fail for the intended pending/conflict exclusivity violation.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `src/presentation/adapters/query/optimistic-layer-runtime.ts`
|
||||||
|
- `src/presentation/adapters/query/application-query.ts`
|
||||||
|
- `src/application/view-models/async-state.ts`
|
||||||
|
- `src/contracts/errors.ts`
|
||||||
|
- `src/presentation/components/async-surface.tsx`
|
||||||
|
- `src/presentation/forms/form-contracts.ts`
|
||||||
|
- `src/presentation/forms/use-app-form.ts`
|
||||||
|
- `src/presentation/i18n/catalog.ts`
|
||||||
|
- `src/features/reference-feature/presentation/reference-resource-form-page.tsx`
|
||||||
|
- `tests/unit/optimistic-layer-runtime.test.ts`
|
||||||
|
- `tests/component/application-query.test.tsx`
|
||||||
|
- `tests/component/async-surface.test.tsx`
|
||||||
|
- `tests/features/reference-feature/reference-page.test.tsx`
|
||||||
|
- `tests/fixtures/typecheck/invalid-async-overlay.ts`
|
||||||
|
|
||||||
|
The AsyncSurface, catalog, UI test, and type-fixture additions are a narrow scope expansion required to avoid rendering the new indicator as a background refresh and to preserve the overlay type contract.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Final focused command (error classification, optimistic runtime, mutation bridge, async surface, form facade, and production reference form): 6 files / 100 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 741, component 123, integration 23, reference feature 24, recipes 17.
|
||||||
|
- `git diff --check` — PASS.
|
||||||
|
- `corepack pnpm run check:types:fixture:async-overlay` — expected non-zero; TypeScript rejects `mutationConflict: true` when `mutationPending: true`, confirming the negative fixture still reaches its intended invariant.
|
||||||
|
|
||||||
|
## Self-review decisions
|
||||||
|
|
||||||
|
- The plan-prescribed `reconcileUnknownEffect(resolution)` API remains intact. Rather than introduce a public token incompatible with that interface, the controller retains intent-bound FIFO records and serializes reconciliation through the current event turn. A repeated action after the first promise settles is an explicit action on the next visible unknown record.
|
||||||
|
- Scope cleanup removes stale local projection without claiming or invalidating a server outcome. A stale generation cannot use its former record after the queue is discarded.
|
||||||
|
- Legacy manual snapshot restoration was removed because it could erase later successful work. Shared ordered layers are the minimal mechanism that gives legacy and bound mutations the same re-projection guarantees.
|
||||||
|
- A non-optimistic legacy mutation has no cache key from which a durable logical identity can be inferred. Its type contract therefore requires a stable caller-supplied `definitionId`; this preserves remount durability without merging unrelated controllers.
|
||||||
|
- Registry mutation was moved out of render into the committed effect lifecycle. The server-render regression fills the nominal definition count with abandoned renders, then proves a committed mutation can still acquire and execute.
|
||||||
|
- Browser/Playwright gates were not run; this task changed no browser-only integration. The jsdom component tests cover the new accessible status and actions.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -11,6 +11,7 @@ export const ASYNC_OVERLAYS = Object.freeze([
|
|||||||
"refreshing",
|
"refreshing",
|
||||||
"stale-degraded",
|
"stale-degraded",
|
||||||
"mutation-pending",
|
"mutation-pending",
|
||||||
|
"mutation-effect-unknown",
|
||||||
"mutation-conflict",
|
"mutation-conflict",
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
@@ -19,30 +20,42 @@ export type AsyncOverlay =
|
|||||||
refreshing: false;
|
refreshing: false;
|
||||||
staleDegraded: false;
|
staleDegraded: false;
|
||||||
mutationPending: false;
|
mutationPending: false;
|
||||||
|
mutationEffectUnknown: false;
|
||||||
mutationConflict: false;
|
mutationConflict: false;
|
||||||
}>
|
}>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
refreshing: true;
|
refreshing: true;
|
||||||
staleDegraded: false;
|
staleDegraded: false;
|
||||||
mutationPending: false;
|
mutationPending: false;
|
||||||
|
mutationEffectUnknown: false;
|
||||||
mutationConflict: false;
|
mutationConflict: false;
|
||||||
}>
|
}>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
refreshing: false;
|
refreshing: false;
|
||||||
staleDegraded: true;
|
staleDegraded: true;
|
||||||
mutationPending: false;
|
mutationPending: false;
|
||||||
|
mutationEffectUnknown: false;
|
||||||
mutationConflict: false;
|
mutationConflict: false;
|
||||||
}>
|
}>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
refreshing: false;
|
refreshing: false;
|
||||||
staleDegraded: false;
|
staleDegraded: false;
|
||||||
mutationPending: true;
|
mutationPending: true;
|
||||||
|
mutationEffectUnknown: false;
|
||||||
mutationConflict: false;
|
mutationConflict: false;
|
||||||
}>
|
}>
|
||||||
| Readonly<{
|
| Readonly<{
|
||||||
refreshing: false;
|
refreshing: false;
|
||||||
staleDegraded: false;
|
staleDegraded: false;
|
||||||
mutationPending: false;
|
mutationPending: false;
|
||||||
|
mutationEffectUnknown: true;
|
||||||
|
mutationConflict: false;
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
refreshing: false;
|
||||||
|
staleDegraded: false;
|
||||||
|
mutationPending: false;
|
||||||
|
mutationEffectUnknown: false;
|
||||||
mutationConflict: true;
|
mutationConflict: true;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -54,6 +67,7 @@ export type AsyncSignals = Readonly<{
|
|||||||
isStale?: boolean;
|
isStale?: boolean;
|
||||||
isDegraded?: boolean;
|
isDegraded?: boolean;
|
||||||
isMutationPending?: boolean;
|
isMutationPending?: boolean;
|
||||||
|
hasMutationEffectUnknown?: boolean;
|
||||||
hasMutationConflict?: boolean;
|
hasMutationConflict?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -84,15 +98,17 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState {
|
|||||||
: "initial-loading";
|
: "initial-loading";
|
||||||
|
|
||||||
const indicator =
|
const indicator =
|
||||||
signals.hasMutationConflict && hasData
|
signals.hasMutationEffectUnknown && hasData
|
||||||
? "mutation-conflict"
|
? "mutation-effect-unknown"
|
||||||
: signals.isMutationPending && hasData
|
: signals.hasMutationConflict && hasData
|
||||||
? "mutation-pending"
|
? "mutation-conflict"
|
||||||
: signals.isStale && signals.isDegraded && hasData
|
: signals.isMutationPending && hasData
|
||||||
? "stale-degraded"
|
? "mutation-pending"
|
||||||
: signals.isFetching && hasData
|
: signals.isStale && signals.isDegraded && hasData
|
||||||
? "refreshing"
|
? "stale-degraded"
|
||||||
: null;
|
: signals.isFetching && hasData
|
||||||
|
? "refreshing"
|
||||||
|
: null;
|
||||||
const overlay = overlayFor(indicator);
|
const overlay = overlayFor(indicator);
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -107,6 +123,7 @@ export function deriveAsyncState(signals: AsyncSignals): AsyncState {
|
|||||||
export function selectOverlayIndicator(
|
export function selectOverlayIndicator(
|
||||||
overlay: AsyncOverlay,
|
overlay: AsyncOverlay,
|
||||||
): AsyncState["indicator"] {
|
): AsyncState["indicator"] {
|
||||||
|
if (overlay.mutationEffectUnknown) return "mutation-effect-unknown";
|
||||||
if (overlay.mutationConflict) return "mutation-conflict";
|
if (overlay.mutationConflict) return "mutation-conflict";
|
||||||
if (overlay.mutationPending) return "mutation-pending";
|
if (overlay.mutationPending) return "mutation-pending";
|
||||||
if (overlay.staleDegraded) return "stale-degraded";
|
if (overlay.staleDegraded) return "stale-degraded";
|
||||||
@@ -120,6 +137,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
|
|||||||
refreshing: true,
|
refreshing: true,
|
||||||
staleDegraded: false,
|
staleDegraded: false,
|
||||||
mutationPending: false,
|
mutationPending: false,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
mutationConflict: false,
|
mutationConflict: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,6 +146,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
|
|||||||
refreshing: false,
|
refreshing: false,
|
||||||
staleDegraded: true,
|
staleDegraded: true,
|
||||||
mutationPending: false,
|
mutationPending: false,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
mutationConflict: false,
|
mutationConflict: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -136,6 +155,16 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
|
|||||||
refreshing: false,
|
refreshing: false,
|
||||||
staleDegraded: false,
|
staleDegraded: false,
|
||||||
mutationPending: true,
|
mutationPending: true,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
|
mutationConflict: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (indicator === "mutation-effect-unknown") {
|
||||||
|
return Object.freeze({
|
||||||
|
refreshing: false,
|
||||||
|
staleDegraded: false,
|
||||||
|
mutationPending: false,
|
||||||
|
mutationEffectUnknown: true,
|
||||||
mutationConflict: false,
|
mutationConflict: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,6 +173,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
|
|||||||
refreshing: false,
|
refreshing: false,
|
||||||
staleDegraded: false,
|
staleDegraded: false,
|
||||||
mutationPending: false,
|
mutationPending: false,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
mutationConflict: true,
|
mutationConflict: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -151,6 +181,7 @@ function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay {
|
|||||||
refreshing: false,
|
refreshing: false,
|
||||||
staleDegraded: false,
|
staleDegraded: false,
|
||||||
mutationPending: false,
|
mutationPending: false,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
mutationConflict: false,
|
mutationConflict: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -281,7 +281,8 @@ export function createFailure(
|
|||||||
kind: definition.kind,
|
kind: definition.kind,
|
||||||
code: typeof details.code === "string" ? details.code : definition.kind,
|
code: typeof details.code === "string" ? details.code : definition.kind,
|
||||||
retryable:
|
retryable:
|
||||||
details.effect === "MAYBE_APPLIED"
|
details.effect === "MAYBE_APPLIED" ||
|
||||||
|
details.effect === "APPLIED_CONFIRMED"
|
||||||
? false
|
? false
|
||||||
: definition.defaultRetryable,
|
: definition.defaultRetryable,
|
||||||
operationId,
|
operationId,
|
||||||
@@ -320,7 +321,28 @@ export function createFailure(
|
|||||||
action:
|
action:
|
||||||
details.effect === "MAYBE_APPLIED"
|
details.effect === "MAYBE_APPLIED"
|
||||||
? "contact-support"
|
? "contact-support"
|
||||||
: definition.action,
|
: details.effect === "APPLIED_CONFIRMED"
|
||||||
|
? "none"
|
||||||
|
: definition.action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds controller-owned mutation effect knowledge without weakening the
|
||||||
|
* fail-safe handling required for an unknown server-side outcome.
|
||||||
|
*/
|
||||||
|
export function withFailureEffect(
|
||||||
|
failure: AppFailure,
|
||||||
|
effect: Exclude<FailureEffectCertainty, "NOT_APPLICABLE">,
|
||||||
|
): AppFailure {
|
||||||
|
return Object.freeze({
|
||||||
|
...failure,
|
||||||
|
effect,
|
||||||
|
...(effect === "MAYBE_APPLIED"
|
||||||
|
? { retryable: false as const, action: "contact-support" as const }
|
||||||
|
: effect === "APPLIED_CONFIRMED"
|
||||||
|
? { retryable: false as const, action: "none" as const }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
AsyncSurface,
|
||||||
DirtyNavigationDialog,
|
DirtyNavigationDialog,
|
||||||
ErrorSummary,
|
ErrorSummary,
|
||||||
Form,
|
Form,
|
||||||
@@ -40,13 +41,23 @@ export default function ReferenceResourceFormPage() {
|
|||||||
mapToCommand: toCreateReferenceCommand,
|
mapToCommand: toCreateReferenceCommand,
|
||||||
submit,
|
submit,
|
||||||
});
|
});
|
||||||
|
const mutationEffectUnknown =
|
||||||
|
mutation.state.indicator === "mutation-effect-unknown";
|
||||||
|
const mutationBlocked =
|
||||||
|
mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
|
||||||
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
|
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
id={form.formId}
|
id={form.formId}
|
||||||
pending={form.pending}
|
pending={form.pending}
|
||||||
onSubmit={(event) => void form.submitForm(event)}
|
onSubmit={(event) => {
|
||||||
|
if (mutationBlocked) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void form.submitForm(event);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FormPage
|
<FormPage
|
||||||
breadcrumb={
|
breadcrumb={
|
||||||
@@ -95,24 +106,36 @@ export default function ReferenceResourceFormPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => navigate("/examples/reference-resources")}
|
onClick={() => navigate("/examples/reference-resources")}
|
||||||
disabled={form.pending}
|
disabled={form.pending || mutationBlocked}
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={form.pending}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={form.pending || mutationBlocked}
|
||||||
|
>
|
||||||
{form.pending ? "저장 중…" : "저장"}
|
{form.pending ? "저장 중…" : "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => form.reset()}
|
onClick={() => form.reset()}
|
||||||
disabled={!form.dirty || form.pending}
|
disabled={!form.dirty || form.pending || mutationBlocked}
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
}
|
}
|
||||||
feedback={
|
feedback={
|
||||||
form.result === "success" ? (
|
mutationEffectUnknown ? (
|
||||||
|
<AsyncSurface
|
||||||
|
state={mutation.state}
|
||||||
|
onReconcileUnknownEffect={(resolution) => {
|
||||||
|
void mutation.reconcileUnknownEffect(resolution).then(() => {
|
||||||
|
if (resolution === "APPLIED") form.settleApplied();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : form.result === "success" ? (
|
||||||
<p role="status">저장했습니다.</p>
|
<p role="status">저장했습니다.</p>
|
||||||
) : form.result === "conflict" ? (
|
) : form.result === "conflict" ? (
|
||||||
<p role="status">충돌을 해결한 뒤 다시 제출할 수 있습니다.</p>
|
<p role="status">충돌을 해결한 뒤 다시 제출할 수 있습니다.</p>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
hashKey,
|
||||||
|
type QueryClient,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
@@ -18,14 +21,20 @@ import type { Result } from "../../../application/result.ts";
|
|||||||
import {
|
import {
|
||||||
createFailure,
|
createFailure,
|
||||||
normalizeUnknownFailure,
|
normalizeUnknownFailure,
|
||||||
|
withFailureEffect,
|
||||||
type AppFailure,
|
type AppFailure,
|
||||||
} from "../../../contracts/errors.ts";
|
} from "../../../contracts/errors.ts";
|
||||||
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
|
import type {
|
||||||
|
QueryInvalidationCoordinator,
|
||||||
|
QueryInvalidationTopic,
|
||||||
|
} from "../../../contracts/query-invalidation.ts";
|
||||||
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
|
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
|
||||||
|
import type { CacheScopeSnapshot } from "../../../contracts/server-state-scope.ts";
|
||||||
import {
|
import {
|
||||||
admitQueryResult,
|
admitQueryResult,
|
||||||
type BoundMutation,
|
type BoundMutation,
|
||||||
type BoundQuery,
|
type BoundQuery,
|
||||||
|
MUTATION_COORDINATOR_BOUNDS,
|
||||||
type MutationDuplicatePolicy,
|
type MutationDuplicatePolicy,
|
||||||
} from "../../../contracts/server-state.ts";
|
} from "../../../contracts/server-state.ts";
|
||||||
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
|
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
|
||||||
@@ -181,27 +190,75 @@ export function useApplicationQuery<Value>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyMutationOptions<Input, Value> = Readonly<{
|
type LegacyMutationBase<Input, Value> = Readonly<{
|
||||||
execute(input: Input): Promise<ApplicationResult<Value>>;
|
execute(input: Input): Promise<ApplicationResult<Value>>;
|
||||||
duplicatePolicy?: MutationDuplicatePolicy;
|
duplicatePolicy?: MutationDuplicatePolicy;
|
||||||
invalidate?: readonly QueryInvalidationTopic[];
|
invalidate?: readonly QueryInvalidationTopic[];
|
||||||
optimistic?: Readonly<{
|
|
||||||
queryKey: readonly unknown[];
|
|
||||||
update(previous: unknown, input: Input): unknown;
|
|
||||||
}>;
|
|
||||||
currentData?: unknown;
|
currentData?: unknown;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type LegacyMutationOptions<Input, Value> = LegacyMutationBase<Input, Value> &
|
||||||
|
(
|
||||||
|
| Readonly<{
|
||||||
|
/** Stable logical identity required to retain an unknown effect across remounts. */
|
||||||
|
definitionId: string;
|
||||||
|
optimistic?: never;
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
definitionId?: string;
|
||||||
|
optimistic: Readonly<{
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
update(previous: unknown, input: Input): unknown;
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
);
|
||||||
|
|
||||||
type ApplicationMutationController<Input, Value> = Readonly<{
|
type ApplicationMutationController<Input, Value> = Readonly<{
|
||||||
state: AsyncState;
|
state: AsyncState;
|
||||||
submit(input: Input): Promise<ApplicationResult<Value>>;
|
submit(input: Input): Promise<ApplicationResult<Value>>;
|
||||||
resolveConflict(): Promise<void>;
|
resolveConflict(): Promise<void>;
|
||||||
|
reconcileUnknownEffect(
|
||||||
|
resolution: "APPLIED" | "NOT_APPLIED",
|
||||||
|
): Promise<void>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type MutationExecution<Input> =
|
type MutationExecution<Input> =
|
||||||
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
|
| Readonly<{ kind: "BOUND"; input: Input; intent: MutationIntent }>
|
||||||
| Readonly<{ kind: "LEGACY"; input: Input }>;
|
| Readonly<{ kind: "LEGACY"; input: Input }>;
|
||||||
|
|
||||||
|
type MutationAdmission = {
|
||||||
|
sequence: number;
|
||||||
|
state: "ACTIVE" | "UNKNOWN" | "RECONCILING" | "SETTLED";
|
||||||
|
intent: MutationIntent | null;
|
||||||
|
scope: CacheScopeSnapshot | null;
|
||||||
|
optimisticLayer: OptimisticLayerLease | null;
|
||||||
|
optimisticQueryKey: readonly unknown[] | null;
|
||||||
|
readonly invalidate: readonly QueryInvalidationTopic[];
|
||||||
|
invalidationCoordinator: QueryInvalidationCoordinator | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnknownEffectChannel = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly registry: UnknownEffectRegistry;
|
||||||
|
readonly owner: QueryClient;
|
||||||
|
readonly scope: CacheScopeSnapshot | null;
|
||||||
|
nextSequence: number;
|
||||||
|
admissions: MutationAdmission[];
|
||||||
|
version: number;
|
||||||
|
reconciliationInFlight: boolean;
|
||||||
|
readonly listeners: Set<() => void>;
|
||||||
|
disposeScopeListener: (() => void) | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnknownEffectRegistry = {
|
||||||
|
readonly channels: Map<string, UnknownEffectChannel>;
|
||||||
|
activeAdmissions: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEGACY_OPTIMISTIC_SCOPE = Object.freeze({
|
||||||
|
isCurrent: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
export function useApplicationMutation<Input, Value>(
|
export function useApplicationMutation<Input, Value>(
|
||||||
options: BoundMutation<Input, Value>,
|
options: BoundMutation<Input, Value>,
|
||||||
): ApplicationMutationController<Input, Value>;
|
): ApplicationMutationController<Input, Value>;
|
||||||
@@ -221,7 +278,11 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
const optimistic = "optimistic" in options ? options.optimistic : undefined;
|
const optimistic = "optimistic" in options ? options.optimistic : undefined;
|
||||||
const currentData = "currentData" in options ? options.currentData : undefined;
|
const currentData = "currentData" in options ? options.currentData : undefined;
|
||||||
const definitionId =
|
const definitionId =
|
||||||
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
|
"definitionId" in options
|
||||||
|
? options.definitionId ?? "LEGACY_MUTATION"
|
||||||
|
: "LEGACY_MUTATION";
|
||||||
|
const definitionVersion =
|
||||||
|
"definitionVersion" in options ? options.definitionVersion : 0;
|
||||||
const duplicatePolicy =
|
const duplicatePolicy =
|
||||||
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
|
"duplicatePolicy" in options ? options.duplicatePolicy : "REJECT_WHILE_ACTIVE";
|
||||||
const [conflict, setConflict] = useState<AppFailure | null>(null);
|
const [conflict, setConflict] = useState<AppFailure | null>(null);
|
||||||
@@ -232,6 +293,53 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
"requiresIdempotencyKey" in options
|
"requiresIdempotencyKey" in options
|
||||||
? options.requiresIdempotencyKey
|
? options.requiresIdempotencyKey
|
||||||
: false;
|
: false;
|
||||||
|
const unknownEffectChannelKey = scope
|
||||||
|
? `bound:${scope.generation}:${scope.fingerprint}:${definitionId}:${definitionVersion}`
|
||||||
|
: `legacy:${definitionId}:${
|
||||||
|
optimistic
|
||||||
|
? hashKey(optimistic.queryKey)
|
||||||
|
: "non-optimistic"
|
||||||
|
}`;
|
||||||
|
const [acquiredUnknownEffectChannel, setAcquiredUnknownEffectChannel] =
|
||||||
|
useState<UnknownEffectChannel | null>(null);
|
||||||
|
const unknownEffectChannel =
|
||||||
|
acquiredUnknownEffectChannel?.owner === queryClient &&
|
||||||
|
acquiredUnknownEffectChannel.key === unknownEffectChannelKey &&
|
||||||
|
acquiredUnknownEffectChannel.scope === (scope ?? null)
|
||||||
|
? acquiredUnknownEffectChannel
|
||||||
|
: null;
|
||||||
|
useEffect(() => {
|
||||||
|
const acquired = acquireUnknownEffectChannel(
|
||||||
|
queryClient,
|
||||||
|
unknownEffectChannelKey,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
setAcquiredUnknownEffectChannel(acquired);
|
||||||
|
return () => {
|
||||||
|
if (acquired) {
|
||||||
|
releaseUnknownEffectChannelIfUnused(queryClient, acquired);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [queryClient, scope, unknownEffectChannelKey]);
|
||||||
|
const subscribeToUnknownEffects = useCallback(
|
||||||
|
(listener: () => void) => {
|
||||||
|
if (!unknownEffectChannel) return () => {};
|
||||||
|
unknownEffectChannel.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
unknownEffectChannel.listeners.delete(listener);
|
||||||
|
releaseUnknownEffectChannelIfUnused(
|
||||||
|
queryClient,
|
||||||
|
unknownEffectChannel,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[queryClient, unknownEffectChannel],
|
||||||
|
);
|
||||||
|
useSyncExternalStore(
|
||||||
|
subscribeToUnknownEffects,
|
||||||
|
() => unknownEffectChannel?.version ?? 0,
|
||||||
|
() => unknownEffectChannel?.version ?? 0,
|
||||||
|
);
|
||||||
const mutation = useMutation<
|
const mutation = useMutation<
|
||||||
Value,
|
Value,
|
||||||
ApplicationQueryError,
|
ApplicationQueryError,
|
||||||
@@ -246,7 +354,7 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
"SCOPE_GENERATION_CHANGED",
|
"SCOPE_GENERATION_CHANGED",
|
||||||
definitionId,
|
definitionId,
|
||||||
0,
|
0,
|
||||||
{ code: "MUTATION_SCOPE_STALE" },
|
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -298,7 +406,7 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
"SCOPE_GENERATION_CHANGED",
|
"SCOPE_GENERATION_CHANGED",
|
||||||
definitionId,
|
definitionId,
|
||||||
0,
|
0,
|
||||||
{ code: "MUTATION_SCOPE_STALE" },
|
{ code: "MUTATION_SCOPE_STALE", effect: "NOT_STARTED" },
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -311,9 +419,12 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: normalizeUnknownFailure(error, {
|
error: withFailureEffect(
|
||||||
operationId: definitionId,
|
normalizeUnknownFailure(error, {
|
||||||
}),
|
operationId: definitionId,
|
||||||
|
}),
|
||||||
|
"NOT_STARTED",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const active = mutationExecutions(queryClient).get(identity) as
|
const active = mutationExecutions(queryClient).get(identity) as
|
||||||
@@ -331,26 +442,58 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
"DUPLICATE_IN_FLIGHT",
|
"DUPLICATE_IN_FLIGHT",
|
||||||
definitionId,
|
definitionId,
|
||||||
0,
|
0,
|
||||||
{ code: "DUPLICATE_IN_FLIGHT" },
|
{ code: "DUPLICATE_IN_FLIGHT", effect: "NOT_STARTED" },
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!unknownEffectChannel ||
|
||||||
|
unknownEffectChannel.registry.activeAdmissions >=
|
||||||
|
MUTATION_COORDINATOR_BOUNDS.activeIntentsTotal
|
||||||
|
) {
|
||||||
|
identityLease?.release();
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: createFailure(
|
||||||
|
"IDENTITY_INTERN_LIMIT_EXCEEDED",
|
||||||
|
definitionId,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
code: "UNKNOWN_EFFECT_CHANNEL_LIMIT_EXCEEDED",
|
||||||
|
effect: "NOT_STARTED",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const admission: MutationAdmission = {
|
||||||
|
sequence: unknownEffectChannel.nextSequence++,
|
||||||
|
state: "ACTIVE",
|
||||||
|
intent: null,
|
||||||
|
scope: scope ?? null,
|
||||||
|
optimisticLayer: null,
|
||||||
|
optimisticQueryKey: optimistic?.queryKey ?? null,
|
||||||
|
invalidate,
|
||||||
|
invalidationCoordinator: invalidationCoordinator ?? null,
|
||||||
|
};
|
||||||
|
unknownEffectChannel.admissions.push(admission);
|
||||||
|
unknownEffectChannel.registry.activeAdmissions += 1;
|
||||||
|
notifyUnknownEffectChannel(unknownEffectChannel);
|
||||||
setConflict(null);
|
setConflict(null);
|
||||||
mutation.reset();
|
mutation.reset();
|
||||||
|
|
||||||
const pending = (async (): Promise<ApplicationResult<Value>> => {
|
const pending = (async (): Promise<ApplicationResult<Value>> => {
|
||||||
const execution: MutationExecution<Input> =
|
let execution: MutationExecution<Input>;
|
||||||
scope
|
if (scope) {
|
||||||
? Object.freeze({
|
const intent = mutationIntentFactory.create({
|
||||||
kind: "BOUND" as const,
|
operationId: mutationOperationId,
|
||||||
input,
|
canonicalInputIdentity: identity,
|
||||||
intent: mutationIntentFactory.create({
|
requiresIdempotencyKey,
|
||||||
operationId: mutationOperationId,
|
});
|
||||||
canonicalInputIdentity: identity,
|
admission.intent = intent;
|
||||||
requiresIdempotencyKey,
|
execution = Object.freeze({ kind: "BOUND", input, intent });
|
||||||
}),
|
} else {
|
||||||
})
|
execution = Object.freeze({ kind: "LEGACY", input });
|
||||||
: Object.freeze({ kind: "LEGACY" as const, input });
|
}
|
||||||
const mutationLease =
|
const mutationLease =
|
||||||
invalidate.length === 0
|
invalidate.length === 0
|
||||||
? null
|
? null
|
||||||
@@ -359,54 +502,60 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
throw new Error("Query invalidation coordinator is not installed.");
|
throw new Error("Query invalidation coordinator is not installed.");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let previous: unknown;
|
|
||||||
let hadPreviousData = false;
|
|
||||||
let optimisticLayer: OptimisticLayerLease | null = null;
|
let optimisticLayer: OptimisticLayerLease | null = null;
|
||||||
if (optimistic) {
|
if (optimistic) {
|
||||||
await queryClient.cancelQueries({
|
await queryClient.cancelQueries({
|
||||||
queryKey: optimistic.queryKey,
|
queryKey: optimistic.queryKey,
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
if (scope) {
|
optimisticLayer = optimisticLayers(queryClient).begin(
|
||||||
optimisticLayer = optimisticLayers(queryClient).begin(
|
optimistic.queryKey,
|
||||||
optimistic.queryKey,
|
input,
|
||||||
input,
|
optimistic.update,
|
||||||
optimistic.update,
|
scope ?? LEGACY_OPTIMISTIC_SCOPE,
|
||||||
scope,
|
);
|
||||||
);
|
admission.optimisticLayer = optimisticLayer;
|
||||||
} else {
|
|
||||||
previous = queryClient.getQueryData(optimistic.queryKey);
|
|
||||||
hadPreviousData = previous !== undefined;
|
|
||||||
queryClient.setQueryData(
|
|
||||||
optimistic.queryKey,
|
|
||||||
optimistic.update(previous, input),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: Value;
|
let value: Value;
|
||||||
try {
|
try {
|
||||||
value = await mutation.mutateAsync(execution);
|
value = await mutation.mutateAsync(execution);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (optimistic) {
|
const reportedFailure =
|
||||||
if (optimisticLayer) {
|
|
||||||
optimisticLayer.rollback();
|
|
||||||
} else if (hadPreviousData) {
|
|
||||||
queryClient.setQueryData(optimistic.queryKey, previous);
|
|
||||||
} else {
|
|
||||||
queryClient.removeQueries({
|
|
||||||
queryKey: optimistic.queryKey,
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const failure =
|
|
||||||
error instanceof ApplicationQueryError
|
error instanceof ApplicationQueryError
|
||||||
? error.failure
|
? error.failure
|
||||||
: normalizeUnknownFailure(error, {
|
: normalizeUnknownFailure(error, {
|
||||||
operationId: "APPLICATION_MUTATION",
|
operationId: "APPLICATION_MUTATION",
|
||||||
});
|
});
|
||||||
if (failure.kind === "CONFLICT") setConflict(failure);
|
const failure = mutationFailureWithEffect(reportedFailure);
|
||||||
|
switch (failure.effect) {
|
||||||
|
case "NOT_STARTED":
|
||||||
|
case "NOT_APPLIED":
|
||||||
|
optimisticLayer?.rollback();
|
||||||
|
if (failure.kind === "CONFLICT") setConflict(failure);
|
||||||
|
settleMutationAdmission(unknownEffectChannel, admission);
|
||||||
|
break;
|
||||||
|
case "APPLIED_CONFIRMED":
|
||||||
|
optimisticLayer?.commit();
|
||||||
|
if (!scope || scope.isCurrent()) {
|
||||||
|
try {
|
||||||
|
await invalidationCoordinator?.invalidate(invalidate);
|
||||||
|
} catch {
|
||||||
|
// A confirmed command remains committed if refresh fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settleMutationAdmission(unknownEffectChannel, admission);
|
||||||
|
break;
|
||||||
|
case "MAYBE_APPLIED":
|
||||||
|
optimisticLayer?.markUncertain();
|
||||||
|
if (!scope || scope.isCurrent()) {
|
||||||
|
admission.state = "UNKNOWN";
|
||||||
|
notifyUnknownEffectChannel(unknownEffectChannel);
|
||||||
|
} else {
|
||||||
|
settleMutationAdmission(unknownEffectChannel, admission);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
return { ok: false, error: failure };
|
return { ok: false, error: failure };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +565,7 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
} catch {
|
} catch {
|
||||||
// Cache refresh remains best effort after the server has committed.
|
// Cache refresh remains best effort after the server has committed.
|
||||||
}
|
}
|
||||||
|
settleMutationAdmission(unknownEffectChannel, admission);
|
||||||
return { ok: true, value };
|
return { ok: true, value };
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -426,9 +576,13 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
const failure = normalizeUnknownFailure(error, {
|
const failure = withFailureEffect(
|
||||||
operationId: "APPLICATION_MUTATION",
|
normalizeUnknownFailure(error, {
|
||||||
});
|
operationId: "APPLICATION_MUTATION",
|
||||||
|
}),
|
||||||
|
"NOT_STARTED",
|
||||||
|
);
|
||||||
|
settleMutationAdmission(unknownEffectChannel, admission);
|
||||||
if (failure.kind === "CONFLICT") setConflict(failure);
|
if (failure.kind === "CONFLICT") setConflict(failure);
|
||||||
return { ok: false as const, error: failure };
|
return { ok: false as const, error: failure };
|
||||||
})
|
})
|
||||||
@@ -455,6 +609,7 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
mutationOperationId,
|
mutationOperationId,
|
||||||
requiresIdempotencyKey,
|
requiresIdempotencyKey,
|
||||||
scope,
|
scope,
|
||||||
|
unknownEffectChannel,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,14 +622,194 @@ export function useApplicationMutation<Input, Value>(
|
|||||||
await invalidationCoordinator?.invalidate(invalidate);
|
await invalidationCoordinator?.invalidate(invalidate);
|
||||||
}, [invalidate, invalidationCoordinator, mutation]);
|
}, [invalidate, invalidationCoordinator, mutation]);
|
||||||
|
|
||||||
|
const reconcileUnknownEffect = useCallback(
|
||||||
|
async (resolution: "APPLIED" | "NOT_APPLIED") => {
|
||||||
|
if (
|
||||||
|
!unknownEffectChannel ||
|
||||||
|
unknownEffectChannel.reconciliationInFlight
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending = unknownEffectChannel.admissions[0];
|
||||||
|
if (!pending || pending.state !== "UNKNOWN") return;
|
||||||
|
if (pending.scope && !pending.intent) return;
|
||||||
|
unknownEffectChannel.reconciliationInFlight = true;
|
||||||
|
pending.state = "RECONCILING";
|
||||||
|
notifyUnknownEffectChannel(unknownEffectChannel);
|
||||||
|
try {
|
||||||
|
pending.optimisticLayer?.reconcile(resolution);
|
||||||
|
if (
|
||||||
|
resolution === "APPLIED" &&
|
||||||
|
(!pending.scope || pending.scope.isCurrent())
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await pending.invalidationCoordinator?.invalidate(
|
||||||
|
pending.invalidate,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Explicit reconciliation remains settled if refresh fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (pending.scope && !pending.scope.isCurrent()) {
|
||||||
|
removeAdmissionOptimisticQuery(queryClient, pending);
|
||||||
|
}
|
||||||
|
settleMutationAdmission(unknownEffectChannel, pending);
|
||||||
|
if (
|
||||||
|
!unknownEffectChannel.admissions.some(
|
||||||
|
(admission) => admission.state === "ACTIVE",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutation.reset();
|
||||||
|
}
|
||||||
|
// Keep the head intent locked through the current event turn so two
|
||||||
|
// clicks cannot consume two FIFO records when reconciliation itself
|
||||||
|
// has no asynchronous invalidation work.
|
||||||
|
await Promise.resolve();
|
||||||
|
unknownEffectChannel.reconciliationInFlight = false;
|
||||||
|
notifyUnknownEffectChannel(unknownEffectChannel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutation, queryClient, unknownEffectChannel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unknownEffectHead = unknownEffectChannel?.admissions[0];
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
state: deriveAsyncState({
|
state: deriveAsyncState({
|
||||||
data: currentData ?? true,
|
data: currentData ?? true,
|
||||||
isMutationPending: mutation.isPending,
|
isMutationPending:
|
||||||
|
mutation.isPending ||
|
||||||
|
unknownEffectHead?.state === "ACTIVE" ||
|
||||||
|
unknownEffectHead?.state === "RECONCILING",
|
||||||
|
hasMutationEffectUnknown: unknownEffectHead?.state === "UNKNOWN",
|
||||||
hasMutationConflict: conflict !== null,
|
hasMutationConflict: conflict !== null,
|
||||||
}),
|
}),
|
||||||
submit,
|
submit,
|
||||||
resolveConflict,
|
resolveConflict,
|
||||||
|
reconcileUnknownEffect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationFailureWithEffect(failure: AppFailure): AppFailure {
|
||||||
|
switch (failure.effect) {
|
||||||
|
case "NOT_STARTED":
|
||||||
|
case "NOT_APPLIED":
|
||||||
|
case "APPLIED_CONFIRMED":
|
||||||
|
case "MAYBE_APPLIED":
|
||||||
|
return withFailureEffect(failure, failure.effect);
|
||||||
|
case "NOT_APPLICABLE":
|
||||||
|
case undefined:
|
||||||
|
return withFailureEffect(failure, "MAYBE_APPLIED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNKNOWN_EFFECT_CHANNELS = new WeakMap<
|
||||||
|
object,
|
||||||
|
UnknownEffectRegistry
|
||||||
|
>();
|
||||||
|
|
||||||
|
function acquireUnknownEffectChannel(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
key: string,
|
||||||
|
scope: CacheScopeSnapshot | undefined,
|
||||||
|
): UnknownEffectChannel | null {
|
||||||
|
let registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
|
||||||
|
if (!registry) {
|
||||||
|
registry = { channels: new Map(), activeAdmissions: 0 };
|
||||||
|
UNKNOWN_EFFECT_CHANNELS.set(queryClient, registry);
|
||||||
|
}
|
||||||
|
const { channels } = registry;
|
||||||
|
const existing = channels.get(key);
|
||||||
|
if (existing) return existing.scope === (scope ?? null) ? existing : null;
|
||||||
|
if (
|
||||||
|
channels.size >= MUTATION_COORDINATOR_BOUNDS.activeDefinitionsPerRuntime
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const created: UnknownEffectChannel = {
|
||||||
|
key,
|
||||||
|
registry,
|
||||||
|
owner: queryClient,
|
||||||
|
scope: scope ?? null,
|
||||||
|
nextSequence: 1,
|
||||||
|
admissions: [],
|
||||||
|
version: 0,
|
||||||
|
reconciliationInFlight: false,
|
||||||
|
listeners: new Set(),
|
||||||
|
disposeScopeListener: null,
|
||||||
|
};
|
||||||
|
channels.set(key, created);
|
||||||
|
if (scope) {
|
||||||
|
const discardScope = () => {
|
||||||
|
for (const admission of created.admissions) {
|
||||||
|
removeAdmissionOptimisticQuery(queryClient, admission);
|
||||||
|
markMutationAdmissionSettled(created, admission);
|
||||||
|
}
|
||||||
|
created.admissions = [];
|
||||||
|
notifyUnknownEffectChannel(created);
|
||||||
|
releaseUnknownEffectChannelIfUnused(queryClient, created);
|
||||||
|
};
|
||||||
|
scope.signal.addEventListener("abort", discardScope, { once: true });
|
||||||
|
created.disposeScopeListener = () =>
|
||||||
|
scope.signal.removeEventListener("abort", discardScope);
|
||||||
|
if (scope.signal.aborted || !scope.isCurrent()) discardScope();
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseUnknownEffectChannelIfUnused(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
channel: UnknownEffectChannel,
|
||||||
|
): void {
|
||||||
|
if (channel.admissions.length > 0 || channel.listeners.size > 0) return;
|
||||||
|
const registry = UNKNOWN_EFFECT_CHANNELS.get(queryClient);
|
||||||
|
const channels = registry?.channels;
|
||||||
|
if (channels?.get(channel.key) !== channel) return;
|
||||||
|
channel.disposeScopeListener?.();
|
||||||
|
channels.delete(channel.key);
|
||||||
|
if (channels.size === 0 && registry?.activeAdmissions === 0) {
|
||||||
|
UNKNOWN_EFFECT_CHANNELS.delete(queryClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyUnknownEffectChannel(channel: UnknownEffectChannel): void {
|
||||||
|
channel.version += 1;
|
||||||
|
for (const listener of [...channel.listeners]) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleMutationAdmission(
|
||||||
|
channel: UnknownEffectChannel,
|
||||||
|
admission: MutationAdmission,
|
||||||
|
): void {
|
||||||
|
markMutationAdmissionSettled(channel, admission);
|
||||||
|
while (channel.admissions[0]?.state === "SETTLED") {
|
||||||
|
channel.admissions.shift();
|
||||||
|
}
|
||||||
|
notifyUnknownEffectChannel(channel);
|
||||||
|
releaseUnknownEffectChannelIfUnused(channel.owner, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markMutationAdmissionSettled(
|
||||||
|
channel: UnknownEffectChannel,
|
||||||
|
admission: MutationAdmission,
|
||||||
|
): void {
|
||||||
|
if (admission.state === "SETTLED") return;
|
||||||
|
admission.state = "SETTLED";
|
||||||
|
channel.registry.activeAdmissions = Math.max(
|
||||||
|
0,
|
||||||
|
channel.registry.activeAdmissions - 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAdmissionOptimisticQuery(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
admission: MutationAdmission,
|
||||||
|
): void {
|
||||||
|
if (!admission.optimisticQueryKey) return;
|
||||||
|
queryClient.removeQueries({
|
||||||
|
queryKey: admission.optimisticQueryKey,
|
||||||
|
exact: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,19 +6,25 @@ import { OPTIMISTIC_LAYER_BOUNDS } from "../../../contracts/server-state.ts";
|
|||||||
export type OptimisticLayerLease = Readonly<{
|
export type OptimisticLayerLease = Readonly<{
|
||||||
commit(): void;
|
commit(): void;
|
||||||
rollback(): void;
|
rollback(): void;
|
||||||
|
markUncertain(): void;
|
||||||
|
reconcile(resolution: "APPLIED" | "NOT_APPLIED"): void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type Layer = {
|
type Layer = {
|
||||||
id: number;
|
id: number;
|
||||||
status: "pending" | "committed";
|
status: "pending" | "uncertain" | "committed";
|
||||||
apply(value: unknown): unknown;
|
apply(value: unknown): unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OptimisticLayerScope = Pick<CacheScopeSnapshot, "isCurrent"> &
|
||||||
|
Partial<Pick<CacheScopeSnapshot, "signal">>;
|
||||||
|
|
||||||
type EntryState = {
|
type EntryState = {
|
||||||
queryKey: readonly unknown[];
|
queryKey: readonly unknown[];
|
||||||
scope: CacheScopeSnapshot;
|
scope: OptimisticLayerScope;
|
||||||
base: unknown;
|
base: unknown;
|
||||||
layers: Layer[];
|
layers: Layer[];
|
||||||
|
disposeScopeListener: (() => void) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
||||||
@@ -26,6 +32,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
let writing = false;
|
let writing = false;
|
||||||
|
|
||||||
|
function removeEntry(key: string, entry: EntryState): void {
|
||||||
|
if (entries.get(key) === entry) entries.delete(key);
|
||||||
|
entry.disposeScopeListener?.();
|
||||||
|
entry.disposeScopeListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.getQueryCache().subscribe((event) => {
|
queryClient.getQueryCache().subscribe((event) => {
|
||||||
if (
|
if (
|
||||||
writing ||
|
writing ||
|
||||||
@@ -42,7 +54,12 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
|
|
||||||
function project(key: string, entry: EntryState): void {
|
function project(key: string, entry: EntryState): void {
|
||||||
if (!entry.scope.isCurrent()) {
|
if (!entry.scope.isCurrent()) {
|
||||||
entries.delete(key);
|
removeEntry(key, entry);
|
||||||
|
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entry.base === undefined && entry.layers.length === 0) {
|
||||||
|
removeEntry(key, entry);
|
||||||
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
|
queryClient.removeQueries({ queryKey: entry.queryKey, exact: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,7 +67,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
try {
|
try {
|
||||||
for (const layer of entry.layers) value = layer.apply(value);
|
for (const layer of entry.layers) value = layer.apply(value);
|
||||||
} catch {
|
} catch {
|
||||||
entries.delete(key);
|
removeEntry(key, entry);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writing = true;
|
writing = true;
|
||||||
@@ -68,7 +85,7 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
entry.base = committed.apply(entry.base);
|
entry.base = committed.apply(entry.base);
|
||||||
}
|
}
|
||||||
project(key, entry);
|
project(key, entry);
|
||||||
if (entry.layers.length === 0) entries.delete(key);
|
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@@ -76,23 +93,42 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
queryKey: readonly unknown[],
|
queryKey: readonly unknown[],
|
||||||
input: Input,
|
input: Input,
|
||||||
update: (previous: unknown, input: Input) => unknown,
|
update: (previous: unknown, input: Input) => unknown,
|
||||||
scope: CacheScopeSnapshot,
|
scope: OptimisticLayerScope,
|
||||||
): OptimisticLayerLease | null {
|
): OptimisticLayerLease | null {
|
||||||
if (!scope.isCurrent()) return null;
|
if (!scope.isCurrent()) return null;
|
||||||
const current = queryClient.getQueryData(queryKey);
|
const current = queryClient.getQueryData(queryKey);
|
||||||
if (current === undefined) return null;
|
|
||||||
const key = hashKey(queryKey);
|
const key = hashKey(queryKey);
|
||||||
let entry = entries.get(key);
|
let entry = entries.get(key);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = { queryKey, scope, base: current, layers: [] };
|
entry = {
|
||||||
|
queryKey,
|
||||||
|
scope,
|
||||||
|
base: current,
|
||||||
|
layers: [],
|
||||||
|
disposeScopeListener: null,
|
||||||
|
};
|
||||||
entries.set(key, entry);
|
entries.set(key, entry);
|
||||||
|
if (scope.signal) {
|
||||||
|
const selectedEntry = entry;
|
||||||
|
const discardScope = () => {
|
||||||
|
removeEntry(key, selectedEntry);
|
||||||
|
queryClient.removeQueries({ queryKey, exact: true });
|
||||||
|
};
|
||||||
|
scope.signal.addEventListener("abort", discardScope, { once: true });
|
||||||
|
entry.disposeScopeListener = () =>
|
||||||
|
scope.signal?.removeEventListener("abort", discardScope);
|
||||||
|
if (scope.signal.aborted || !scope.isCurrent()) {
|
||||||
|
discardScope();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (entry.scope !== scope) {
|
} else if (entry.scope !== scope) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// §11.5. Overflow falls back to pessimistic execution; an existing
|
// §11.5. Overflow falls back to pessimistic execution; an existing
|
||||||
// layer is never silently evicted to make room for a new one.
|
// layer is never silently evicted to make room for a new one.
|
||||||
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
|
if (entry.layers.length >= OPTIMISTIC_LAYER_BOUNDS.maxLayersPerQueryKey) {
|
||||||
if (entry.layers.length === 0) entries.delete(key);
|
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const layer: Layer = {
|
const layer: Layer = {
|
||||||
@@ -103,40 +139,64 @@ export function createOptimisticLayerRuntime(queryClient: QueryClient) {
|
|||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
try {
|
try {
|
||||||
projected = update(entry.base, input);
|
projected = update(entry.base, input);
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (entry.layers.length === 0) entries.delete(key);
|
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
estimateLayerBytes(projected) >
|
estimateLayerBytes(projected) >
|
||||||
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
|
OPTIMISTIC_LAYER_BOUNDS.maxSingleLayerBytes
|
||||||
) {
|
) {
|
||||||
if (entry.layers.length === 0) entries.delete(key);
|
if (entry.layers.length === 0) removeEntry(key, entry);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
entry.layers.push(layer);
|
entry.layers.push(layer);
|
||||||
project(key, entry);
|
project(key, entry);
|
||||||
let settled = false;
|
let state: "pending" | "uncertain" | "settled" = "pending";
|
||||||
|
const selectedLayer = () => {
|
||||||
|
if (entries.get(key) !== entry) return undefined;
|
||||||
|
return entry.layers.find((candidate) => candidate.id === layer.id);
|
||||||
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
commit() {
|
commit() {
|
||||||
if (settled) return;
|
if (state !== "pending") return;
|
||||||
settled = true;
|
state = "settled";
|
||||||
const selected = entry?.layers.find(
|
const selected = selectedLayer();
|
||||||
(candidate) => candidate.id === layer.id,
|
if (!selected) return;
|
||||||
);
|
|
||||||
if (!entry || !selected) return;
|
|
||||||
selected.status = "committed";
|
selected.status = "committed";
|
||||||
collapse(key, entry);
|
collapse(key, entry);
|
||||||
},
|
},
|
||||||
rollback() {
|
rollback() {
|
||||||
if (settled) return;
|
if (state !== "pending") return;
|
||||||
settled = true;
|
state = "settled";
|
||||||
if (!entry) return;
|
if (!selectedLayer()) return;
|
||||||
entry.layers = entry.layers.filter(
|
entry.layers = entry.layers.filter(
|
||||||
(candidate) => candidate.id !== layer.id,
|
(candidate) => candidate.id !== layer.id,
|
||||||
);
|
);
|
||||||
collapse(key, entry);
|
collapse(key, entry);
|
||||||
},
|
},
|
||||||
|
markUncertain() {
|
||||||
|
if (state !== "pending") return;
|
||||||
|
state = "uncertain";
|
||||||
|
const selected = selectedLayer();
|
||||||
|
if (!selected) return;
|
||||||
|
selected.status = "uncertain";
|
||||||
|
collapse(key, entry);
|
||||||
|
},
|
||||||
|
reconcile(resolution) {
|
||||||
|
if (state !== "uncertain") return;
|
||||||
|
state = "settled";
|
||||||
|
const selected = selectedLayer();
|
||||||
|
if (!selected) return;
|
||||||
|
if (resolution === "APPLIED") {
|
||||||
|
selected.status = "committed";
|
||||||
|
} else {
|
||||||
|
entry.layers = entry.layers.filter(
|
||||||
|
(candidate) => candidate.id !== layer.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
collapse(key, entry);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export type AsyncSurfaceProps = Readonly<{
|
|||||||
onAction?: () => void;
|
onAction?: () => void;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
onResolveConflict?: () => void;
|
onResolveConflict?: () => void;
|
||||||
|
onReconcileUnknownEffect?: (
|
||||||
|
resolution: "APPLIED" | "NOT_APPLIED",
|
||||||
|
) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function AsyncSurface({
|
export function AsyncSurface({
|
||||||
@@ -95,6 +98,7 @@ export function AsyncSurface({
|
|||||||
onAction,
|
onAction,
|
||||||
onRetry,
|
onRetry,
|
||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
|
onReconcileUnknownEffect,
|
||||||
}: AsyncSurfaceProps) {
|
}: AsyncSurfaceProps) {
|
||||||
const { message } = useLocale();
|
const { message } = useLocale();
|
||||||
if (state.base === "initial-loading") return <LoadingSurface />;
|
if (state.base === "initial-loading") return <LoadingSurface />;
|
||||||
@@ -121,11 +125,13 @@ export function AsyncSurface({
|
|||||||
{message(
|
{message(
|
||||||
state.indicator === "stale-degraded"
|
state.indicator === "stale-degraded"
|
||||||
? "async.staleDegraded"
|
? "async.staleDegraded"
|
||||||
: state.indicator === "mutation-conflict"
|
: state.indicator === "mutation-effect-unknown"
|
||||||
? "async.mutationConflict"
|
? "async.mutationEffectUnknown"
|
||||||
: state.indicator === "mutation-pending"
|
: state.indicator === "mutation-conflict"
|
||||||
? "async.mutationPending"
|
? "async.mutationConflict"
|
||||||
: "async.refreshing",
|
: state.indicator === "mutation-pending"
|
||||||
|
? "async.mutationPending"
|
||||||
|
: "async.refreshing",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{state.indicator === "stale-degraded" && onRetry ? (
|
{state.indicator === "stale-degraded" && onRetry ? (
|
||||||
@@ -136,6 +142,21 @@ export function AsyncSurface({
|
|||||||
{message("action.resolveConflict")}
|
{message("action.resolveConflict")}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{state.indicator === "mutation-effect-unknown" &&
|
||||||
|
onReconcileUnknownEffect ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => onReconcileUnknownEffect("APPLIED")}
|
||||||
|
>
|
||||||
|
{message("action.confirmMutationApplied")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onReconcileUnknownEffect("NOT_APPLIED")}
|
||||||
|
>
|
||||||
|
{message("action.confirmMutationNotApplied")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type FormResultState =
|
|||||||
| "success"
|
| "success"
|
||||||
| "validation-error"
|
| "validation-error"
|
||||||
| "conflict"
|
| "conflict"
|
||||||
|
| "effect-unknown"
|
||||||
| "unavailable";
|
| "unavailable";
|
||||||
|
|
||||||
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
|
export type MappedValidationFailure<Values extends FormValues> = Readonly<{
|
||||||
|
|||||||
@@ -135,6 +135,26 @@ export function useAppForm<
|
|||||||
[defaultValues],
|
[defaultValues],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const settleSuccessfulValues = useCallback(
|
||||||
|
(settledValues: Values) => {
|
||||||
|
setFieldErrors({} as FieldErrors<Values>);
|
||||||
|
setFormErrors([]);
|
||||||
|
setResult("success");
|
||||||
|
if (resetOnSuccess) {
|
||||||
|
setValues(defaultValues);
|
||||||
|
setInitialValues(defaultValues);
|
||||||
|
setTouched(new Set());
|
||||||
|
} else {
|
||||||
|
setInitialValues(settledValues);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defaultValues, resetOnSuccess],
|
||||||
|
);
|
||||||
|
|
||||||
|
const settleApplied = useCallback(() => {
|
||||||
|
settleSuccessfulValues(values);
|
||||||
|
}, [settleSuccessfulValues, values]);
|
||||||
|
|
||||||
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();
|
||||||
@@ -170,14 +190,7 @@ export function useAppForm<
|
|||||||
try {
|
try {
|
||||||
const outcome = await execution;
|
const outcome = await execution;
|
||||||
if (outcome.ok) {
|
if (outcome.ok) {
|
||||||
setResult("success");
|
settleSuccessfulValues(parsed.data);
|
||||||
if (resetOnSuccess) {
|
|
||||||
setValues(defaultValues);
|
|
||||||
setInitialValues(defaultValues);
|
|
||||||
setTouched(new Set());
|
|
||||||
} else {
|
|
||||||
setInitialValues(parsed.data);
|
|
||||||
}
|
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
if (outcome.error.kind === "VALIDATION_REJECTED") {
|
||||||
@@ -190,6 +203,11 @@ export function useAppForm<
|
|||||||
setFormErrors(mapped.formErrors);
|
setFormErrors(mapped.formErrors);
|
||||||
setResult("validation-error");
|
setResult("validation-error");
|
||||||
focusFirstError(mapped.fieldErrors);
|
focusFirstError(mapped.fieldErrors);
|
||||||
|
} else if (outcome.error.effect === "MAYBE_APPLIED") {
|
||||||
|
setFormErrors([]);
|
||||||
|
setResult("effect-unknown");
|
||||||
|
} else if (outcome.error.effect === "APPLIED_CONFIRMED") {
|
||||||
|
settleSuccessfulValues(parsed.data);
|
||||||
} else if (outcome.error.kind === "CONFLICT") {
|
} else if (outcome.error.kind === "CONFLICT") {
|
||||||
setFormErrors([
|
setFormErrors([
|
||||||
message("form.conflict"),
|
message("form.conflict"),
|
||||||
@@ -207,12 +225,11 @@ export function useAppForm<
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
allowedServerFields,
|
allowedServerFields,
|
||||||
defaultValues,
|
|
||||||
focusFirstError,
|
focusFirstError,
|
||||||
mapToCommand,
|
mapToCommand,
|
||||||
message,
|
message,
|
||||||
resetOnSuccess,
|
|
||||||
schema,
|
schema,
|
||||||
|
settleSuccessfulValues,
|
||||||
submit,
|
submit,
|
||||||
values,
|
values,
|
||||||
],
|
],
|
||||||
@@ -233,6 +250,7 @@ export function useAppForm<
|
|||||||
setValue,
|
setValue,
|
||||||
submitForm,
|
submitForm,
|
||||||
reset,
|
reset,
|
||||||
|
settleApplied,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const PLATFORM_KO_MESSAGES = {
|
|||||||
"action.reloadOnce": "한 번 새로고침",
|
"action.reloadOnce": "한 번 새로고침",
|
||||||
"action.contactSupport": "지원 정보 확인",
|
"action.contactSupport": "지원 정보 확인",
|
||||||
"action.resolveConflict": "충돌 해결",
|
"action.resolveConflict": "충돌 해결",
|
||||||
|
"action.confirmMutationApplied": "변경됨으로 확인",
|
||||||
|
"action.confirmMutationNotApplied": "변경되지 않음으로 확인",
|
||||||
"action.continueEditing": "계속 작성",
|
"action.continueEditing": "계속 작성",
|
||||||
"action.discardAndLeave": "변경 버리고 이동",
|
"action.discardAndLeave": "변경 버리고 이동",
|
||||||
"action.signIn": "로그인 시작",
|
"action.signIn": "로그인 시작",
|
||||||
@@ -55,6 +57,7 @@ const PLATFORM_KO_MESSAGES = {
|
|||||||
"async.refreshing": "최신 정보를 확인하고 있습니다.",
|
"async.refreshing": "최신 정보를 확인하고 있습니다.",
|
||||||
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
|
"async.staleDegraded": "기존 정보를 표시하고 있습니다.",
|
||||||
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
|
"async.mutationPending": "변경 사항을 저장하고 있습니다.",
|
||||||
|
"async.mutationEffectUnknown": "변경 결과를 확인할 수 없습니다.",
|
||||||
"async.mutationConflict": "다른 변경과 충돌했습니다.",
|
"async.mutationConflict": "다른 변경과 충돌했습니다.",
|
||||||
"access.auth.eyebrow": "401 · 인증 필요",
|
"access.auth.eyebrow": "401 · 인증 필요",
|
||||||
"access.auth.title": "로그인이 필요합니다.",
|
"access.auth.title": "로그인이 필요합니다.",
|
||||||
@@ -163,6 +166,8 @@ const PLATFORM_EN_MESSAGES = {
|
|||||||
"action.reloadOnce": "Reload once",
|
"action.reloadOnce": "Reload once",
|
||||||
"action.contactSupport": "View support information",
|
"action.contactSupport": "View support information",
|
||||||
"action.resolveConflict": "Resolve conflict",
|
"action.resolveConflict": "Resolve conflict",
|
||||||
|
"action.confirmMutationApplied": "Confirm the change was applied",
|
||||||
|
"action.confirmMutationNotApplied": "Confirm the change was not applied",
|
||||||
"action.continueEditing": "Continue editing",
|
"action.continueEditing": "Continue editing",
|
||||||
"action.discardAndLeave": "Discard and leave",
|
"action.discardAndLeave": "Discard and leave",
|
||||||
"action.signIn": "Start sign-in",
|
"action.signIn": "Start sign-in",
|
||||||
@@ -207,6 +212,7 @@ const PLATFORM_EN_MESSAGES = {
|
|||||||
"async.refreshing": "Checking for the latest information.",
|
"async.refreshing": "Checking for the latest information.",
|
||||||
"async.staleDegraded": "Showing previously loaded information.",
|
"async.staleDegraded": "Showing previously loaded information.",
|
||||||
"async.mutationPending": "Saving changes.",
|
"async.mutationPending": "Saving changes.",
|
||||||
|
"async.mutationEffectUnknown": "The result of the change is unknown.",
|
||||||
"async.mutationConflict": "The change conflicts with another update.",
|
"async.mutationConflict": "The change conflicts with another update.",
|
||||||
"access.auth.eyebrow": "401 · Authentication required",
|
"access.auth.eyebrow": "401 · Authentication required",
|
||||||
"access.auth.title": "Sign-in is required.",
|
"access.auth.title": "Sign-in is required.",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,10 @@ describe("async UI state matrix", () => {
|
|||||||
[{ data: ["value"], isFetching: true }, "refreshing"],
|
[{ data: ["value"], isFetching: true }, "refreshing"],
|
||||||
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
||||||
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
||||||
|
[
|
||||||
|
{ data: ["value"], hasMutationEffectUnknown: true },
|
||||||
|
"mutation-effect-unknown",
|
||||||
|
],
|
||||||
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
||||||
])("derives overlay state %#", (signals, indicator) => {
|
])("derives overlay state %#", (signals, indicator) => {
|
||||||
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||||
@@ -35,16 +39,55 @@ describe("async UI state matrix", () => {
|
|||||||
data: ["value"],
|
data: ["value"],
|
||||||
isFetching: true,
|
isFetching: true,
|
||||||
isMutationPending: true,
|
isMutationPending: true,
|
||||||
|
hasMutationEffectUnknown: true,
|
||||||
hasMutationConflict: true,
|
hasMutationConflict: true,
|
||||||
});
|
});
|
||||||
expect(state.indicator).toBe("mutation-conflict");
|
expect(state.indicator).toBe("mutation-effect-unknown");
|
||||||
expect(state.overlay).toMatchObject({
|
expect(state.overlay).toMatchObject({
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
mutationPending: false,
|
mutationPending: false,
|
||||||
mutationConflict: true,
|
mutationEffectUnknown: true,
|
||||||
|
mutationConflict: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders unknown mutation effects with reconciliation-only actions", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const retry = vi.fn();
|
||||||
|
const reconcile = vi.fn();
|
||||||
|
const state = deriveAsyncState({
|
||||||
|
data: ["value"],
|
||||||
|
hasMutationEffectUnknown: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AsyncSurface
|
||||||
|
state={state}
|
||||||
|
onRetry={retry}
|
||||||
|
onReconcileUnknownEffect={reconcile}
|
||||||
|
>
|
||||||
|
existing content
|
||||||
|
</AsyncSurface>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent(
|
||||||
|
"변경 결과를 확인할 수 없습니다.",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("existing content").closest("section"),
|
||||||
|
).toHaveAttribute("aria-busy", "false");
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "다시 시도" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "변경됨으로 확인" }));
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||||
|
);
|
||||||
|
expect(reconcile).toHaveBeenNthCalledWith(1, "APPLIED");
|
||||||
|
expect(reconcile).toHaveBeenNthCalledWith(2, "NOT_APPLIED");
|
||||||
|
expect(retry).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps content visible while a non-blocking refresh runs", () => {
|
it("keeps content visible while a non-blocking refresh runs", () => {
|
||||||
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
||||||
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ describe("reference feature page states", () => {
|
|||||||
"CONFLICT",
|
"CONFLICT",
|
||||||
"CREATE_REFERENCE_RESOURCE",
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
0,
|
0,
|
||||||
|
{ effect: "NOT_APPLIED" },
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||||
@@ -290,6 +291,112 @@ describe("reference feature page states", () => {
|
|||||||
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const createResource = vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: createFailure(
|
||||||
|
"SERVER_FAILURE",
|
||||||
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
|
0,
|
||||||
|
{ effect: "MAYBE_APPLIED" },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
renderReference(
|
||||||
|
inputWith({ createResource }),
|
||||||
|
"/examples/reference-resources/new",
|
||||||
|
);
|
||||||
|
await user.type(
|
||||||
|
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||||
|
"Unknown result",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("변경 결과를 확인할 수 없습니다."),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
expect(createResource).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||||
|
).toHaveValue("Unknown result");
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
|
||||||
|
);
|
||||||
|
expect(createResource).toHaveBeenCalledOnce();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||||
|
).toHaveValue("Unknown result");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles the form after confirming an unknown create was applied", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const createResource = vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: createFailure(
|
||||||
|
"SERVER_FAILURE",
|
||||||
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
|
0,
|
||||||
|
{ effect: "MAYBE_APPLIED" },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
renderReference(
|
||||||
|
inputWith({ createResource }),
|
||||||
|
"/examples/reference-resources/new",
|
||||||
|
);
|
||||||
|
const name = await screen.findByRole("textbox", {
|
||||||
|
name: /새 항목 이름/,
|
||||||
|
});
|
||||||
|
await user.type(name, "Already created");
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "변경됨으로 확인" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||||
|
expect(name).toHaveValue("");
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
expect(createResource).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an applied-confirmed failure as a settled create", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const createResource = vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
error: createFailure(
|
||||||
|
"SERVER_FAILURE",
|
||||||
|
"CREATE_REFERENCE_RESOURCE",
|
||||||
|
0,
|
||||||
|
{ effect: "APPLIED_CONFIRMED" },
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
renderReference(
|
||||||
|
inputWith({ createResource }),
|
||||||
|
"/examples/reference-resources/new",
|
||||||
|
);
|
||||||
|
const name = await screen.findByRole("textbox", {
|
||||||
|
name: /새 항목 이름/,
|
||||||
|
});
|
||||||
|
await user.type(name, "Committed despite response");
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||||
|
expect(name).toHaveValue("");
|
||||||
|
expect(
|
||||||
|
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||||
|
expect(createResource).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const listResources = vi
|
const listResources = vi
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ export const invalidPendingConflict: AsyncOverlay = {
|
|||||||
refreshing: false,
|
refreshing: false,
|
||||||
staleDegraded: false,
|
staleDegraded: false,
|
||||||
mutationPending: true,
|
mutationPending: true,
|
||||||
|
mutationEffectUnknown: false,
|
||||||
mutationConflict: true,
|
mutationConflict: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,71 @@ describe("revision-safe optimistic layer runtime", () => {
|
|||||||
expect(client.getQueryData(key)).toEqual(["server", "pending"]);
|
expect(client.getQueryData(key)).toEqual(["server", "pending"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps later committed layers projected while an earlier effect is uncertain", () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const key = ["query", "resources"];
|
||||||
|
client.setQueryData(key, ["base"]);
|
||||||
|
const selectedScope = scope();
|
||||||
|
const runtime = createOptimisticLayerRuntime(client);
|
||||||
|
const append = (previous: unknown, input: string) => [
|
||||||
|
...(previous as string[]),
|
||||||
|
input,
|
||||||
|
];
|
||||||
|
const first = runtime.begin(
|
||||||
|
key,
|
||||||
|
"first",
|
||||||
|
append,
|
||||||
|
selectedScope.snapshot,
|
||||||
|
);
|
||||||
|
const second = runtime.begin(
|
||||||
|
key,
|
||||||
|
"second",
|
||||||
|
append,
|
||||||
|
selectedScope.snapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
first?.markUncertain();
|
||||||
|
first?.commit();
|
||||||
|
first?.rollback();
|
||||||
|
second?.commit();
|
||||||
|
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||||
|
|
||||||
|
first?.reconcile("NOT_APPLIED");
|
||||||
|
expect(client.getQueryData(key)).toEqual(["base", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses uncertain and later committed layers in order when reconciled as applied", () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const key = ["query", "resources"];
|
||||||
|
client.setQueryData(key, ["base"]);
|
||||||
|
const selectedScope = scope();
|
||||||
|
const runtime = createOptimisticLayerRuntime(client);
|
||||||
|
const append = (previous: unknown, input: string) => [
|
||||||
|
...(previous as string[]),
|
||||||
|
input,
|
||||||
|
];
|
||||||
|
const first = runtime.begin(
|
||||||
|
key,
|
||||||
|
"first",
|
||||||
|
append,
|
||||||
|
selectedScope.snapshot,
|
||||||
|
);
|
||||||
|
const second = runtime.begin(
|
||||||
|
key,
|
||||||
|
"second",
|
||||||
|
append,
|
||||||
|
selectedScope.snapshot,
|
||||||
|
);
|
||||||
|
|
||||||
|
first?.markUncertain();
|
||||||
|
second?.commit();
|
||||||
|
first?.reconcile("APPLIED");
|
||||||
|
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||||
|
|
||||||
|
first?.reconcile("NOT_APPLIED");
|
||||||
|
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("removes scoped data instead of restoring it after scope expiry", () => {
|
it("removes scoped data instead of restoring it after scope expiry", () => {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const key = ["query", "resources"];
|
const key = ["query", "resources"];
|
||||||
@@ -87,4 +152,24 @@ describe("revision-safe optimistic layer runtime", () => {
|
|||||||
layer?.rollback();
|
layer?.rollback();
|
||||||
expect(client.getQueryData(key)).toBeUndefined();
|
expect(client.getQueryData(key)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not restore an uncertain layer after its scope expires", () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const key = ["query", "resources"];
|
||||||
|
client.setQueryData(key, ["base"]);
|
||||||
|
const selectedScope = scope();
|
||||||
|
const runtime = createOptimisticLayerRuntime(client);
|
||||||
|
const layer = runtime.begin(
|
||||||
|
key,
|
||||||
|
"uncertain",
|
||||||
|
(previous, input) => [...(previous as string[]), input],
|
||||||
|
selectedScope.snapshot,
|
||||||
|
);
|
||||||
|
layer?.markUncertain();
|
||||||
|
|
||||||
|
selectedScope.expire();
|
||||||
|
layer?.reconcile("APPLIED");
|
||||||
|
|
||||||
|
expect(client.getQueryData(key)).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user