feat: 기능 추가 과정중

This commit is contained in:
donghyeon-ka
2026-07-30 15:58:20 +09:00
parent d3ef801fe6
commit 6c52cdb916
648 changed files with 126325 additions and 6680 deletions
@@ -1,7 +1,7 @@
import {
useCallback,
useEffect,
useRef,
useMemo,
useState,
} from "react";
import {
@@ -13,17 +13,31 @@ import {
import {
deriveAsyncState,
type AsyncState,
} from "../../../application/view-models/async-state.js";
import type { ApiFailure } from "../../../contracts/errors.js";
} from "../../../application/view-models/async-state.ts";
import type { Result } from "../../../application/result.ts";
import {
createFailure,
normalizeUnknownFailure,
type AppFailure,
} from "../../../contracts/errors.ts";
import type { QueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import type {
BoundMutation,
BoundQuery,
} from "../../../contracts/server-state.ts";
import { runtimeIdentityToken } from "../../../contracts/query-keys.ts";
import { useQueryInvalidationCoordinator } from "./query-invalidation-provider.tsx";
import {
createOptimisticLayerRuntime,
type OptimisticLayerLease,
} from "./optimistic-layer-runtime.ts";
export type ApplicationResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type ApplicationResult<Value> = Result<Value>;
class ApplicationQueryError extends Error {
readonly failure: ApiFailure;
readonly failure: AppFailure;
constructor(failure: ApiFailure) {
constructor(failure: AppFailure) {
super(failure.kind);
this.name = "ApplicationQueryError";
this.failure = failure;
@@ -31,31 +45,102 @@ class ApplicationQueryError extends Error {
}
export function useApplicationQuery<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
options:
| BoundQuery<Value>
| 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 { queryKey, execute } = options;
const enabled = "enabled" in options ? options.enabled ?? true : true;
const profile = "profile" in options ? options.profile : undefined;
const scope = "scope" in options ? options.scope : undefined;
const identity = "identity" in options ? options.identity : undefined;
const queryDefinitionId =
"definitionId" in options ? options.definitionId : "APPLICATION_QUERY";
const [staleFailure, setStaleFailure] = useState(false);
useEffect(() => {
identity?.acquire();
return () => identity?.release();
}, [identity]);
const query = useQuery<Value, ApplicationQueryError>({
queryKey,
enabled,
retry: false,
staleTime: profile?.staleTimeMs,
gcTime: profile?.gcTimeMs,
refetchOnMount: profile?.refetchOnMount,
refetchOnWindowFocus: profile?.refetchOnFocus,
refetchOnReconnect: profile?.refetchOnReconnect,
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");
identity?.acquire();
try {
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_STALE" },
),
);
}
const result = await execute({ signal });
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
queryDefinitionId,
0,
{ code: "QUERY_SCOPE_CHANGED" },
),
);
}
if (result.ok) {
if (
profile &&
!isAdmissibleResult(
result.value,
profile.maxResultItems,
profile.maxEstimatedResultBytes,
)
) {
throw new ApplicationQueryError(
createFailure(
"RESULT_LIMIT_EXCEEDED",
"APPLICATION_QUERY",
0,
{ code: "RESULT_ADMISSION_LIMIT_EXCEEDED" },
),
);
}
return result.value;
}
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
throw new ApplicationQueryError(result.error);
} catch (error) {
if (signal.aborted) {
throw new DOMException("Query cancelled", "AbortError");
}
if (error instanceof ApplicationQueryError) throw error;
throw new ApplicationQueryError(
normalizeUnknownFailure(error, {
operationId: "APPLICATION_QUERY",
}),
);
} finally {
identity?.release();
}
throw new ApplicationQueryError(result.error);
},
});
const hasData = query.data !== undefined && query.data !== null;
@@ -91,28 +176,61 @@ export function useApplicationQuery<Value>(
}
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;
}>,
options:
| BoundMutation<Input, Value>
| Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly QueryInvalidationTopic[];
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 invalidationCoordinator = useQueryInvalidationCoordinator();
const { execute } = options;
const invalidate = useMemo(
() => options.invalidate ?? [],
[options.invalidate],
);
const optimistic = "optimistic" in options ? options.optimistic : undefined;
const currentData = "currentData" in options ? options.currentData : undefined;
const definitionId =
"definitionId" in options ? options.definitionId : "LEGACY_MUTATION";
const duplicatePolicy =
"duplicatePolicy" in options ? options.duplicatePolicy : "JOIN_IDENTICAL";
const [conflict, setConflict] = useState<AppFailure | null>(null);
const scope = "scope" in options ? options.scope : undefined;
const mutation = useMutation<Value, ApplicationQueryError, Input>({
retry: false,
mutationFn: async (input) => {
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
),
);
}
const result = await execute(input);
if (scope && !scope.isCurrent()) {
throw new ApplicationQueryError(
createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_CHANGED" },
),
);
}
if (result.ok) return result.value;
throw new ApplicationQueryError(result.error);
},
@@ -120,55 +238,172 @@ export function useApplicationMutation<Input, Value>(
const submit = useCallback(
(input: Input): Promise<ApplicationResult<Value>> => {
if (inFlight.current) return inFlight.current;
let identity: string;
let identityLease: ReturnType<
NonNullable<typeof scope>["identities"]["intern"]
> | null = null;
try {
if (scope) {
if (!scope.isCurrent()) {
return Promise.resolve({
ok: false,
error: createFailure(
"SCOPE_GENERATION_CHANGED",
definitionId,
0,
{ code: "MUTATION_SCOPE_STALE" },
),
});
}
identityLease = scope.identities.intern(input);
identityLease.acquire();
identity = `${scope.fingerprint}:${definitionId}:${identityLease.token}`;
} else {
identity = `${definitionId}:${runtimeIdentityToken(input)}`;
}
} catch (error) {
return Promise.resolve({
ok: false,
error: normalizeUnknownFailure(error, {
operationId: definitionId,
}),
});
}
const active = mutationExecutions(queryClient).get(identity) as
| Promise<ApplicationResult<Value>>
| undefined;
if (active && duplicatePolicy === "JOIN_IDENTICAL") {
identityLease?.release();
return active;
}
if (active && duplicatePolicy === "REJECT_DUPLICATE") {
identityLease?.release();
return Promise.resolve({
ok: false,
error: createFailure(
"DUPLICATE_IN_FLIGHT",
definitionId,
0,
{ code: "DUPLICATE_IN_FLIGHT" },
),
});
}
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) => {
const pending = (async (): Promise<ApplicationResult<Value>> => {
const mutationLease =
invalidate.length === 0
? null
: invalidationCoordinator?.beginMutation(invalidate);
if (invalidate.length > 0 && !mutationLease) {
throw new Error("Query invalidation coordinator is not installed.");
}
try {
let previous: unknown;
let hadPreviousData = false;
let optimisticLayer: OptimisticLayerLease | null = null;
if (optimistic) {
queryClient.setQueryData(optimistic.queryKey, previous);
await queryClient.cancelQueries({
queryKey: optimistic.queryKey,
exact: true,
});
if (scope) {
optimisticLayer = optimisticLayers(queryClient).begin(
optimistic.queryKey,
input,
optimistic.update,
scope,
);
} else {
previous = queryClient.getQueryData(optimistic.queryKey);
hadPreviousData = previous !== undefined;
queryClient.setQueryData(
optimistic.queryKey,
optimistic.update(previous, input),
);
}
}
const failure =
error instanceof ApplicationQueryError
? error.failure
: unexpectedMutationFailure();
let value: Value;
try {
value = await mutation.mutateAsync(input);
} catch (error: unknown) {
if (optimistic) {
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.failure
: normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false, error: failure };
}
optimisticLayer?.commit();
try {
await invalidationCoordinator?.invalidate(invalidate);
} catch {
// Cache refresh remains best effort after the server has committed.
}
return { ok: true, value };
} finally {
try {
await mutationLease?.release();
} catch {
// A cache coordination defect cannot change the committed command.
}
}
})()
.catch((error: unknown) => {
const failure = normalizeUnknownFailure(error, {
operationId: "APPLICATION_MUTATION",
});
if (failure.kind === "CONFLICT") setConflict(failure);
return { ok: false as const, error: failure };
})
.finally(() => {
inFlight.current = null;
identityLease?.release();
if (mutationExecutions(queryClient).get(identity) === pending) {
mutationExecutions(queryClient).delete(identity);
}
});
inFlight.current = pending;
if (duplicatePolicy !== "ALLOW_INDEPENDENT") {
mutationExecutions(queryClient).set(identity, pending);
}
return pending;
},
[invalidate, mutation, optimistic, queryClient],
[
invalidate,
invalidationCoordinator,
mutation,
optimistic,
queryClient,
definitionId,
duplicatePolicy,
scope,
],
);
const resolveConflict = useCallback(async () => {
setConflict(null);
mutation.reset();
for (const queryKey of invalidate) {
await queryClient.invalidateQueries({ queryKey, exact: false });
if (invalidate.length > 0 && !invalidationCoordinator) {
throw new Error("Query invalidation coordinator is not installed.");
}
}, [invalidate, mutation, queryClient]);
await invalidationCoordinator?.invalidate(invalidate);
}, [invalidate, invalidationCoordinator, mutation]);
return Object.freeze({
state: deriveAsyncState({
@@ -181,14 +416,65 @@ export function useApplicationMutation<Input, Value>(
});
}
function unexpectedMutationFailure(): ApiFailure {
return {
kind: "UNKNOWN_FAILURE",
code: "UNKNOWN_FAILURE",
retryable: false,
operationId: "APPLICATION_MUTATION",
attemptCount: 1,
userMessageKey: "error.unknown_failure",
action: "contact-support",
};
const RUNTIME_MUTATION_EXECUTIONS = new WeakMap<
object,
Map<string, Promise<ApplicationResult<unknown>>>
>();
function mutationExecutions(
owner: object,
): Map<string, Promise<ApplicationResult<unknown>>> {
const existing = RUNTIME_MUTATION_EXECUTIONS.get(owner);
if (existing) return existing;
const created = new Map<string, Promise<ApplicationResult<unknown>>>();
RUNTIME_MUTATION_EXECUTIONS.set(owner, created);
return created;
}
const OPTIMISTIC_LAYER_RUNTIMES = new WeakMap<
object,
ReturnType<typeof createOptimisticLayerRuntime>
>();
function optimisticLayers(
queryClient: Parameters<typeof createOptimisticLayerRuntime>[0],
): ReturnType<typeof createOptimisticLayerRuntime> {
const existing = OPTIMISTIC_LAYER_RUNTIMES.get(queryClient);
if (existing) return existing;
const created = createOptimisticLayerRuntime(queryClient);
OPTIMISTIC_LAYER_RUNTIMES.set(queryClient, created);
return created;
}
function isAdmissibleResult(
value: unknown,
maxItems: number,
maxBytes: number,
): boolean {
try {
const seen = new WeakSet<object>();
let items = 0;
const visit = (candidate: unknown): boolean => {
if (candidate === null || ["string", "number", "boolean"].includes(typeof candidate)) {
return true;
}
if (!candidate || typeof candidate !== "object" || seen.has(candidate)) return false;
seen.add(candidate);
if (Array.isArray(candidate)) {
items += candidate.length;
return items <= maxItems && candidate.every(visit);
}
const prototype = Object.getPrototypeOf(candidate);
return (
(prototype === Object.prototype || prototype === null) &&
Object.values(candidate).every(visit)
);
};
return (
visit(value) &&
new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes
);
} catch {
return false;
}
}