feat: 기능 추가 과정중
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user