feat: execute HTTP and query runtime contracts

This commit is contained in:
donghyeon-ka
2026-07-26 14:05:12 +09:00
parent 8aaaa033c0
commit ad55e21a3d
29 changed files with 1326 additions and 185 deletions
@@ -0,0 +1,194 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.js";
import type { ApiFailure } from "../../../contracts/errors.js";
export type ApplicationResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
class ApplicationQueryError extends Error {
readonly failure: ApiFailure;
constructor(failure: ApiFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
}
}
export function useApplicationQuery<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute, enabled = true } = options;
const [staleFailure, setStaleFailure] = useState(false);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
queryFn: async ({ signal }) => {
const result = await execute({ signal });
if (result.ok) return result.value;
if (signal.aborted || result.error.kind === "REQUEST_ABORTED") {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
},
});
const hasData = query.data !== undefined && query.data !== null;
useEffect(() => {
if (query.isError && hasData) {
setStaleFailure(true);
} else if (query.isSuccess && !query.isFetching) {
setStaleFailure(false);
}
}, [hasData, query.isError, query.isFetching, query.isSuccess]);
const retry = useCallback(async () => {
setStaleFailure(false);
await query.refetch();
}, [query]);
return Object.freeze({
data: query.data,
state: deriveAsyncState({
data: query.data,
isInitialLoading: query.isPending,
failure:
!hasData && query.error instanceof ApplicationQueryError
? query.error.failure
: undefined,
isFetching: query.isFetching && !query.isPending,
isStale: staleFailure,
isDegraded: staleFailure,
}),
retry,
});
}
export function useApplicationMutation<Input, Value>(
options: Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly (readonly unknown[])[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
const queryClient = useQueryClient();
const { execute, invalidate = [], optimistic, currentData } = options;
const [conflict, setConflict] = useState<ApiFailure | null>(null);
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
const mutation = useMutation<Value, ApplicationQueryError, Input>({
retry: false,
mutationFn: async (input) => {
const result = await execute(input);
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
});
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
if (inFlight.current) return inFlight.current;
setConflict(null);
mutation.reset();
const previous = optimistic
? queryClient.getQueryData(optimistic.queryKey)
: undefined;
if (optimistic) {
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
const pending = mutation
.mutateAsync(input)
.then(async (value) => {
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
return { ok: true as const, value };
})
.catch((error: unknown) => {
if (optimistic) {
queryClient.setQueryData(optimistic.queryKey, previous);
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: unexpectedMutationFailure();
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
inFlight.current = null;
});
inFlight.current = pending;
return pending;
},
[invalidate, mutation, optimistic, queryClient],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
}
}, [invalidate, mutation, queryClient]);
return Object.freeze({
state: deriveAsyncState({
data: currentData ?? true,
isMutationPending: mutation.isPending,
hasMutationConflict: conflict !== null,
}),
submit,
resolveConflict,
});
}
function unexpectedMutationFailure(): ApiFailure {
return {
kind: "UNKNOWN_FAILURE",
code: "UNKNOWN_FAILURE",
retryable: false,
operationId: "APPLICATION_MUTATION",
attemptCount: 1,
userMessageKey: "error.unknown_failure",
action: "contact-support",
};
}
+5
View File
@@ -0,0 +1,5 @@
export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.js";
+23 -9
View File
@@ -65,7 +65,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
data-message-key={userMessageKey}
>
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
{action !== "none" && (
{action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button>
)}
</section>
@@ -76,10 +76,18 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
* @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode,
* onAction?: () => void
* onAction?: () => void,
* onRetry?: () => void,
* onResolveConflict?: () => void
* }} props
*/
export function AsyncSurface({ state, children, onAction }) {
export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
}) {
if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) {
@@ -87,18 +95,24 @@ export function AsyncSurface({ state, children, onAction }) {
<TerminalErrorSurface
userMessageKey={state.failure.userMessageKey}
action={state.failure.action}
onAction={onAction}
onAction={onRetry ?? onAction}
/>
);
}
return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && (
<p role="status" aria-live="polite">
{state.indicator}
</p>
)}
{state.indicator ? (
<div role="status" aria-live="polite">
<span>{state.indicator}</span>
{state.indicator === "stale-degraded" && onRetry ? (
<Button onClick={onRetry}>다시 시도</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
) : null}
</div>
) : null}
{children}
</section>
);