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
+35 -6
View File
@@ -12,13 +12,34 @@ reference implementation이다.
- `presentation`: route input을 query/form controller로 연결하는 inbound
adapter, 독립 form schema/command mapper와 list/detail/form/status page
generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이
자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output
port나 TanStack API를 직접 import하지 않는다.
generic application은 비어 있는 `ApplicationFeatureInputs`와 typed
`features.has/get` registry만 소유한다. 이 feature의 application API가 module
augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므로
등록되지 않은 ID와 잘못된 input shape는 typecheck에서 거절된다. feature hook은
별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로
설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다.
예측 가능한 실패는 `src/application/result.ts`의 공통
`Result<Value, Failure = AppFailure>`로 반환한다. `AppFailure.kind`
`ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인
`ApiFailure`는 같은 type의 alias다.
HTTP adapter의 operation map은 operation ID마다 허용된 route ID, request shape와
성공 value type을 함께 묶는다. raw HTTP executor의 성공값은 Zod request/response
검증과 feature mapper를 통과한 뒤 operation별 runtime result guard에서 typed
executor로 승격된다. 따라서 gateway 메서드는 개별 응답 cast 없이 정확한 결과를
반환하고, operation/route/request 조합 오류는 typecheck에서 차단된다.
route codec과 URL builder는 `src/presentation/routes/route-codecs.ts`, route type은
`route-contract.ts`, React context/provider/hook은 `route-input.tsx`가 각각
소유한다. reference page는 `app-router.tsx`를 역참조하지 않고 좁은
`useRouteInput` 경계만 사용하므로 lazy route와 router 사이의 순환 의존을 만들지
않는다. page는 HTTP client, storage, auth owner, output port나 TanStack API를
직접 import하지 않는다.
## 설치 지점
- 직렬화 계약: `src/features/installed-feature-contracts.js`
- 직렬화 계약: `src/features/installed-feature-contracts.ts`
- component/codec: `src/features/installed-feature-runtimes.tsx`
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
@@ -35,5 +56,13 @@ corepack pnpm test:sample-removal
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
production build와 source/built fixture ID 잔여 0개를 검사한다.
재생성한다. feature 소유 coverage include, risk-policy 행과 test-evidence
contribution도 제거한 뒤 typecheck, architecture, registry, unit/integration,
coverage, source evidence, home smoke, production build와 source/built fixture ID
잔여 0개를 검사한다. generic feature registry의 성공 경로는 reference와 무관한
unit test가 소유하므로 feature 삭제 후에도 공통 boundary coverage가 유지된다.
CI의 negative type fixture는 잘못된 feature ID/input shape, registry에 없는
failure kind, operation과 route ID의 불일치가 실제로 컴파일 실패하는지도
검증한다. Architecture fixture는 허용 edge뿐 아니라 unresolved import, 금지 계층
edge와 TypeScript 순환 의존을 각각 거절해야 통과한다.
@@ -1,30 +1,45 @@
import { createReferenceFeatureInput } from "../application/reference-feature-api.js";
import type { ApiOperation } from "../../../contracts/api-operations.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_CONTRACT,
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.js";
import { mapReferenceOperation } from "../contracts/reference-mapper.js";
} from "../contracts/reference-feature-contract.ts";
import {
validateReferencePayload,
validateReferenceRequest,
} from "../contracts/reference-schemas.js";
import { createReferenceHttpGateway } from "./reference-http-gateway.js";
mapWithBoundaryRegistry,
type MappingResult,
} from "../../../contracts/boundary-mapper.ts";
import { validateWithRuntimeSchemaRegistry } from "../../../contracts/schema-registry.ts";
import {
createReferenceHttpGateway,
type RawReferenceHttpExecutor,
} from "./reference-http-gateway.ts";
type HttpContract = Readonly<{
getOperation(operationId: string): unknown;
validatePayload: typeof validateReferencePayload;
validateRequest: typeof validateReferenceRequest;
mapPayload: typeof mapReferenceOperation;
getOperation(operationId: string): ApiOperation;
validatePayload(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
validateRequest(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
validatePath(schemaId: string, value: unknown): ReturnType<
typeof validateWithRuntimeSchemaRegistry
>;
mapPayload(operationId: string, payload: unknown): MappingResult<unknown>;
}>;
type HttpExecutor = Parameters<typeof createReferenceHttpGateway>[0];
type HttpExecutor = RawReferenceHttpExecutor;
export function createReferenceFeatureInstalledInput(context: Readonly<{
createHttpClient(contract: HttpContract): HttpExecutor;
}>) {
const operations =
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, unknown>>;
const http = context.createHttpClient({
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<Record<string, ApiOperation>>;
const schemas = REFERENCE_FEATURE_CONTRACT.runtimeSchemas;
const mappers = REFERENCE_FEATURE_CONTRACT.mappers;
const validate = (schemaId: string, value: unknown) =>
validateWithRuntimeSchemaRegistry(schemaId, value, schemas);
const rawHttp = context.createHttpClient({
getOperation(operationId) {
const operation = operations[operationId];
if (!operation) {
@@ -32,12 +47,19 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{
}
return operation;
},
validatePayload: validateReferencePayload,
validateRequest: validateReferenceRequest,
mapPayload: mapReferenceOperation,
validatePayload: validate,
validateRequest: validate,
validatePath: validate,
mapPayload(operationId, payload) {
const operation = operations[operationId];
if (!operation?.mapperId) {
return { ok: false, code: "MAPPING_INVARIANT_REJECTED" };
}
return mapWithBoundaryRegistry(operation.mapperId, payload, mappers);
},
});
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(http)),
input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)),
});
}
@@ -1,29 +1,62 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import type { Result } from "../../../application/result.ts";
import {
createFailure,
type ApiFailure,
} from "../../../contracts/errors.ts";
import type {
ReferenceCreateCommand,
ReferenceGateway,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
} from "../application/reference-feature-api.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
type HttpResult =
| Readonly<{ ok: true; value: unknown }>
| Readonly<{ ok: false; error: ApiFailure }>;
type HttpExecutor = Readonly<{
execute(
type ReferenceOperationMap = Readonly<{
LIST_REFERENCE_RESOURCES: Readonly<{
request: Readonly<{
operationId: string;
routeId: string;
pathParams?: Record<string, string | number>;
searchParams?: unknown;
body?: unknown;
operationId: "LIST_REFERENCE_RESOURCES";
routeId: "REFERENCE_RESOURCE_LIST";
searchParams: ReferenceListFilters;
signal?: AbortSignal;
}>,
): Promise<HttpResult>;
}>;
value: readonly ReferenceResource[];
}>;
CREATE_REFERENCE_RESOURCE: Readonly<{
request: Readonly<{
operationId: "CREATE_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_LIST";
body: ReferenceCreateCommand;
}>;
value: ReferenceResource;
}>;
GET_REFERENCE_RESOURCE: Readonly<{
request: Readonly<{
operationId: "GET_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_DETAIL";
pathParams: Readonly<{ resourceId: string }>;
signal?: AbortSignal;
}>;
value: ReferenceResource;
}>;
}>;
export type ReferenceOperationId = keyof ReferenceOperationMap;
export type ReferenceHttpRequest<
OperationId extends ReferenceOperationId,
> = ReferenceOperationMap[OperationId]["request"];
export type ReferenceHttpResult<
OperationId extends ReferenceOperationId,
> = Result<ReferenceOperationMap[OperationId]["value"], ApiFailure>;
export type RawReferenceHttpExecutor = Readonly<{
execute(
request: ReferenceHttpRequest<ReferenceOperationId>,
): Promise<Result<unknown, ApiFailure>>;
}>;
export function createReferenceHttpGateway(
http: HttpExecutor,
http: RawReferenceHttpExecutor,
): ReferenceGateway {
return Object.freeze({
async list(
@@ -36,22 +69,15 @@ export function createReferenceHttpGateway(
searchParams: filters,
signal: context?.signal,
});
return result.ok
? {
ok: true as const,
value: result.value as readonly ReferenceResource[],
}
: result;
return projectListResult(result);
},
async create(command: Readonly<{ name: string; note?: string }>) {
async create(command: ReferenceCreateCommand) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
},
async get(
resourceId: string,
@@ -63,9 +89,61 @@ export function createReferenceHttpGateway(
pathParams: { resourceId },
signal: context?.signal,
});
return result.ok
? { ok: true as const, value: result.value as ReferenceResource }
: result;
return projectResourceResult("GET_REFERENCE_RESOURCE", result);
},
});
}
function projectListResult(
result: Result<unknown, ApiFailure>,
): ReferenceHttpResult<"LIST_REFERENCE_RESOURCES"> {
if (!result.ok) return result;
if (isReferenceResourceList(result.value)) {
return { ok: true, value: result.value };
}
return typedResultFailure("LIST_REFERENCE_RESOURCES");
}
function projectResourceResult(
operationId:
| "CREATE_REFERENCE_RESOURCE"
| "GET_REFERENCE_RESOURCE",
result: Result<unknown, ApiFailure>,
): Result<ReferenceResource, ApiFailure> {
if (!result.ok) return result;
if (isReferenceResource(result.value)) {
return { ok: true, value: result.value };
}
return typedResultFailure(operationId);
}
function typedResultFailure(operationId: ReferenceOperationId) {
return {
ok: false as const,
error: createFailure(
"MAPPING_CONTRACT_VIOLATION",
operationId,
0,
{ code: "BOUND_RESULT_TYPE_MISMATCH" },
),
};
}
function isReferenceResourceList(
value: unknown,
): value is readonly ReferenceResource[] {
return Array.isArray(value) && value.every(isReferenceResource);
}
function isReferenceResource(value: unknown): value is ReferenceResource {
if (!value || typeof value !== "object") return false;
const candidate = value as Readonly<Record<string, unknown>>;
return (
typeof candidate.id === "string" &&
candidate.id.length > 0 &&
typeof candidate.displayName === "string" &&
candidate.displayName.length > 0 &&
(candidate.createdAt === null ||
typeof candidate.createdAt === "string")
);
}
@@ -1,9 +1,10 @@
import type { ApiFailure } from "../../../contracts/errors.js";
import type { Result } from "../../../application/result.ts";
import type {} from "../../../application/ports/in/application-api.ts";
import {
toReferenceView,
type ReferenceResourceView,
} from "../contracts/reference-mapper.js";
import type { ReferenceResource } from "../domain/reference-resource.js";
} from "../contracts/reference-mapper.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
export type ReferenceListFilters = Readonly<{
cursor?: string;
@@ -11,9 +12,12 @@ export type ReferenceListFilters = Readonly<{
tags?: readonly string[];
}>;
export type ReferenceResult<Value> =
| Readonly<{ ok: true; value: Value }>
| Readonly<{ ok: false; error: ApiFailure }>;
export type ReferenceResult<Value> = Result<Value>;
export type ReferenceCreateCommand = Readonly<{
name: string;
note?: string;
}>;
export type ReferenceFeatureInput = Readonly<{
listResources(
@@ -21,7 +25,7 @@ export type ReferenceFeatureInput = Readonly<{
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: Readonly<{ name: string; note?: string }>,
command: ReferenceCreateCommand,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
@@ -29,13 +33,19 @@ export type ReferenceFeatureInput = Readonly<{
): Promise<ReferenceResult<ReferenceResourceView>>;
}>;
declare module "../../../application/ports/in/application-api.ts" {
interface ApplicationFeatureInputs {
"reference-feature": ReferenceFeatureInput;
}
}
export type ReferenceGateway = Readonly<{
list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResource[]>>;
create(
command: Readonly<{ name: string; note?: string }>,
command: ReferenceCreateCommand,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
@@ -1,55 +1,81 @@
import { canonicalize } from "../../../contracts/query-keys.js";
import { canonicalize } from "../../../contracts/query-keys.ts";
import { defineQueryInvalidationTopic } from "../../../contracts/query-invalidation.ts";
import { defineRestOperation } from "../../../contracts/api-operations.ts";
import { REFERENCE_RUNTIME_SCHEMA_CODECS } from "./reference-schemas.ts";
import { REFERENCE_BOUNDARY_MAPPERS } from "./reference-mapper.ts";
export const REFERENCE_FEATURE_ID = "reference-feature";
const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1]);
const REFERENCE_NAMESPACE = Object.freeze(["reference-resource", 1] as const);
export const REFERENCE_RESOURCE_INVALIDATION_TOPIC =
defineQueryInvalidationTopic("qinv.01k10f7m3w9p6r2c8v5n4x");
export const referenceQueryKeys = Object.freeze({
all: () => REFERENCE_NAMESPACE,
list: (filters = {}) =>
list: (filters: Readonly<object> = {}) =>
Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]),
/** @param {string} resourceId */
detail: (resourceId) =>
detail: (resourceId: string) =>
Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]),
});
export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
featureId: REFERENCE_FEATURE_ID,
runtimeSchemas: REFERENCE_RUNTIME_SCHEMA_CODECS,
mappers: REFERENCE_BOUNDARY_MAPPERS,
schemas: Object.freeze({
ReferenceResourceParams: Object.freeze({
schemaId: "ReferenceResourceParams",
boundary: "route-params",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy: "REJECT_UNKNOWN",
}),
ReferenceResourceListQuery: Object.freeze({
schemaId: "ReferenceResourceListQuery",
boundary: "route-search-api-request",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy: "REJECT_UNKNOWN",
}),
CreateReferenceResourceCommand: Object.freeze({
schemaId: "CreateReferenceResourceCommand",
boundary: "api-request",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy: "REJECT_UNKNOWN",
}),
NoRequest: Object.freeze({
schemaId: "NoRequest",
boundary: "api-request",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy: "REJECT_UNKNOWN",
}),
ReferenceResourceListPayload: Object.freeze({
schemaId: "ReferenceResourceListPayload",
boundary: "api-response",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "RESPONSE",
unknownFieldPolicy: "STRIP_UNKNOWN",
}),
ReferenceResourcePayload: Object.freeze({
schemaId: "ReferenceResourcePayload",
boundary: "api-response",
owner: "feature-frontend-reference-feature-vertical-slice",
runtime: "zod",
schemaVersion: 1,
direction: "RESPONSE",
unknownFieldPolicy: "STRIP_UNKNOWN",
}),
}),
routes: Object.freeze({
@@ -133,7 +159,7 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
}),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: Object.freeze({
LIST_REFERENCE_RESOURCES: defineRestOperation({
method: "GET",
path: "/api/reference-resources",
operationId: "LIST_REFERENCE_RESOURCES",
@@ -145,8 +171,23 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
requestSchema: "ReferenceResourceListQuery",
responseSchema: "ReferenceResourceListPayload",
owner: "feature-frontend-reference-feature-vertical-slice",
contractVersion: 2,
protocol: "REST",
semantics: "QUERY",
replayPolicy: "SAFE",
idempotencyKeyPolicy: "NONE",
mapperId: "ReferenceResourceListMapper",
successStatuses: [200],
responseMediaTypes: ["application/json"],
maxResponseBytes: 262_144,
providerId: "PRIMARY_API",
authProfileId: "REFERENCE_EXTERNAL_BEARER",
csrfProfileId: "NO_CSRF_BEARER",
pathSchema: "NoRequest",
pathParameterNames: [],
maxEncodedSearchBytes: 4_096,
}),
CREATE_REFERENCE_RESOURCE: Object.freeze({
CREATE_REFERENCE_RESOURCE: defineRestOperation({
method: "POST",
path: "/api/reference-resources",
operationId: "CREATE_REFERENCE_RESOURCE",
@@ -158,8 +199,23 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
requestSchema: "CreateReferenceResourceCommand",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-reference-feature-vertical-slice",
contractVersion: 2,
protocol: "REST",
semantics: "COMMAND",
replayPolicy: "KEYED_COMMAND",
idempotencyKeyPolicy: "REQUIRED",
mapperId: "ReferenceResourceMapper",
successStatuses: [200, 201],
responseMediaTypes: ["application/json"],
maxResponseBytes: 32_768,
providerId: "PRIMARY_API",
authProfileId: "REFERENCE_EXTERNAL_BEARER",
csrfProfileId: "NO_CSRF_BEARER",
pathSchema: "NoRequest",
pathParameterNames: [],
maxEncodedSearchBytes: 0,
}),
GET_REFERENCE_RESOURCE: Object.freeze({
GET_REFERENCE_RESOURCE: defineRestOperation({
method: "GET",
path: "/api/reference-resources/{resourceId}",
operationId: "GET_REFERENCE_RESOURCE",
@@ -170,7 +226,22 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
requestSource: "none",
requestSchema: "NoRequest",
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-form-page-platform",
owner: "feature-frontend-reference-feature-vertical-slice",
contractVersion: 2,
protocol: "REST",
semantics: "QUERY",
replayPolicy: "SAFE",
idempotencyKeyPolicy: "NONE",
mapperId: "ReferenceResourceMapper",
successStatuses: [200],
responseMediaTypes: ["application/json"],
maxResponseBytes: 32_768,
providerId: "PRIMARY_API",
authProfileId: "REFERENCE_EXTERNAL_BEARER",
csrfProfileId: "NO_CSRF_BEARER",
pathSchema: "ReferenceResourceParams",
pathParameterNames: ["resourceId"],
maxEncodedSearchBytes: 0,
}),
}),
queryRegistry: Object.freeze({
@@ -179,8 +250,10 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
serialization: "canonical-object-order",
identity: "no-pii-token-or-raw-url",
invalidation: "reference resource namespace after successful mutation",
invalidationTopic: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
crossContext: "invalidate-only",
version: 1,
persistence: "disabled",
}),
}),
});
} as const);
@@ -1,7 +1,13 @@
import {
createReferenceResource,
type ReferenceResource,
} from "../domain/reference-resource.js";
} from "../domain/reference-resource.ts";
import {
mappingFailure,
mappingSuccess,
type MappingResult,
type InstalledBoundaryMapper,
} from "../../../contracts/boundary-mapper.ts";
export type ReferenceResourceView = Readonly<{
resourceId: string;
@@ -10,28 +16,43 @@ export type ReferenceResourceView = Readonly<{
optimistic?: boolean;
}>;
function mapReferenceDto(value: unknown): ReferenceResource {
function mapReferenceDto(value: unknown): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
throw new TypeError("Validated reference DTO is required");
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
throw new TypeError("Validated reference DTO invariants were breached");
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
try {
return mappingSuccess(
createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
}),
);
} catch {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
return createReferenceResource({
id: dto.id,
displayName: dto.name,
createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null,
});
}
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): ReferenceResource | readonly ReferenceResource[] {
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") {
if (!Array.isArray(payload)) throw new TypeError("Expected a reference list");
return payload.map(mapReferenceDto);
if (!Array.isArray(payload)) {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
if (payload.length > 100) return mappingFailure("OUTPUT_LIMIT_EXCEEDED");
const output: ReferenceResource[] = [];
for (const item of payload) {
const mapped = mapReferenceDto(item);
if (!mapped.ok) return mapped;
output.push(mapped.value);
}
return mappingSuccess(Object.freeze(output));
}
if (
operationId === "CREATE_REFERENCE_RESOURCE" ||
@@ -39,9 +60,32 @@ export function mapReferenceOperation(
) {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({
ReferenceResourceListMapper: Object.freeze({
mapperId: "ReferenceResourceListMapper",
mapperVersion: 1,
inputSchemaId: "ReferenceResourceListPayload",
outputContractId: "ReferenceResourceList",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 100,
map: (input: unknown) =>
mapReferenceOperation("LIST_REFERENCE_RESOURCES", input),
}),
ReferenceResourceMapper: Object.freeze({
mapperId: "ReferenceResourceMapper",
mapperVersion: 1,
inputSchemaId: "ReferenceResourcePayload",
outputContractId: "ReferenceResource",
owner: "feature-frontend-reference-feature-vertical-slice",
maxOutputItems: 1,
map: (input: unknown) =>
mapReferenceOperation("GET_REFERENCE_RESOURCE", input),
}),
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
export function toReferenceView(
resource: ReferenceResource,
): ReferenceResourceView {
@@ -19,4 +19,4 @@ export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
});
} as const);
@@ -1,4 +1,5 @@
import { z } from "zod";
import type { RuntimeSchemaCodec } from "../../../contracts/schema-registry.ts";
export const referenceResourceListQuerySchema = z
.object({
@@ -26,18 +27,19 @@ export const referenceResourceParamsSchema = z
const referenceResourceDtoSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().datetime().optional(),
})
.strict();
.strip();
const payloadSchemas = {
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema),
ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100),
ReferenceResourcePayload: referenceResourceDtoSchema,
} satisfies Record<string, z.ZodType>;
const requestSchemas = {
ReferenceResourceParams: referenceResourceParamsSchema,
ReferenceResourceListQuery: referenceResourceListQuerySchema,
NoRequest: z.object({}).strict(),
CreateReferenceResourceCommand: z
@@ -60,6 +62,40 @@ function project(result: z.ZodSafeParseResult<unknown>) {
});
}
function codec(
schemaId: string,
schema: z.ZodType,
): RuntimeSchemaCodec {
return Object.freeze({
schemaId,
parse: (value: unknown) => project(schema.safeParse(value)),
});
}
export const REFERENCE_RUNTIME_SCHEMA_CODECS = Object.freeze({
ReferenceResourceParams: codec(
"ReferenceResourceParams",
referenceResourceParamsSchema,
),
ReferenceResourceListQuery: codec(
"ReferenceResourceListQuery",
referenceResourceListQuerySchema,
),
NoRequest: codec("NoRequest", requestSchemas.NoRequest),
CreateReferenceResourceCommand: codec(
"CreateReferenceResourceCommand",
requestSchemas.CreateReferenceResourceCommand,
),
ReferenceResourceListPayload: codec(
"ReferenceResourceListPayload",
payloadSchemas.ReferenceResourceListPayload,
),
ReferenceResourcePayload: codec(
"ReferenceResourcePayload",
payloadSchemas.ReferenceResourcePayload,
),
});
export function validateReferencePayload(schemaId: string, value: unknown) {
const schema = payloadSchemas[schemaId as keyof typeof payloadSchemas];
return schema
@@ -3,7 +3,7 @@ import { lazy } from "react";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.js";
} from "../contracts/reference-schemas.ts";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
@@ -13,18 +13,18 @@ export const REFERENCE_FEATURE_ROUTE_CODECS = {
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
Component: lazy(() => import("./reference-resource-page.tsx")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
Component: lazy(() => import("./reference-resource-detail-page.js")),
Component: lazy(() => import("./reference-resource-detail-page.tsx")),
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
moduleId: "reference-resource-form-page",
Component: lazy(() => import("./reference-resource-form-page.js")),
Component: lazy(() => import("./reference-resource-form-page.tsx")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.js")),
Component: lazy(() => import("./reference-resource-status-page.tsx")),
}),
} as const;
@@ -3,10 +3,11 @@ import { Link } from "react-router-dom";
import {
AsyncSurface,
DetailPage,
} from "../../../presentation/design-system/index.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import { useLocale } from "../../../presentation/i18n/index.js";
import { useReferenceDetail } from "./use-reference-feature.js";
} from "../../../presentation/design-system/index.ts";
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
import { useLocale } from "../../../presentation/i18n/index.ts";
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
import { useReferenceDetail } from "./use-reference-feature.ts";
export default function ReferenceResourceDetailPage() {
const { date, message } = useLocale();
@@ -14,6 +15,7 @@ export default function ReferenceResourceDetailPage() {
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
const failureAction = useReferenceFailureAction(query.state.failure);
return (
<DetailPage
@@ -43,7 +45,11 @@ export default function ReferenceResourceDetailPage() {
)
}
feedback={
<AsyncSurface state={query.state} onRetry={query.retry}>
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
{resource ? (
<p> section을 .</p>
) : null}
@@ -11,14 +11,14 @@ import {
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/design-system/index.js";
} from "../../../presentation/design-system/index.ts";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.js";
import { useReferenceCreate } from "./use-reference-feature.js";
} from "./reference-resource-form.ts";
import { useReferenceCreate } from "./use-reference-feature.ts";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
@@ -4,12 +4,14 @@ import {
AsyncSurface,
Button,
CollectionPage,
} from "../../../presentation/design-system/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
} from "../../../presentation/design-system/index.ts";
import { useReferenceFailureAction } from "./use-reference-failure-action.ts";
import { useReferenceFeature } from "./use-reference-feature.ts";
export default function ReferenceResourcePage() {
const navigate = useNavigate();
const { filters, query } = useReferenceFeature();
const failureAction = useReferenceFailureAction(query.state.failure);
return (
<CollectionPage
@@ -37,7 +39,11 @@ export default function ReferenceResourcePage() {
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface state={query.state} onRetry={query.retry}>
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li key={resource.resourceId}>
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/design-system/index.js";
import { StatusPage } from "../../../presentation/design-system/index.ts";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
@@ -0,0 +1,50 @@
import { useCallback } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import type { AppFailure } from "../../../contracts/errors.ts";
import { useSession } from "../../../presentation/providers/session-provider.tsx";
const REFERENCE_SUPPORT_ROUTE = "/examples/reference-resources/status";
/**
* Reference queries own concrete destinations for generic application failure
* actions. Retry remains query-owned; guarded release reloads remain in the
* chunk recovery boundary.
*/
export function useReferenceFailureAction(
failure: AppFailure | undefined,
): (() => void) | undefined {
const location = useLocation();
const navigate = useNavigate();
const { beginSignIn } = useSession();
const action = failure?.action;
const handleAction = useCallback(() => {
if (action === "reauth") {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
void beginSignIn(returnTo).catch(() => {
void navigate("/");
});
return;
}
if (action === "navigate") {
void navigate("/");
return;
}
if (action === "contact-support") {
void navigate(REFERENCE_SUPPORT_ROUTE);
}
}, [
action,
beginSignIn,
location.hash,
location.pathname,
location.search,
navigate,
]);
return action === "reauth" ||
action === "navigate" ||
action === "contact-support"
? handleAction
: undefined;
}
@@ -1,81 +1,115 @@
import { useApplication } from "../../../presentation/providers/application-provider.js";
import { useApplication } from "../../../presentation/providers/application-provider.tsx";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.js";
import { useRouteInput } from "../../../presentation/routes/app-router.js";
import type { ReferenceResourceView } from "../contracts/reference-mapper.js";
} from "../../../presentation/adapters/query/application-query.ts";
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
import {
REFERENCE_FEATURE_ID,
referenceQueryKeys,
} from "../contracts/reference-feature-contract.js";
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
} from "../contracts/reference-feature-contract.ts";
import type {
ReferenceCreateCommand,
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.js";
} from "../application/reference-feature-api.ts";
import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
import {
bindQuery,
defineServerStateProfile,
type BoundMutation,
} from "../../../contracts/server-state.ts";
import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
const REFERENCE_READ_PROFILE = defineServerStateProfile({
profileId: "reference-resource-read-v1",
staleTimeMs: 30_000,
gcTimeMs: 300_000,
refetchOnMount: true,
refetchOnFocus: true,
refetchOnReconnect: true,
retryOwner: "TRANSPORT",
maxResultItems: 100,
maxEstimatedResultBytes: 262_144,
});
export function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
typeof (candidate as ReferenceFeatureInput).createResource !== "function" ||
typeof (candidate as ReferenceFeatureInput).getResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
return useApplication().features.get(REFERENCE_FEATURE_ID);
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const query = useApplicationQuery({
queryKey: referenceQueryKeys.detail(resourceId),
execute: ({ signal }) => input.getResource(resourceId, { signal }),
});
const scope = useServerStateScope();
const query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-detail-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: "reference-resource",
namespaceVersion: 1,
operationId: "GET_REFERENCE_RESOURCE",
profile: REFERENCE_READ_PROFILE,
execute: (selectedResourceId: string, { signal }) =>
input.getResource(selectedResourceId, { signal }),
},
resourceId,
scope,
),
);
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
return useApplicationMutation({
const scope = useServerStateScope();
const mutation: BoundMutation<
ReferenceCreateCommand,
ReferenceResourceView
> = {
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "JOIN_IDENTICAL",
scope,
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
});
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
};
return useApplicationMutation(mutation);
}
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const routeInput = useRouteInput();
const filters = routeInput.search as ReferenceListFilters;
const queryKey = referenceQueryKeys.list(filters);
const query = useApplicationQuery({
queryKey,
execute: ({ signal }) => input.listResources(filters, { signal }),
});
const mutation = useApplicationMutation({
execute: input.createResource,
invalidate: [referenceQueryKeys.all()],
currentData: true,
optimistic: {
queryKey,
update(previous, command: Readonly<{ name: string }>) {
const current = Array.isArray(previous)
? (previous as readonly ReferenceResourceView[])
: [];
return [
...current,
{
resourceId: `optimistic:${command.name}`,
title: command.name,
createdAt: null,
optimistic: true,
},
];
const query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-list-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: "reference-resource",
namespaceVersion: 1,
operationId: "LIST_REFERENCE_RESOURCES",
profile: REFERENCE_READ_PROFILE,
execute: (selectedFilters: ReferenceListFilters, { signal }) =>
input.listResources(selectedFilters, { signal }),
},
},
filters,
scope,
),
);
const mutation = useApplicationMutation({
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "JOIN_IDENTICAL",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
});
return Object.freeze({ filters, query, mutation });
}