chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user