chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
# Reference feature ownership and removal
이 모듈은 제품 도메인이 아니라 새 기능의 수직 경계를 검증하는 제거 가능한
reference implementation이다.
## 소유 경계
- `domain`: 외부 DTO와 React를 모르는 불변 model
- `application`: UI가 호출하는 list/get/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
- `presentation`: route input을 query/form controller로 연결하는 inbound
adapter, 독립 form schema/command mapper와 list/detail/form/status page
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.ts`
- component/codec: `src/features/installed-feature-runtimes.tsx`
- bootstrap input 조립: `src/features/installed-feature-adapters.ts`
새 기능도 이 세 지점에 contribution을 합성하되 feature ID를 generic application,
router나 HTTP client에 하드코딩하지 않는다.
## 검증과 제거
```sh
corepack pnpm test:reference-feature
corepack pnpm test:sample-removal
```
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한다. 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 순환 의존을 각각 거절해야 통과한다.
@@ -0,0 +1,182 @@
import type { Result } from "../../../application/result.ts";
import type { ApiFailure, FailureKind } from "../../../contracts/errors.ts";
import type { FailureEffectCertainty } from "../../../contracts/errors.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
import {
createFailure,
kindForStatus,
} from "../../../contracts/errors.ts";
import type { HttpExecutionOutcome } from "../../../adapters/http/http-execution-v3.ts";
import { createReferenceFeatureInput } from "../application/reference-feature-api.ts";
import {
REFERENCE_FEATURE_ID,
} from "../contracts/reference-feature-contract.ts";
import { mapReferenceOperation } from "../contracts/reference-mapper.ts";
import {
createReferenceHttpGateway,
type RawReferenceHttpExecutor,
type ReferenceHttpRequest,
type ReferenceOperationId,
} from "./reference-http-gateway.ts";
export type InstalledContractOperationExecutor = Readonly<{
execute(
operationId: string,
input: unknown,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<HttpExecutionOutcome<unknown, unknown>>;
}>;
/**
* The installed feature consumes the composed external-contract operation
* registry through one descriptor-driven executor. Legacy ApiOperation/schema
* registries are intentionally absent from this production composition seam.
*/
export function createReferenceFeatureInstalledInput(context: Readonly<{
contractOperations: InstalledContractOperationExecutor;
}>) {
const rawHttp: RawReferenceHttpExecutor = Object.freeze({
async execute(request) {
const operationId = request.operationId;
const input = inputFor(request);
const signal = "signal" in request ? request.signal : undefined;
const intent = "intent" in request ? request.intent : undefined;
const outcome = await context.contractOperations.execute(
operationId,
input,
{
...(signal === undefined ? {} : { signal }),
...(intent === undefined ? {} : { intent }),
},
);
return projectExecutionOutcome(operationId, outcome);
},
});
return Object.freeze({
featureId: REFERENCE_FEATURE_ID,
input: createReferenceFeatureInput(createReferenceHttpGateway(rawHttp)),
});
}
function inputFor(
request: ReferenceHttpRequest<ReferenceOperationId>,
): unknown {
switch (request.operationId) {
case "LIST_REFERENCE_RESOURCES":
return request.searchParams;
case "CREATE_REFERENCE_RESOURCE":
return request.body;
case "GET_REFERENCE_RESOURCE":
return request.pathParams;
}
}
function projectExecutionOutcome(
operationId: ReferenceOperationId,
outcome: HttpExecutionOutcome<unknown, unknown>,
): Result<unknown, ApiFailure> {
switch (outcome.kind) {
case "SUCCESS": {
const mapped = mapReferenceOperation(operationId, outcome.value);
return mapped.ok
? Object.freeze({ ok: true as const, value: mapped.value })
: failure(
"MAPPING_CONTRACT_VIOLATION",
operationId,
mapped.code,
{ effect: outcome.effect },
);
}
case "PROBLEM":
return failure(
kindForStatus(outcome.metadata.status),
operationId,
"CONTRACT_PROBLEM",
{ httpStatus: outcome.metadata.status, effect: outcome.effect },
);
case "UNAUTHENTICATED":
return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", {
effect: outcome.effect,
});
case "FORBIDDEN":
return failure("FORBIDDEN", operationId, "FORBIDDEN", {
effect: outcome.effect,
});
case "RATE_LIMITED":
return failure("RATE_LIMITED", operationId, "RATE_LIMITED", {
...(outcome.retryAfterMs === undefined
? {}
: { retryAfterMs: outcome.retryAfterMs }),
effect: outcome.effect,
});
case "CANCELLED":
return failure("REQUEST_ABORTED", operationId, "REQUEST_ABORTED", {
effect: outcome.effect,
});
case "TRANSPORT_FAILURE":
return failure(
outcome.failure.kind === "TIMEOUT"
? "REQUEST_TIMEOUT"
: outcome.failure.kind === "ABORTED_BY_SCOPE"
? "SCOPE_GENERATION_CHANGED"
: "NETWORK_UNREACHABLE",
operationId,
outcome.failure.kind,
{ effect: outcome.effect },
);
case "CONTRACT_VIOLATION":
return failure(
failureKindForViolation(outcome.violation.kind),
operationId,
outcome.violation.kind,
{ effect: outcome.effect },
);
}
}
function failureKindForViolation(
violation: Extract<
HttpExecutionOutcome<unknown, unknown>,
{ kind: "CONTRACT_VIOLATION" }
>["violation"]["kind"],
): FailureKind {
switch (violation) {
case "CONTENT_TYPE_MISMATCH":
return "CONTENT_TYPE_MISMATCH";
case "RESPONSE_TOO_LARGE":
return "RESPONSE_BODY_LIMIT";
case "UTF8_INVALID":
case "JSON_INVALID":
return "MALFORMED_JSON";
case "MAPPING_CONTRACT_VIOLATION":
return "MAPPING_CONTRACT_VIOLATION";
case "SCOPE_FENCED":
return "SCOPE_GENERATION_CHANGED";
case "SUCCESS_SCHEMA_INVALID":
case "PROBLEM_SCHEMA_INVALID":
case "VALIDATOR_RUNTIME_FAILURE":
return "SCHEMA_MISMATCH";
default:
return "ENVELOPE_MISMATCH";
}
}
function failure(
kind: FailureKind,
operationId: string,
code: string,
details: Readonly<{
httpStatus?: number;
retryAfterMs?: number;
effect?: FailureEffectCertainty;
}> = {},
): Result<never, ApiFailure> {
return Object.freeze({
ok: false as const,
error: createFailure(kind, operationId, 0, { code, ...details }),
});
}
@@ -0,0 +1,160 @@
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.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
type ReferenceOperationMap = Readonly<{
LIST_REFERENCE_RESOURCES: Readonly<{
request: Readonly<{
operationId: "LIST_REFERENCE_RESOURCES";
routeId: "REFERENCE_RESOURCE_LIST";
searchParams: ReferenceListFilters;
signal?: AbortSignal;
}>;
value: readonly ReferenceResource[];
}>;
CREATE_REFERENCE_RESOURCE: Readonly<{
request: Readonly<{
operationId: "CREATE_REFERENCE_RESOURCE";
routeId: "REFERENCE_RESOURCE_LIST";
body: ReferenceCreateCommand;
signal?: AbortSignal;
intent?: MutationIntent;
}>;
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: RawReferenceHttpExecutor,
): ReferenceGateway {
return Object.freeze({
async list(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "LIST_REFERENCE_RESOURCES",
routeId: "REFERENCE_RESOURCE_LIST",
searchParams: filters,
signal: context?.signal,
});
return projectListResult(result);
},
async create(
command: ReferenceCreateCommand,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
body: command,
signal: context?.signal,
intent: context?.intent,
});
return projectResourceResult("CREATE_REFERENCE_RESOURCE", result);
},
async get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
) {
const result = await http.execute({
operationId: "GET_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_DETAIL",
pathParams: { resourceId },
signal: context?.signal,
});
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")
);
}
@@ -0,0 +1,91 @@
import type { Result } from "../../../application/result.ts";
import type {} from "../../../application/ports/in/application-api.ts";
import {
toReferenceView,
type ReferenceResourceView,
} from "../contracts/reference-mapper.ts";
import type { ReferenceResource } from "../domain/reference-resource.ts";
import type { MutationIntent } from "../../../contracts/mutation-intent.ts";
export type ReferenceListFilters = Readonly<{
cursor?: string;
limit: number;
tags?: readonly string[];
}>;
export type ReferenceResult<Value> = Result<Value>;
export type ReferenceCreateCommand = Readonly<{
name: string;
note?: string;
}>;
export type ReferenceFeatureInput = Readonly<{
listResources(
filters: ReferenceListFilters,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<readonly ReferenceResourceView[]>>;
createResource(
command: ReferenceCreateCommand,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<ReferenceResult<ReferenceResourceView>>;
getResource(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): 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: ReferenceCreateCommand,
context?: Readonly<{
signal?: AbortSignal;
intent?: MutationIntent;
}>,
): Promise<ReferenceResult<ReferenceResource>>;
get(
resourceId: string,
context?: Readonly<{ signal?: AbortSignal }>,
): Promise<ReferenceResult<ReferenceResource>>;
}>;
export function createReferenceFeatureInput(
gateway: ReferenceGateway,
): ReferenceFeatureInput {
return Object.freeze({
async listResources(filters, context) {
const result = await gateway.list(filters, context);
return result.ok
? {
ok: true as const,
value: result.value.map((resource) => toReferenceView(resource)),
}
: result;
},
async createResource(command, context) {
const result = await gateway.create(command, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
async getResource(resourceId, context) {
const result = await gateway.get(resourceId, context);
return result.ok
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
});
}
@@ -0,0 +1,255 @@
import { z } from "zod";
import type {
CommandEffectDescriptor,
HttpRequestProjection,
InstalledContractContribution,
InstalledHttpContract,
RuntimeValidator,
} from "../../../contracts/external-contract-runtime.ts";
import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "./reference-schemas.ts";
/**
* §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference
* HTTP vertical executable as deterministic template data and is excluded from
* `contractSet`. A product feature MUST instead pin an external package and
* import it from its own `contracts/<service>-contract-contribution.ts`.
*/
function zodValidator<T>(
schemaId: string,
schema: z.ZodType<T>,
): RuntimeValidator<T> {
return Object.freeze({
schemaId,
safeParse(value: unknown) {
const result = schema.safeParse(value);
if (result.success) {
return Object.freeze({
success: true as const,
data: structuredClone(result.data),
});
}
return Object.freeze({
success: false as const,
issues: Object.freeze(
result.error.issues.map((issue) =>
Object.freeze({
path: Object.freeze(
issue.path.map((segment): string | number =>
typeof segment === "number" ? segment : String(segment),
),
),
code: String(issue.code),
}),
),
),
});
},
});
}
const referenceResourceDto = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().min(1).optional(),
})
.strip();
const problemSchema = z
.object({
type: z.string().min(1).max(512),
title: z.string().min(1).max(240),
status: z.int().min(100).max(599),
code: z.string().min(1).max(120).optional(),
})
.strip();
export type ReferenceProblem = z.output<typeof problemSchema>;
const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema);
const createCommandSchema = z
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict();
/**
* Fixture-side classifier standing in for the package-provided pure bounded
* classifier. A status the fixture does not describe stays `MAYBE_APPLIED`.
*/
const CREATE_EFFECT: CommandEffectDescriptor<ReferenceProblem> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) {
if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED";
return "MAYBE_APPLIED";
},
});
const LIST_REFERENCE_RESOURCES: InstalledHttpContract<
z.output<typeof referenceResourceListQuerySchema>,
readonly z.output<typeof referenceResourceDto>[],
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "LIST_REFERENCE_RESOURCES",
method: "GET" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"ReferenceResourceListQuery",
referenceResourceListQuerySchema,
),
outputValidator: zodValidator(
"ReferenceResourceListPayload",
z.array(referenceResourceDto).max(100),
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: z.output<typeof referenceResourceListQuerySchema>) {
const entries: (readonly [string, string])[] = [];
if (input.cursor !== undefined) entries.push(["cursor", input.cursor]);
entries.push(["limit", String(input.limit)]);
for (const tag of input.tags ?? []) entries.push(["tags", tag]);
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze(entries),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_LIST_V1",
requestByteLimit: 0,
responseByteLimit: 262_144,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.list",
}),
});
const GET_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof referenceResourceParamsSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "GET_REFERENCE_RESOURCE",
method: "GET" as const,
pathTemplate: "/api/reference-resources/{resourceId}",
inputValidator: zodValidator(
"ReferenceResourceParams",
referenceResourceParamsSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "SAFE" as const,
requestBody: "NONE" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: null,
commandEffect: null,
projectRequest(input: z.output<typeof referenceResourceParamsSchema>) {
return Object.freeze({
pathValues: Object.freeze({ resourceId: input.resourceId }),
queryEntries: Object.freeze([]),
body: null,
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_DETAIL_V1",
requestByteLimit: 0,
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
retryBudget: 2 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.detail",
}),
});
const CREATE_REFERENCE_RESOURCE: InstalledHttpContract<
z.output<typeof createCommandSchema>,
z.output<typeof referenceResourceDto>,
ReferenceProblem
> = Object.freeze({
contract: Object.freeze({
operationId: "CREATE_REFERENCE_RESOURCE",
method: "POST" as const,
pathTemplate: "/api/reference-resources",
inputValidator: zodValidator(
"CreateReferenceResourceCommand",
createCommandSchema,
),
outputValidator: zodValidator(
"ReferenceResourcePayload",
referenceResourceDto,
),
problemValidator: PROBLEM_VALIDATOR,
acceptedStatuses: Object.freeze([200, 201]),
emptyBodyStatuses: Object.freeze([]),
retrySemantics: "KEYED" as const,
requestBody: "JSON" as const,
responseBody: "REQUIRED_JSON" as const,
commandRecovery: Object.freeze({
mode: "IDEMPOTENCY_REPLAY" as const,
operationIdentityField: "idempotencyKey",
}),
commandEffect: CREATE_EFFECT,
projectRequest(input: z.output<typeof createCommandSchema>) {
return Object.freeze({
pathValues: Object.freeze({}),
queryEntries: Object.freeze([]),
body: Object.freeze({ ...input }),
});
},
}),
frontend: Object.freeze({
policyId: "REFERENCE_CREATE_V1",
requestByteLimit: 32_768,
responseByteLimit: 32_768,
totalDeadlineMs: 10_000,
/**
* §8.3. A KEYED command has no automatic retry after a dispatched attempt
* lost its response; the fixture therefore declares a zero budget rather
* than relying on a runtime special case.
*/
retryBudget: 0 as const,
authProfileId: "REFERENCE_EXTERNAL_BEARER",
diagnosticsOperation: "reference.create",
}),
});
export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution =
Object.freeze({
contributionId: "reference-feature-http-v1",
featureId: REFERENCE_FEATURE_ID,
source: Object.freeze({
kind: "TEMPLATE_FIXTURE" as const,
fixtureId: "REFERENCE_FEATURE_V1" as const,
revision: 1 as const,
}),
http: Object.freeze([
LIST_REFERENCE_RESOURCES,
GET_REFERENCE_RESOURCE,
CREATE_REFERENCE_RESOURCE,
]) as readonly InstalledHttpContract<unknown, unknown, unknown>[],
events: Object.freeze([]),
});
@@ -0,0 +1,257 @@
import { defineQueryNamespaceIdentity } 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";
export const REFERENCE_RESOURCE_QUERY_NAMESPACE = defineQueryNamespaceIdentity(
"reference-resource",
1,
);
export const REFERENCE_RESOURCE_INVALIDATION_TOPIC =
defineQueryInvalidationTopic("qinv.01k10f7m3w9p6r2c8v5n4x");
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({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
path: "/examples/reference-resources",
paramsSchema: null,
searchSchema: "ReferenceResourceListQuery",
access: "session-required",
loadingSurface: "reference-resource-list",
errorSurface: "feature-boundary",
chunkId: "route-reference-resources",
title: "Reference feature",
navigationLabel: "Reference feature",
navigationOrder: 50,
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
path: "/examples/reference-resources/:resourceId",
paramsSchema: "ReferenceResourceParams",
searchSchema: null,
access: "session-required",
loadingSurface: "reference-resource-detail",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-detail",
title: "Reference detail",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
path: "/examples/reference-resources/new",
paramsSchema: null,
searchSchema: null,
access: "session-required",
loadingSurface: "reference-resource-form",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-form",
title: "Reference form",
navigationLabel: null,
navigationOrder: null,
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
path: "/examples/reference-resources/status",
paramsSchema: null,
searchSchema: null,
access: "session-required",
loadingSurface: "reference-resource-status",
errorSurface: "feature-boundary",
chunkId: "route-reference-resource-status",
title: "Reference status",
navigationLabel: null,
navigationOrder: null,
}),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
routeId: "REFERENCE_RESOURCE_LIST",
moduleId: "reference-resource-page",
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
routeId: "REFERENCE_RESOURCE_DETAIL",
moduleId: "reference-resource-detail-page",
paramsCodec: "ReferenceResourceParams",
searchCodec: "none",
}),
REFERENCE_RESOURCE_FORM: Object.freeze({
routeId: "REFERENCE_RESOURCE_FORM",
moduleId: "reference-resource-form-page",
paramsCodec: "none",
searchCodec: "none",
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
routeId: "REFERENCE_RESOURCE_STATUS",
moduleId: "reference-resource-status-page",
paramsCodec: "none",
searchCodec: "none",
}),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: defineRestOperation({
method: "GET",
path: "/api/reference-resources",
operationId: "LIST_REFERENCE_RESOURCES",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "search",
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: defineRestOperation({
method: "POST",
path: "/api/reference-resources",
operationId: "CREATE_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "keyed",
retry: "runtime",
requestSource: "body",
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: defineRestOperation({
method: "GET",
path: "/api/reference-resources/{resourceId}",
operationId: "GET_REFERENCE_RESOURCE",
auth: "external-session",
timeoutMs: null,
idempotency: "safe",
retry: "runtime",
requestSource: "none",
requestSchema: "NoRequest",
responseSchema: "ReferenceResourcePayload",
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,
}),
}),
invalidation: Object.freeze({
topics: Object.freeze([REFERENCE_RESOURCE_INVALIDATION_TOPIC]),
namespaces: Object.freeze([REFERENCE_RESOURCE_QUERY_NAMESPACE]),
edges: Object.freeze([
Object.freeze({
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE,
}),
]),
}),
topicVersions: Object.freeze([
Object.freeze({
topicId: REFERENCE_RESOURCE_INVALIDATION_TOPIC,
topicVersion: 1,
}),
]),
} as const);
@@ -0,0 +1,97 @@
import {
createReferenceResource,
type ReferenceResource,
} from "../domain/reference-resource.ts";
import {
mappingFailure,
mappingSuccess,
type MappingResult,
type InstalledBoundaryMapper,
} from "../../../contracts/boundary-mapper.ts";
export type ReferenceResourceView = Readonly<{
resourceId: string;
title: string;
createdAt: string | null;
optimistic?: boolean;
}>;
function mapReferenceDto(value: unknown): MappingResult<ReferenceResource> {
if (!value || typeof value !== "object") {
return mappingFailure("MAPPING_INVARIANT_REJECTED");
}
const dto = value as Record<string, unknown>;
if (typeof dto.id !== "string" || typeof dto.name !== "string") {
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");
}
}
export function mapReferenceOperation(
operationId: string,
payload: unknown,
): MappingResult<ReferenceResource | readonly ReferenceResource[]> {
if (operationId === "LIST_REFERENCE_RESOURCES") {
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" ||
operationId === "GET_REFERENCE_RESOURCE"
) {
return mapReferenceDto(payload);
}
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 {
return Object.freeze({
resourceId: resource.id,
title: resource.displayName,
createdAt: resource.createdAt,
});
}
@@ -0,0 +1,22 @@
export const REFERENCE_MESSAGE_CATALOGS = Object.freeze({
"ko-KR": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
"en-US": Object.freeze({
"route.REFERENCE_RESOURCE_LIST.navigation": "Reference feature",
"route.REFERENCE_RESOURCE_LIST.title": "Reference resources",
"route.REFERENCE_RESOURCE_DETAIL.navigation": "Reference detail",
"route.REFERENCE_RESOURCE_DETAIL.title": "Reference resource detail",
"route.REFERENCE_RESOURCE_FORM.navigation": "Reference form",
"route.REFERENCE_RESOURCE_FORM.title": "Create reference resource",
"route.REFERENCE_RESOURCE_STATUS.navigation": "Reference status",
"route.REFERENCE_RESOURCE_STATUS.title": "Reference resource status",
}),
} as const);
@@ -0,0 +1,111 @@
import { z } from "zod";
import type { RuntimeSchemaCodec } from "../../../contracts/schema-registry.ts";
export const referenceResourceListQuerySchema = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z
.preprocess(
(value) =>
value === undefined
? undefined
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
)
.optional(),
})
.strict();
export const referenceResourceParamsSchema = z
.object({
resourceId: z.string().trim().min(1).max(120),
})
.strict();
const referenceResourceDtoSchema = z
.object({
id: z.string().min(1).max(120),
name: z.string().min(1).max(240),
createdAt: z.string().datetime().optional(),
})
.strip();
const payloadSchemas = {
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
.object({
name: z.string().trim().min(1).max(120),
note: z.string().trim().max(500).optional(),
})
.strict(),
} satisfies Record<string, z.ZodType>;
function project(result: z.ZodSafeParseResult<unknown>) {
return result.success
? ({ success: true as const, data: structuredClone(result.data) })
: ({
success: false as const,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
});
}
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
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
export function validateReferenceRequest(schemaId: string, value: unknown) {
const schema = requestSchemas[schemaId as keyof typeof requestSchemas];
return schema
? project(schema.safeParse(value))
: { success: false as const, issues: [{ path: "", code: "SCHEMA_NOT_REGISTERED" }] };
}
@@ -0,0 +1,14 @@
export type ReferenceResource = Readonly<{
id: string;
displayName: string;
createdAt: string | null;
}>;
export function createReferenceResource(
values: ReferenceResource,
): ReferenceResource {
if (!values.id || !values.displayName) {
throw new TypeError("Reference resource invariants require id and displayName");
}
return Object.freeze({ ...values });
}
@@ -0,0 +1,30 @@
import { lazy } from "react";
import {
referenceResourceListQuerySchema,
referenceResourceParamsSchema,
} from "../contracts/reference-schemas.ts";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
REFERENCE_RESOURCE_LIST: Object.freeze({
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.tsx")),
}),
REFERENCE_RESOURCE_DETAIL: Object.freeze({
moduleId: "reference-resource-detail-page",
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.tsx")),
}),
REFERENCE_RESOURCE_STATUS: Object.freeze({
moduleId: "reference-resource-status-page",
Component: lazy(() => import("./reference-resource-status-page.tsx")),
}),
} as const;
@@ -0,0 +1,61 @@
import { Link } from "react-router-dom";
import {
AsyncSurface,
DetailPage,
} 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();
const route = useRouteInput();
const resourceId = String(route.params.resourceId);
const { query } = useReferenceDetail(resourceId);
const resource = query.data;
const failureAction = useReferenceFailureAction(query.state.failure);
return (
<DetailPage
key={resourceId}
breadcrumb={
<Link to="/examples/reference-resources">Reference resources</Link>
}
heading={{
eyebrow: "DetailPage",
title: resource?.title ?? "Reference detail",
description: "route param과 detail query의 reset 경계를 확인합니다.",
}}
metadata={
resource ? (
<dl>
<dt>Resource ID</dt>
<dd>{resource.resourceId}</dd>
<dt>Created</dt>
<dd>
{resource.createdAt
? date(new Date(resource.createdAt))
: message("common.noDisplayValue")}
</dd>
</dl>
) : (
<p> .</p>
)
}
feedback={
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
{resource ? (
<p> section을 .</p>
) : null}
</AsyncSurface>
}
aside={<p> slot입니다.</p>}
/>
);
}
@@ -0,0 +1,158 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import {
Button,
AsyncSurface,
DirtyNavigationDialog,
ErrorSummary,
Form,
FormActions,
FormPage,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../../presentation/design-system/index.ts";
import {
REFERENCE_FORM_DEFAULTS,
referenceResourceFormSchema,
toCreateReferenceCommand,
type ReferenceResourceFormValues,
} from "./reference-resource-form.ts";
import { useReferenceCreate } from "./use-reference-feature.ts";
const FIELD_LABELS = Object.freeze({
name: "새 항목 이름",
note: "설명",
}) satisfies Record<keyof ReferenceResourceFormValues, string>;
export default function ReferenceResourceFormPage() {
const navigate = useNavigate();
const mutation = useReferenceCreate();
const submit = useCallback(
(command: ReturnType<typeof toCreateReferenceCommand>) =>
mutation.submit(command),
[mutation],
);
const form = useAppForm({
schema: referenceResourceFormSchema,
defaultValues: REFERENCE_FORM_DEFAULTS,
allowedServerFields: ["name", "note"],
mapToCommand: toCreateReferenceCommand,
submit,
});
const mutationEffectUnknown =
mutation.state.indicator === "mutation-effect-unknown";
const mutationBlocked =
mutationEffectUnknown || mutation.state.indicator === "mutation-pending";
const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
return (
<Form
id={form.formId}
pending={form.pending}
onSubmit={(event) => {
if (mutationBlocked) {
event.preventDefault();
return;
}
void form.submitForm(event);
}}
>
<FormPage
breadcrumb={
<button
className="ui-button ui-button--ghost"
type="button"
onClick={() => navigate("/examples/reference-resources")}
>
</button>
}
heading={{
eyebrow: "FormPage",
title: "Reference resource 만들기",
description:
"presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.",
}}
errorSummary={
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={FIELD_LABELS}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
}
fields={
<>
<FormField
{...form.field("name")}
label={FIELD_LABELS.name}
description="앞뒤 공백은 command mapper 전에 제거됩니다."
autoComplete="off"
required
/>
<FormField
{...form.field("note")}
label={FIELD_LABELS.note}
description="선택 입력이며 비어 있으면 command에 포함되지 않습니다."
autoComplete="off"
/>
</>
}
formActions={
<FormActions sticky>
<Button
variant="secondary"
onClick={() => navigate("/examples/reference-resources")}
disabled={form.pending || mutationBlocked}
>
</Button>
<Button
type="submit"
disabled={form.pending || mutationBlocked}
>
{form.pending ? "저장 중…" : "저장"}
</Button>
<Button
variant="ghost"
onClick={() => form.reset()}
disabled={!form.dirty || form.pending || mutationBlocked}
>
</Button>
</FormActions>
}
feedback={
mutationEffectUnknown ? (
<AsyncSurface
state={mutation.state}
onReconcileUnknownEffect={(resolution) => {
void mutation.reconcileUnknownEffect(resolution).then(() => {
if (resolution === "APPLIED") {
form.settleApplied();
} else {
form.settleNotApplied();
}
});
}}
/>
) : form.result === "success" ? (
<p role="status">.</p>
) : form.result === "conflict" ? (
<p role="status"> .</p>
) : null
}
aside={
<p>
form value는 URL, storage, telemetry에 submit
application command로 .
</p>
}
guard={<DirtyNavigationDialog guard={guard} />}
/>
</Form>
);
}
@@ -0,0 +1,29 @@
import { z } from "zod";
export const referenceResourceFormSchema = z
.object({
name: z
.string()
.trim()
.min(2, "이름은 두 글자 이상이어야 합니다.")
.max(120),
note: z.string().trim().max(500).default(""),
})
.strict();
export type ReferenceResourceFormValues = z.infer<
typeof referenceResourceFormSchema
>;
export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
Object.freeze({
name: "",
note: "",
});
export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
return Object.freeze({
name: values.name,
...(values.note ? { note: values.note } : {}),
});
}
@@ -0,0 +1,61 @@
import { Link, useNavigate } from "react-router-dom";
import {
AsyncSurface,
Button,
CollectionPage,
} 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
heading={{
eyebrow: "제거 가능한 수직 슬라이스",
title: "Reference feature",
description:
"URL codec, application input, HTTP/schema/mapper와 query 상태를 한 경로로 검증합니다.",
}}
actions={[
{
kind: "button",
label: "새 항목 만들기",
onAction: () => navigate("/examples/reference-resources/new"),
},
]}
activeFilters={
<p data-testid="reference-filter">
limit {filters.limit}
{filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
</p>
}
toolbar={<Button onClick={() => void query.retry()}></Button>}
resultCount={
query.data ? `${query.data.length}개 항목` : "결과 확인 중"
}
>
<AsyncSurface
state={query.state}
onAction={failureAction}
onRetry={query.retry}
>
<ul aria-label="Reference resources">
{(query.data ?? []).map((resource) => (
<li key={resource.resourceId}>
<Link
to={`/examples/reference-resources/${encodeURIComponent(resource.resourceId)}`}
>
{resource.title}
</Link>
</li>
))}
</ul>
</AsyncSurface>
</CollectionPage>
);
}
@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
import { StatusPage } from "../../../presentation/design-system/index.ts";
export default function ReferenceResourceStatusPage() {
const navigate = useNavigate();
return (
<StatusPage
variant="maintenance"
heading={{
eyebrow: "StatusPage · maintenance",
title: "잠시 사용할 수 없습니다.",
description:
"도메인 데이터나 raw 오류를 노출하지 않는 중립적인 상태 페이지 예시입니다.",
}}
primaryAction={{
kind: "button",
label: "목록으로 이동",
onAction: () => navigate("/examples/reference-resources"),
}}
supportReference="REFERENCE-STATUS-DEMO"
/>
);
}
@@ -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;
}
@@ -0,0 +1,138 @@
import { useApplication } from "../../../presentation/providers/application-provider.tsx";
import {
useApplicationMutation,
useApplicationQuery,
} from "../../../presentation/adapters/query/application-query.ts";
import { useRouteInput } from "../../../presentation/routes/route-input.tsx";
import {
REFERENCE_FEATURE_ID,
REFERENCE_RESOURCE_INVALIDATION_TOPIC,
REFERENCE_RESOURCE_QUERY_NAMESPACE,
} from "../contracts/reference-feature-contract.ts";
import type {
ReferenceCreateCommand,
ReferenceFeatureInput,
ReferenceListFilters,
} from "../application/reference-feature-api.ts";
import type { ReferenceResourceView } from "../contracts/reference-mapper.ts";
import {
bindQuery,
type BoundMutation,
type QueryResultMeasure,
} from "../../../contracts/server-state.ts";
import { useServerStateScope } from "../../../presentation/adapters/query/server-state-scope-provider.tsx";
const UTF8 = new TextEncoder();
/**
* §10.4. Feature-owned measurement over the mapped application value. There is
* no generic fallback: bounded string bytes plus fixed primitive width plus a
* small per-item overhead, never `JSON.stringify` or a recursive walker.
*/
function measureResourceView(view: ReferenceResourceView): QueryResultMeasure {
return {
itemCount: 1,
estimatedBytes:
UTF8.encode(view.resourceId).byteLength +
UTF8.encode(view.title).byteLength +
UTF8.encode(view.createdAt ?? "").byteLength +
32,
};
}
function measureResourceList(
views: readonly ReferenceResourceView[],
): QueryResultMeasure {
let estimatedBytes = 16;
for (const view of views) {
estimatedBytes += measureResourceView(view).estimatedBytes;
}
return { itemCount: views.length, estimatedBytes };
}
export function useReferenceFeatureInput(): ReferenceFeatureInput {
return useApplication().features.get(REFERENCE_FEATURE_ID);
}
export function useReferenceDetail(resourceId: string) {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-detail-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
namespaceVersion:
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
operationId: "GET_REFERENCE_RESOURCE",
profileId: "DETAIL_STANDARD",
measureResult: measureResourceView,
execute: (selectedResourceId: string, { signal }) =>
input.getResource(selectedResourceId, { signal }),
},
resourceId,
scope,
),
);
return Object.freeze({ query });
}
export function useReferenceCreate() {
const input = useReferenceFeatureInput();
const scope = useServerStateScope();
const mutation: BoundMutation<
ReferenceCreateCommand,
ReferenceResourceView
> = {
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
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 query = useApplicationQuery(
bindQuery(
{
definitionId: "reference-resource-list-v1",
definitionVersion: 1,
owner: REFERENCE_FEATURE_ID,
namespace: REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceId,
namespaceVersion:
REFERENCE_RESOURCE_QUERY_NAMESPACE.namespaceVersion,
operationId: "LIST_REFERENCE_RESOURCES",
profileId: "LIST_STANDARD",
measureResult: measureResourceList,
execute: (selectedFilters: ReferenceListFilters, { signal }) =>
input.listResources(selectedFilters, { signal }),
},
filters,
scope,
),
);
const mutation = useApplicationMutation({
definitionId: "reference-resource-create-v1",
definitionVersion: 1,
operationId: "CREATE_REFERENCE_RESOURCE",
requiresIdempotencyKey: true,
owner: REFERENCE_FEATURE_ID,
duplicatePolicy: "REJECT_WHILE_ACTIVE",
scope,
execute: input.createResource,
invalidate: [REFERENCE_RESOURCE_INVALIDATION_TOPIC],
});
return Object.freeze({ filters, query, mutation });
}