From ad55e21a3de6327fd90e42423e374309032ac4ee Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sun, 26 Jul 2026 14:05:12 +0900 Subject: [PATCH] feat: execute HTTP and query runtime contracts --- .dependency-cruiser.cjs | 2 +- config/ci/gates.json | 3 +- config/contracts/registry-governance.json | 4 +- .../frontend-platform-capability-review.md | 68 +++-- .../frontend-ports-adapters-and-boundaries.md | 21 +- .../typescript-state-and-data-flow.md | 14 + .../frontend-platform-testing-strategy.md | 12 +- eslint.config.js | 14 + package.json | 1 + src/adapters/http/client.js | 141 +++++++--- src/adapters/http/request-builder.ts | 73 ++++++ src/adapters/http/retry-policy.js | 3 +- src/adapters/http/schema-registry.js | 1 + src/adapters/platform/system-clock.js | 6 +- src/application/view-models/async-state.js | 81 ------ src/application/view-models/async-state.ts | 156 +++++++++++ src/bootstrap/runtime-adapters.js | 25 ++ src/contracts/api-operations.js | 12 +- .../adapters/query/application-query.ts | 194 ++++++++++++++ src/presentation/adapters/query/index.ts | 5 + src/presentation/components/async-surface.jsx | 32 ++- tests/component/application-query.test.jsx | 218 ++++++++++++++++ tests/component/async-surface.test.jsx | 44 +++- .../presentation-imports-tanstack.tsx | 3 + .../typecheck/invalid-async-overlay.ts | 8 + .../http-execution-contract.test.js | 244 ++++++++++++++++++ tests/unit/request-builder.test.ts | 54 ++++ tests/unit/runtime-adapters.test.js | 65 ++++- tests/unit/system-clock.test.js | 7 +- 29 files changed, 1326 insertions(+), 185 deletions(-) create mode 100644 src/adapters/http/request-builder.ts delete mode 100644 src/application/view-models/async-state.js create mode 100644 src/application/view-models/async-state.ts create mode 100644 src/presentation/adapters/query/application-query.ts create mode 100644 src/presentation/adapters/query/index.ts create mode 100644 tests/component/application-query.test.jsx create mode 100644 tests/fixtures/architecture/forbidden/presentation-imports-tanstack.tsx create mode 100644 tests/fixtures/typecheck/invalid-async-overlay.ts create mode 100644 tests/integration/http-execution-contract.test.js create mode 100644 tests/unit/request-builder.test.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 5197aa3..b1062d3 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -20,7 +20,7 @@ module.exports = { { name: "presentation-does-not-know-adapters", severity: "error", - from: { path: "^src/presentation" }, + from: { path: "^src/presentation/(?!adapters/query)" }, to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, }, { diff --git a/config/ci/gates.json b/config/ci/gates.json index 3b4ecb5..99afbf2 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -78,7 +78,8 @@ { "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:application-output", "expect": "fail" }, - { "script": "check:types:fixture:application-input", "expect": "fail" } + { "script": "check:types:fixture:application-input", "expect": "fail" }, + { "script": "check:types:fixture:async-overlay", "expect": "fail" } ], "logPath": "artifacts/quality/check-types.txt", "evidence": ["artifacts/quality/check-types.txt"], diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json index d0f24be..84d1480 100644 --- a/config/contracts/registry-governance.json +++ b/config/contracts/registry-governance.json @@ -29,6 +29,8 @@ "auth", "timeoutMs", "idempotency", + "retry", + "requestSource", "requestSchema", "responseSchema", "owner" @@ -111,6 +113,6 @@ ], "compatibilityImpact": { "allowed": ["none", "additive", "behavior-change", "breaking"], - "current": "additive" + "current": "behavior-change" } } diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md index bbf50d2..88a4041 100644 --- a/docs/architecture/frontend-platform-capability-review.md +++ b/docs/architecture/frontend-platform-capability-review.md @@ -33,13 +33,13 @@ 특히 다음은 선행 해결이 필요하다. -1. React 화면이 호출할 application input API와 런타임 주입 경계 -2. TanStack Query를 사용하는 표준 query/mutation inbound adapter -3. TypeScript 전환 전에 TS 파일까지 검사하도록 만드는 도구 안전망 -4. path/query/body projection과 runtime timeout/retry가 정확히 연결된 HTTP 계층 -5. 선언과 실행이 일치하는 typed route 계약 -6. 전체를 제거할 수 있는 실제 reference feature -7. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 +RP-01~RP-03에서 TypeScript 도구 안전망, application runtime 주입, +query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선행 해결 +대상은 다음과 같다. + +1. 선언과 실행이 일치하는 typed route 계약 +2. 전체를 제거할 수 있는 실제 reference feature +3. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 더 정확한 표현은 다음과 같다. @@ -63,13 +63,13 @@ | --- | --- | --- | --- | | 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | -| application facade | 부분 준비 | `create-application.js`는 있으나 `main.jsx`에서 우회 | UI에는 input API만 주입 | -| HTTP client | 부분 준비 | timeout, abort, retry, auth, envelope, Zod가 존재 | path/query, parsed body, runtime 설정, 정리 로직 보완 | -| retry | 부분 준비 | safe/keyed 요청 정책 존재 | retry 소유자 단일화, 설정 연결, telemetry | +| application facade | 준비됨 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 | +| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 | +| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 | | 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | -| 검증 | 부분 준비 | runtime/API Zod 존재 | route/form/domain 경계를 분리하고 실제 parse 결과 사용 | +| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 | | 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 | -| 서버 상태 | 미제공에 가까운 부분 준비 | QueryClientProvider와 cache port는 존재 | query/mutation hook과 화면 reference flow | +| 서버 상태 | 부분 준비 | 제한된 query/mutation bridge와 lifecycle test | RP-05 reference route에서 실제 feature 연결 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 | @@ -82,7 +82,7 @@ | 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | -| 비동기 상태 불변식 | 부분 준비 | 공통 model/surface는 있으나 일부 모순 상태를 허용 | query/mutation 상태 조합과 action latch를 닫음 | +| 비동기 상태 불변식 | 준비됨 | 배타적 typed overlay, stale latch, 실제 retry/conflict action | reference 화면에서 전체 상태 전시 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | | 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 | @@ -94,13 +94,12 @@ ### 5.1 P0: 기능 개발을 막는 항목 -#### application 계층이 런타임에서 우회된다 +#### RP-02에서 application 런타임 우회 해결 -`src/bootstrap/composition-root.js`는 application을 생성하지만 -`src/bootstrap/main.jsx`는 이를 라우터에 주입하지 않는다. UI에는 auth, storage, -telemetry 같은 raw outbound dependency와 concrete QueryClient가 전달된다. -`src/application/create-application.js`도 use case 중심 input API보다 outbound -capability를 다시 노출하는 형태다. +`src/bootstrap/composition-root.js`가 만든 typed application input API는 +production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와 +release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime +query만 사용한다. 목표 상태: @@ -110,12 +109,13 @@ capability를 다시 노출하는 형태다. - bootstrap만 concrete outbound adapter를 알고 조합한다. - 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다. -#### 서버 상태 라이브러리는 마운트됐지만 사용할 수 없다 +#### RP-03에서 표준 서버 상태 bridge 구현 -`QueryClientProvider`는 존재하지만 저장소의 제품 코드에서 `useQuery`와 -`useMutation`을 사용하지 않는다. 동시에 presentation의 `@tanstack/**` import는 -금지돼 있다. 현재 `QueryCachePort`는 명령형 read/write/invalidate만 제공하여 -React 구독, 요청 상태, cancellation, optimistic update를 대신할 수 없다. +`src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다. +`useApplicationQuery`와 `useApplicationMutation`은 application result를 React +lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic +rollback, conflict resolution과 invalidation을 검증한다. 다른 presentation +경로의 직접 TanStack import는 negative fixture가 거절한다. 목표 상태: @@ -128,7 +128,7 @@ React 구독, 요청 상태, cancellation, optimistic update를 대신할 수 - loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을 reference feature에서 보여 준다. -#### TypeScript 전환 전에 검사 도구가 TS를 인식해야 한다 +#### RP-01에서 TypeScript 검사 도구 안전망 구현 현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은 중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다. @@ -143,18 +143,14 @@ TypeScript 전환은 [TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) 처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다. -#### HTTP 계약에 선언과 실행의 차이가 있다 +#### RP-03에서 HTTP 선언과 실행의 차이 해결 -현재 HTTP 계층은 공통화 수준이 높지만 다음 정확성 문제가 남아 있다. - -- runtime config의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`가 client 생성에 - 전달되지 않는다. -- operation path에 path parameter와 search parameter를 투영하는 표준 builder가 - 없다. -- sample filter는 cache key에만 반영되고 실제 요청 URL에는 반영되지 않는다. -- Zod의 parsed/transformed request body 대신 원본 body를 전송한다. -- request validation의 조기 반환 경로에서 timeout/listener 정리가 늦어진다. -- HTTP failure, retry, recovery가 telemetry 사건과 이어지지 않는다. +HTTP request builder는 path segment escaping, canonical optional/array search, +Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과 +0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed +failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema +failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을 +검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다. client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth, timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md index 068bb7b..9f4e58d 100644 --- a/docs/architecture/frontend-ports-adapters-and-boundaries.md +++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md @@ -141,17 +141,26 @@ RP-02 구현으로 다음 경계는 실행 경로에 연결됐다. - presentation의 direct fetch/browser storage/concrete adapter/TanStack import와 application의 React/concrete adapter import는 negative fixture가 거절한다. +RP-03 구현으로 HTTP와 server-state 경계도 다음처럼 연결됐다. + +- `src/presentation/adapters/query`만 TanStack Query import를 허용하며 + application query/mutation을 cancellation, invalidation, deduplication, + optimistic rollback과 conflict 해제에 연결한다. +- HTTP request builder는 path escaping과 canonical search를 소유하고 Zod가 + 변환한 search/body를 실제 request에 사용한다. +- runtime timeout과 max retry attempts가 transport factory에 주입되며 + validation, success, abort, timeout과 exhausted retry의 timer/listener + 정리를 테스트한다. +- HTTP가 자동 network retry를 소유하고 query/mutation adapter의 vendor retry는 + 비활성화한다. + 후속 브랜치에서 닫아야 할 실행 불일치는 다음과 같다. -1. `QueryClientProvider`는 존재하지만 실제 product route에서 - `useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다. -2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, +1. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`, `errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. -3. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API +2. 제거 테스트는 `src/sample/contract-fixture`만 제거하며, sample API operation, Zod schema, mapper, domain model과 query key는 다른 경로에 남는다. -4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만 - concrete HTTP client 구성에 전달되지 않는다. 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 불일치를 제거하는 것이다. diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md index 76972f3..8696445 100644 --- a/docs/architecture/typescript-state-and-data-flow.md +++ b/docs/architecture/typescript-state-and-data-flow.md @@ -387,6 +387,20 @@ retry 조건: timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 경로에서 정리되어야 한다. +RP-03의 현재 구현은 다음 계약을 자동 검증한다. + +- `request-builder.ts`가 path 값을 escape하고 search key를 정렬하며 array 순서를 + 보존한다. +- operation의 `requestSource`가 search/body schema를 선택하고 Zod의 + default/trim 결과만 URL 또는 JSON payload에 전달한다. +- runtime `REQUEST_TIMEOUT_MS`와 `MAX_RETRY_ATTEMPTS`가 transport factory에 + 주입된다. +- query adapter의 자동 retry는 끄고 HTTP만 bounded network retry를 소유한다. +- `AsyncOverlay`는 refreshing/stale-degraded/mutation-pending/ + mutation-conflict를 TypeScript union으로 배타화한다. +- `useApplicationQuery`와 `useApplicationMutation`은 cancellation, stale latch, + duplicate submit, optimistic rollback, conflict resolution을 제공한다. + ## 6. 인증과 token 소유권 기본 skeleton은 token manager를 제공하지 않는다. diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index 5885924..babb4a5 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -91,12 +91,14 @@ Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않 - boot error shell의 safe metadata - StrictMode와 unmount cleanup -#### TanStack Query의 React integration test가 없다 +#### TanStack Query의 React integration test 기반 -Query cache adapter의 명령형 `read/write/invalidate` unit test는 있지만 -`useQuery`, `useMutation`, cancellation, stale/background refresh, optimistic -rollback을 사용하는 production presentation adapter가 아직 없다. 따라서 query -provider가 마운트되어도 React 사용자의 실제 상태 전환은 검증되지 않는다. +`tests/component/application-query.test.jsx`는 production query inbound +adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal +failure, background stale-failure latch와 retry 복구, duplicate submit, +optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제 +QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의 +query/mutation vendor retry는 꺼져 있다. #### Form 테스트가 단일 TextField 흐름에 머문다 diff --git a/eslint.config.js b/eslint.config.js index 06a35f4..ac30084 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -176,6 +176,20 @@ export default [ ], }, }, + { + files: [ + `src/presentation/adapters/query/**/*.${sourceExtensions}`, + ], + rules: { + "no-restricted-imports": restrictedImports([ + "**/adapters/http/**", + "**/adapters/storage/**", + "**/adapters/auth/**", + "**/bootstrap/**", + "**/application/ports/out/**", + ]), + }, + }, { files: [`src/adapters/**/*.${sourceExtensions}`], rules: { diff --git a/package.json b/package.json index 00d5bbf..c152736 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", "check:types:fixture:application-output": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", "check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", + "check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", "test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml", diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 094b2ca..46abc59 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -12,6 +12,7 @@ import { validateOperationPayload, validateOperationRequest, } from "./schema-registry.js"; +import { buildRequestTarget } from "./request-builder.js"; const noAuthSession = /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ @@ -22,6 +23,14 @@ const noAuthSession = }); /** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */ +/** @typedef {import("./request-builder.js").OperationRequestInput} OperationRequestInput */ + +/** + * @typedef {{ + * setTimeout(callback: () => void, milliseconds: number): unknown, + * clearTimeout(handle: unknown): void + * }} Scheduler + */ /** * @typedef {{ ok: true, value: unknown, meta: Record } | @@ -38,7 +47,11 @@ const noAuthSession = * validatePayload?: (schemaId: string, value: unknown) => * { success: true, data: unknown } | { success: false }, * mapPayload?: (operationId: string, payload: unknown) => unknown, - * idempotencyKeyFactory?: () => string + * idempotencyKeyFactory?: () => string, + * timeoutMs?: number, + * maxRetryAttempts?: number, + * scheduler?: Scheduler, + * getOperation?: typeof getApiOperation * }} dependencies */ export function createHttpClient(dependencies) { @@ -51,19 +64,46 @@ export function createHttpClient(dependencies) { const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const idempotencyKeyFactory = dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); + const defaultTimeoutMs = dependencies.timeoutMs ?? 10_000; + const maxRetryAttempts = dependencies.maxRetryAttempts ?? 2; + const selectOperation = dependencies.getOperation ?? getApiOperation; + const scheduler = + dependencies.scheduler ?? + /** @type {Scheduler} */ ({ + setTimeout: (callback, milliseconds) => + globalThis.setTimeout(callback, milliseconds), + clearTimeout: (handle) => + globalThis.clearTimeout( + /** @type {ReturnType} */ (handle), + ), + }); /** - * @param {string} operationId + * @param {string | OperationRequestInput} request * @param {{ - * body?: unknown, - * routeId?: string, - * signal?: AbortSignal, - * idempotencyKey?: string - * }} [input] - * @returns {Promise} - */ - async function execute(operationId, input = {}) { - const operation = getApiOperation(operationId); + * body?: unknown, + * routeId?: string, + * pathParams?: Record, + * searchParams?: unknown, + * signal?: AbortSignal, + * idempotencyKey?: string + * }} [legacyInput] + * @returns {Promise} + */ + async function execute(request, legacyInput = {}) { + const input = + typeof request === "string" + ? { + operationId: request, + routeId: legacyInput.routeId ?? "UNSPECIFIED_ROUTE", + pathParams: legacyInput.pathParams, + searchParams: legacyInput.searchParams, + body: legacyInput.body, + signal: legacyInput.signal, + idempotencyKey: legacyInput.idempotencyKey, + } + : request; + const operation = selectOperation(input.operationId); const logicalIdempotencyKey = operation.idempotency === "keyed" ? input.idempotencyKey ?? idempotencyKeyFactory() @@ -105,7 +145,14 @@ export function createHttpClient(dependencies) { return outcome; } - if (!shouldRetry(operation, outcome.error, retryCount)) { + if ( + !shouldRetry( + operation, + outcome.error, + retryCount, + maxRetryAttempts, + ) + ) { return outcome; } @@ -117,7 +164,7 @@ export function createHttpClient(dependencies) { } catch { return { ok: false, - error: failure("REQUEST_ABORTED", operationId, retryCount, { + error: failure("REQUEST_ABORTED", input.operationId, retryCount, { code: "REQUEST_ABORTED", }), }; @@ -128,7 +175,7 @@ export function createHttpClient(dependencies) { /** * @param {{ * operation: ReturnType, - * input: { body?: unknown, routeId?: string, signal?: AbortSignal }, + * input: OperationRequestInput, * attempt: number, * idempotencyKey?: string * }} context @@ -136,23 +183,19 @@ export function createHttpClient(dependencies) { */ async function performAttempt(context) { const { operation, input, attempt, idempotencyKey } = context; - const controller = new AbortController(); - let timedOut = false; - const timeout = setTimeout(() => { - timedOut = true; - controller.abort("timeout"); - }, operation.timeoutMs); - const onExternalAbort = () => controller.abort(input.signal?.reason); - input.signal?.addEventListener("abort", onExternalAbort, { once: true }); - - const headers = new Headers({ Accept: "application/json" }); - if (input.body !== undefined) headers.set("Content-Type", "application/json"); - if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); - - if (input.body !== undefined) { + /** @type {unknown} */ + let parsedSearch = {}; + let parsedBody; + const requestValue = + operation.requestSource === "search" + ? input.searchParams ?? {} + : operation.requestSource === "body" + ? input.body + : {}; + if (operation.requestSource !== "none") { const requestValidation = validateOperationRequest( operation.requestSchema, - input.body, + requestValue, ); if (!requestValidation.success) { return { @@ -162,12 +205,46 @@ export function createHttpClient(dependencies) { }), }; } + if (operation.requestSource === "search") { + parsedSearch = requestValidation.data; + } else { + parsedBody = requestValidation.data; + } } - let request = new Request(new URL(operation.path, dependencies.baseUrl), { + const target = buildRequestTarget( + dependencies.baseUrl, + operation, + input.pathParams, + parsedSearch, + ); + if (!target.success) { + return { + ok: false, + error: failure("VALIDATION_REJECTED", operation.operationId, attempt, { + code: target.code, + }), + }; + } + + const controller = new AbortController(); + let timedOut = false; + const timeout = scheduler.setTimeout(() => { + timedOut = true; + controller.abort("timeout"); + }, operation.timeoutMs ?? defaultTimeoutMs); + const onExternalAbort = () => controller.abort(input.signal?.reason); + input.signal?.addEventListener("abort", onExternalAbort, { once: true }); + if (input.signal?.aborted) onExternalAbort(); + + const headers = new Headers({ Accept: "application/json" }); + if (parsedBody !== undefined) headers.set("Content-Type", "application/json"); + if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey); + + let request = new Request(target.url, { method: operation.method, headers, - body: input.body === undefined ? undefined : JSON.stringify(input.body), + body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody), signal: controller.signal, }); @@ -237,7 +314,7 @@ export function createHttpClient(dependencies) { }), }; } finally { - clearTimeout(timeout); + scheduler.clearTimeout(timeout); input.signal?.removeEventListener("abort", onExternalAbort); } } diff --git a/src/adapters/http/request-builder.ts b/src/adapters/http/request-builder.ts new file mode 100644 index 0000000..76ef2b0 --- /dev/null +++ b/src/adapters/http/request-builder.ts @@ -0,0 +1,73 @@ +import type { ApiOperation } from "../../contracts/api-operations.js"; + +export type OperationRequestInput = Readonly<{ + operationId: string; + routeId: string; + pathParams?: Readonly>; + searchParams?: unknown; + body?: unknown; + signal?: AbortSignal; + idempotencyKey?: string; +}>; + +export type RequestTargetResult = + | Readonly<{ success: true; url: URL }> + | Readonly<{ + success: false; + code: "PATH_PARAMETER_MISSING" | "SEARCH_PARAMETER_INVALID"; + }>; + +const pathParameterPattern = /:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g; + +export function buildRequestTarget( + baseUrl: string, + operation: ApiOperation, + pathParams: Readonly> = {}, + parsedSearch: unknown = {}, +): RequestTargetResult { + let missingPathParameter = false; + const pathname = operation.path.replace( + pathParameterPattern, + (_token, colonName: string | undefined, braceName: string | undefined) => { + const name = colonName ?? braceName ?? ""; + const value = pathParams[name]; + if (value === undefined) { + missingPathParameter = true; + return ""; + } + return encodeURIComponent(String(value)); + }, + ); + if (missingPathParameter) { + return { success: false, code: "PATH_PARAMETER_MISSING" }; + } + + if ( + parsedSearch === null || + typeof parsedSearch !== "object" || + Array.isArray(parsedSearch) + ) { + return { success: false, code: "SEARCH_PARAMETER_INVALID" }; + } + + const url = new URL(pathname, baseUrl); + const search = parsedSearch as Readonly>; + for (const key of Object.keys(search).sort((left, right) => + left.localeCompare(right), + )) { + const value = search[key]; + if (value === undefined || value === null) continue; + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + if ( + typeof item !== "string" && + typeof item !== "number" && + typeof item !== "boolean" + ) { + return { success: false, code: "SEARCH_PARAMETER_INVALID" }; + } + url.searchParams.append(key, String(item)); + } + } + return { success: true, url }; +} diff --git a/src/adapters/http/retry-policy.js b/src/adapters/http/retry-policy.js index ecf787e..e89207e 100644 --- a/src/adapters/http/retry-policy.js +++ b/src/adapters/http/retry-policy.js @@ -35,12 +35,13 @@ export function parseRetryAfter(value, now = Date.now()) { } /** - * @param {{ idempotency: "safe" | "keyed" | "none" }} operation + * @param {{ idempotency: "safe" | "keyed" | "none", retry?: "runtime" | "never" }} operation * @param {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure * @param {number} retryCount * @param {number} [maxRetries] */ export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { + if (operation.retry === "never") return false; if (retryCount >= maxRetries) return false; if (!retryKinds.has(failure.kind)) return false; if ( diff --git a/src/adapters/http/schema-registry.js b/src/adapters/http/schema-registry.js index bc2ca78..4ec4af3 100644 --- a/src/adapters/http/schema-registry.js +++ b/src/adapters/http/schema-registry.js @@ -57,6 +57,7 @@ const requestSchemas = .object({ cursor: z.string().optional(), limit: z.int().min(1).max(100).default(20), + tags: z.array(z.string().trim().min(1)).optional(), }) .strict(), CreateSampleResourceCommand: z diff --git a/src/adapters/platform/system-clock.js b/src/adapters/platform/system-clock.js index 62f45cd..dbcb0df 100644 --- a/src/adapters/platform/system-clock.js +++ b/src/adapters/platform/system-clock.js @@ -8,9 +8,13 @@ export const systemClock = Object.freeze({ return; } - const timer = setTimeout(resolve, milliseconds); + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); const onAbort = () => { clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); reject(signal?.reason); }; signal?.addEventListener("abort", onAbort, { once: true }); diff --git a/src/application/view-models/async-state.js b/src/application/view-models/async-state.js deleted file mode 100644 index 4d6db8c..0000000 --- a/src/application/view-models/async-state.js +++ /dev/null @@ -1,81 +0,0 @@ -export const ASYNC_BASE_STATES = Object.freeze([ - "initial-loading", - "success", - "empty", - "terminal-error", -]); - -export const ASYNC_OVERLAYS = Object.freeze([ - "refreshing", - "stale-degraded", - "mutation-pending", - "mutation-conflict", -]); - -/** - * @typedef {{ - * data?: unknown, - * isInitialLoading?: boolean, - * failure?: import("../../contracts/errors.js").ApiFailure, - * isFetching?: boolean, - * isStale?: boolean, - * isDegraded?: boolean, - * isMutationPending?: boolean, - * hasMutationConflict?: boolean - * }} AsyncSignals - */ - -/** @param {AsyncSignals} signals */ -export function deriveAsyncState(signals) { - const hasData = signals.data !== undefined && signals.data !== null; - const empty = - hasData && - ((Array.isArray(signals.data) && signals.data.length === 0) || - signals.data === ""); - - let base; - if (signals.isInitialLoading && !hasData) { - base = "initial-loading"; - } else if (signals.failure && !hasData) { - base = "terminal-error"; - } else if (empty) { - base = "empty"; - } else if (hasData) { - base = "success"; - } else { - base = "initial-loading"; - } - - const overlay = Object.freeze({ - refreshing: Boolean(signals.isFetching && hasData), - staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData), - mutationPending: Boolean(signals.isMutationPending && hasData), - mutationConflict: Boolean(signals.hasMutationConflict && hasData), - }); - - const state = { - base, - data: base === "success" || base === "empty" ? signals.data : undefined, - failure: base === "terminal-error" ? signals.failure : undefined, - overlay, - indicator: selectOverlayIndicator(overlay), - }; - - return Object.freeze(state); -} - -/** - * @param {{ - * refreshing: boolean, - * staleDegraded: boolean, - * mutationPending: boolean, - * mutationConflict: boolean - * }} overlay - */ -export function selectOverlayIndicator(overlay) { - if (overlay.mutationConflict) return "mutation-conflict"; - if (overlay.mutationPending) return "mutation-pending"; - if (overlay.staleDegraded) return "stale-degraded"; - if (overlay.refreshing) return "refreshing"; - return null; -} diff --git a/src/application/view-models/async-state.ts b/src/application/view-models/async-state.ts new file mode 100644 index 0000000..74e689d --- /dev/null +++ b/src/application/view-models/async-state.ts @@ -0,0 +1,156 @@ +import type { ApiFailure } from "../../contracts/errors.js"; + +export const ASYNC_BASE_STATES = Object.freeze([ + "initial-loading", + "success", + "empty", + "terminal-error", +] as const); + +export const ASYNC_OVERLAYS = Object.freeze([ + "refreshing", + "stale-degraded", + "mutation-pending", + "mutation-conflict", +] as const); + +export type AsyncOverlay = + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: true; + staleDegraded: false; + mutationPending: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: true; + mutationPending: false; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: true; + mutationConflict: false; + }> + | Readonly<{ + refreshing: false; + staleDegraded: false; + mutationPending: false; + mutationConflict: true; + }>; + +export type AsyncSignals = Readonly<{ + data?: unknown; + isInitialLoading?: boolean; + failure?: ApiFailure; + isFetching?: boolean; + isStale?: boolean; + isDegraded?: boolean; + isMutationPending?: boolean; + hasMutationConflict?: boolean; +}>; + +export type AsyncState = Readonly<{ + base: (typeof ASYNC_BASE_STATES)[number]; + data?: unknown; + failure?: ApiFailure; + overlay: AsyncOverlay; + indicator: (typeof ASYNC_OVERLAYS)[number] | null; +}>; + +export function deriveAsyncState(signals: AsyncSignals): AsyncState { + const hasData = signals.data !== undefined && signals.data !== null; + const empty = + hasData && + ((Array.isArray(signals.data) && signals.data.length === 0) || + signals.data === ""); + + const base = + signals.isInitialLoading && !hasData + ? "initial-loading" + : signals.failure && !hasData + ? "terminal-error" + : empty + ? "empty" + : hasData + ? "success" + : "initial-loading"; + + const indicator = + signals.hasMutationConflict && hasData + ? "mutation-conflict" + : signals.isMutationPending && hasData + ? "mutation-pending" + : signals.isStale && signals.isDegraded && hasData + ? "stale-degraded" + : signals.isFetching && hasData + ? "refreshing" + : null; + const overlay = overlayFor(indicator); + + return Object.freeze({ + base, + data: base === "success" || base === "empty" ? signals.data : undefined, + failure: base === "terminal-error" ? signals.failure : undefined, + overlay, + indicator, + }); +} + +export function selectOverlayIndicator( + overlay: AsyncOverlay, +): AsyncState["indicator"] { + if (overlay.mutationConflict) return "mutation-conflict"; + if (overlay.mutationPending) return "mutation-pending"; + if (overlay.staleDegraded) return "stale-degraded"; + if (overlay.refreshing) return "refreshing"; + return null; +} + +function overlayFor(indicator: AsyncState["indicator"]): AsyncOverlay { + if (indicator === "refreshing") { + return Object.freeze({ + refreshing: true, + staleDegraded: false, + mutationPending: false, + mutationConflict: false, + }); + } + if (indicator === "stale-degraded") { + return Object.freeze({ + refreshing: false, + staleDegraded: true, + mutationPending: false, + mutationConflict: false, + }); + } + if (indicator === "mutation-pending") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: true, + mutationConflict: false, + }); + } + if (indicator === "mutation-conflict") { + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationConflict: true, + }); + } + return Object.freeze({ + refreshing: false, + staleDegraded: false, + mutationPending: false, + mutationConflict: false, + }); +} diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js index b4b0cc7..835b65c 100644 --- a/src/bootstrap/runtime-adapters.js +++ b/src/bootstrap/runtime-adapters.js @@ -3,6 +3,7 @@ import { createExternalAuthSessionAdapter, createUnavailableSessionAdapter, } from "../adapters/auth/external-session-adapter.js"; +import { createHttpClient } from "../adapters/http/client.js"; import { createQueryClient, } from "../adapters/query-cache/tanstack-query-cache.js"; @@ -40,6 +41,30 @@ function storageOrUndefined(value) { : undefined; } +/** + * Runtime-aware transport factory. Feature gateway composition calls this + * factory when a registered API capability is installed. + * + * @param {{ + * runtime: Awaited>, + * authSession: import("../application/ports/auth-session-port.js").AuthSessionPort, + * fetcher?: typeof fetch, + * clock?: import("../application/ports/clock-port.js").ClockPort, + * scheduler?: Parameters[0]["scheduler"] + * }} context + */ +export function createRuntimeHttpClient(context) { + return createHttpClient({ + baseUrl: context.runtime.config.API_BASE_URL, + timeoutMs: context.runtime.config.REQUEST_TIMEOUT_MS, + maxRetryAttempts: context.runtime.config.MAX_RETRY_ATTEMPTS, + authSession: context.authSession, + fetcher: context.fetcher, + clock: context.clock, + scheduler: context.scheduler, + }); +} + /** * @param {{ * runtime: Awaited>, diff --git a/src/contracts/api-operations.js b/src/contracts/api-operations.js index 08c6558..a4eec5b 100644 --- a/src/contracts/api-operations.js +++ b/src/contracts/api-operations.js @@ -4,8 +4,10 @@ * path: string, * operationId: string, * auth: "none" | "external-session", - * timeoutMs: number, + * timeoutMs: number | null, * idempotency: "safe" | "keyed" | "none", + * retry: "runtime" | "never", + * requestSource: "search" | "body" | "none", * requestSchema: string, * responseSchema: string, * owner: string @@ -21,8 +23,10 @@ export const API_OPERATIONS = Object.freeze({ path: "/api/sample/resources", operationId: "LIST_SAMPLE_RESOURCES", auth: "external-session", - timeoutMs: 10_000, + timeoutMs: null, idempotency: "safe", + retry: "runtime", + requestSource: "search", requestSchema: "SampleResourceListQuery", responseSchema: "SampleResourceListPayload", owner: "feature-sample-feature-slice-contract-fixture", @@ -32,8 +36,10 @@ export const API_OPERATIONS = Object.freeze({ path: "/api/sample/resources", operationId: "CREATE_SAMPLE_RESOURCE", auth: "external-session", - timeoutMs: 10_000, + timeoutMs: null, idempotency: "keyed", + retry: "runtime", + requestSource: "body", requestSchema: "CreateSampleResourceCommand", responseSchema: "SampleResourcePayload", owner: "feature-sample-feature-slice-contract-fixture", diff --git a/src/presentation/adapters/query/application-query.ts b/src/presentation/adapters/query/application-query.ts new file mode 100644 index 0000000..10173a0 --- /dev/null +++ b/src/presentation/adapters/query/application-query.ts @@ -0,0 +1,194 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { + deriveAsyncState, + type AsyncState, +} from "../../../application/view-models/async-state.js"; +import type { ApiFailure } from "../../../contracts/errors.js"; + +export type ApplicationResult = + | Readonly<{ ok: true; value: Value }> + | Readonly<{ ok: false; error: ApiFailure }>; + +class ApplicationQueryError extends Error { + readonly failure: ApiFailure; + + constructor(failure: ApiFailure) { + super(failure.kind); + this.name = "ApplicationQueryError"; + this.failure = failure; + } +} + +export function useApplicationQuery( + options: Readonly<{ + queryKey: readonly unknown[]; + execute(context: Readonly<{ signal: AbortSignal }>): Promise< + ApplicationResult + >; + enabled?: boolean; + }>, +): Readonly<{ + data: Value | undefined; + state: AsyncState; + retry(): Promise; +}> { + const { queryKey, execute, enabled = true } = options; + const [staleFailure, setStaleFailure] = useState(false); + const query = useQuery({ + queryKey, + enabled, + retry: false, + queryFn: async ({ signal }) => { + const result = await execute({ signal }); + if (result.ok) return result.value; + if (signal.aborted || result.error.kind === "REQUEST_ABORTED") { + throw new DOMException("Query cancelled", "AbortError"); + } + throw new ApplicationQueryError(result.error); + }, + }); + const hasData = query.data !== undefined && query.data !== null; + + useEffect(() => { + if (query.isError && hasData) { + setStaleFailure(true); + } else if (query.isSuccess && !query.isFetching) { + setStaleFailure(false); + } + }, [hasData, query.isError, query.isFetching, query.isSuccess]); + + const retry = useCallback(async () => { + setStaleFailure(false); + await query.refetch(); + }, [query]); + + return Object.freeze({ + data: query.data, + state: deriveAsyncState({ + data: query.data, + isInitialLoading: query.isPending, + failure: + !hasData && query.error instanceof ApplicationQueryError + ? query.error.failure + : undefined, + isFetching: query.isFetching && !query.isPending, + isStale: staleFailure, + isDegraded: staleFailure, + }), + retry, + }); +} + +export function useApplicationMutation( + options: Readonly<{ + execute(input: Input): Promise>; + invalidate?: readonly (readonly unknown[])[]; + optimistic?: Readonly<{ + queryKey: readonly unknown[]; + update(previous: unknown, input: Input): unknown; + }>; + currentData?: unknown; + }>, +): Readonly<{ + state: AsyncState; + submit(input: Input): Promise>; + resolveConflict(): Promise; +}> { + const queryClient = useQueryClient(); + const { execute, invalidate = [], optimistic, currentData } = options; + const [conflict, setConflict] = useState(null); + const inFlight = useRef> | null>(null); + const mutation = useMutation({ + retry: false, + mutationFn: async (input) => { + const result = await execute(input); + if (result.ok) return result.value; + throw new ApplicationQueryError(result.error); + }, + }); + + const submit = useCallback( + (input: Input): Promise> => { + if (inFlight.current) return inFlight.current; + setConflict(null); + mutation.reset(); + + const previous = optimistic + ? queryClient.getQueryData(optimistic.queryKey) + : undefined; + if (optimistic) { + queryClient.setQueryData( + optimistic.queryKey, + optimistic.update(previous, input), + ); + } + + const pending = mutation + .mutateAsync(input) + .then(async (value) => { + for (const queryKey of invalidate) { + await queryClient.invalidateQueries({ queryKey, exact: false }); + } + return { ok: true as const, value }; + }) + .catch((error: unknown) => { + if (optimistic) { + queryClient.setQueryData(optimistic.queryKey, previous); + } + const failure = + error instanceof ApplicationQueryError + ? error.failure + : unexpectedMutationFailure(); + if (failure.kind === "CONFLICT") setConflict(failure); + return { ok: false as const, error: failure }; + }) + .finally(() => { + inFlight.current = null; + }); + inFlight.current = pending; + return pending; + }, + [invalidate, mutation, optimistic, queryClient], + ); + + const resolveConflict = useCallback(async () => { + setConflict(null); + mutation.reset(); + for (const queryKey of invalidate) { + await queryClient.invalidateQueries({ queryKey, exact: false }); + } + }, [invalidate, mutation, queryClient]); + + return Object.freeze({ + state: deriveAsyncState({ + data: currentData ?? true, + isMutationPending: mutation.isPending, + hasMutationConflict: conflict !== null, + }), + submit, + resolveConflict, + }); +} + +function unexpectedMutationFailure(): ApiFailure { + return { + kind: "UNKNOWN_FAILURE", + code: "UNKNOWN_FAILURE", + retryable: false, + operationId: "APPLICATION_MUTATION", + attemptCount: 1, + userMessageKey: "error.unknown_failure", + action: "contact-support", + }; +} diff --git a/src/presentation/adapters/query/index.ts b/src/presentation/adapters/query/index.ts new file mode 100644 index 0000000..36a7637 --- /dev/null +++ b/src/presentation/adapters/query/index.ts @@ -0,0 +1,5 @@ +export { + useApplicationMutation, + useApplicationQuery, + type ApplicationResult, +} from "./application-query.js"; diff --git a/src/presentation/components/async-surface.jsx b/src/presentation/components/async-surface.jsx index e005d7c..c1cf68d 100644 --- a/src/presentation/components/async-surface.jsx +++ b/src/presentation/components/async-surface.jsx @@ -65,7 +65,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) { data-message-key={userMessageKey} >

{errorMessage(userMessageKey)}

- {action !== "none" && ( + {action !== "none" && onAction && ( )} @@ -76,10 +76,18 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) { * @param {{ * state: ReturnType, * children?: React.ReactNode, - * onAction?: () => void + * onAction?: () => void, + * onRetry?: () => void, + * onResolveConflict?: () => void * }} props */ -export function AsyncSurface({ state, children, onAction }) { +export function AsyncSurface({ + state, + children, + onAction, + onRetry, + onResolveConflict, +}) { if (state.base === "initial-loading") return ; if (state.base === "empty") return ; if (state.base === "terminal-error" && state.failure) { @@ -87,18 +95,24 @@ export function AsyncSurface({ state, children, onAction }) { ); } return (
- {state.indicator && ( -

- {state.indicator} -

- )} + {state.indicator ? ( +
+ {state.indicator} + {state.indicator === "stale-degraded" && onRetry ? ( + + ) : null} + {state.indicator === "mutation-conflict" && onResolveConflict ? ( + + ) : null} +
+ ) : null} {children}
); diff --git a/tests/component/application-query.test.jsx b/tests/component/application-query.test.jsx new file mode 100644 index 0000000..16df843 --- /dev/null +++ b/tests/component/application-query.test.jsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom + +import { + act, + renderHook, + waitFor, +} from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +import { + useApplicationMutation, + useApplicationQuery, +} from "../../src/presentation/adapters/query/application-query.js"; +import { createFailure } from "../../src/contracts/errors.js"; + +function queryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: 0, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +/** @param {QueryClient} client */ +function wrapper(client) { + /** @param {{children: React.ReactNode}} props */ + return function QueryWrapper({ children }) { + return ( + {children} + ); + }; +} + +describe("application query inbound bridge", () => { + it("latches a background failure over stale data and clears it on retry success", async () => { + const client = queryClient(); + const responses = [ + { ok: /** @type {const} */ (true), value: ["first"] }, + { + ok: /** @type {const} */ (false), + error: createFailure("SERVER_FAILURE", "LIST", 0), + }, + { ok: /** @type {const} */ (true), value: ["recovered"] }, + ]; + const execute = vi.fn(async () => responses.shift() ?? responses[0]); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["resource", "list"], + execute, + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => expect(hook.result.current.data).toEqual(["first"])); + await act(() => hook.result.current.retry()); + await waitFor(() => + expect(hook.result.current.state.indicator).toBe("stale-degraded"), + ); + expect(hook.result.current.state.base).toBe("success"); + expect(hook.result.current.data).toEqual(["first"]); + + await act(() => hook.result.current.retry()); + await waitFor(() => + expect(hook.result.current.data).toEqual(["recovered"]), + ); + expect(hook.result.current.state.indicator).toBeNull(); + expect(execute).toHaveBeenCalledTimes(3); + }); + + it("projects an initial application failure into terminal state", async () => { + const client = queryClient(); + const failure = createFailure("FORBIDDEN", "LIST", 0); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["forbidden"], + execute: async () => ({ ok: false, error: failure }), + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(hook.result.current.state.base).toBe("terminal-error"), + ); + expect(hook.result.current.state.failure).toBe(failure); + }); + + it("passes cancellation to the application and does not retain an unmounted error", async () => { + const client = queryClient(); + let aborted = false; + const execute = vi.fn( + ({ signal }) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => { + aborted = true; + resolve({ + ok: false, + error: createFailure("REQUEST_ABORTED", "LIST", 0), + }); + }, + { once: true }, + ); + }), + ); + const hook = renderHook( + () => + useApplicationQuery({ + queryKey: ["cancelled"], + execute, + }), + { wrapper: wrapper(client) }, + ); + + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + hook.unmount(); + await waitFor(() => expect(aborted).toBe(true)); + expect(client.getQueryState(["cancelled"])?.status).not.toBe("error"); + }); +}); + +describe("application mutation inbound bridge", () => { + it("deduplicates submit and commits one optimistic mutation", async () => { + const client = queryClient(); + const key = ["resource", "list"]; + client.setQueryData(key, ["existing"]); + /** @type {(value: {ok: true, value: string}) => void} */ + let complete = () => {}; + const execute = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const hook = renderHook( + () => + useApplicationMutation({ + execute, + invalidate: [["resource"]], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + .../** @type {string[]} */ (previous), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + /** @type {ReturnType | null} */ + let first = null; + /** @type {ReturnType | null} */ + let duplicate = null; + act(() => { + first = hook.result.current.submit("created"); + duplicate = hook.result.current.submit("created"); + }); + expect(first).toBe(duplicate); + expect(client.getQueryData(key)).toEqual(["existing", "created"]); + await waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await waitFor(() => + expect(hook.result.current.state.indicator).toBe("mutation-pending"), + ); + + complete({ ok: true, value: "created" }); + if (!first) throw new Error("expected pending mutation"); + await act(() => first); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + await waitFor(() => + expect(hook.result.current.state.indicator).toBeNull(), + ); + }); + + it("rolls optimistic data back and exposes a resolvable conflict", async () => { + const client = queryClient(); + const key = ["resource", "list"]; + client.setQueryData(key, ["existing"]); + const conflict = createFailure("CONFLICT", "CREATE", 0); + const hook = renderHook( + () => + useApplicationMutation({ + execute: async () => ({ ok: false, error: conflict }), + invalidate: [["resource"]], + currentData: client.getQueryData(key), + optimistic: { + queryKey: key, + update: (previous, input) => [ + .../** @type {string[]} */ (previous), + input, + ], + }, + }), + { wrapper: wrapper(client) }, + ); + + let outcome; + await act(async () => { + outcome = await hook.result.current.submit("conflicting"); + }); + expect(outcome).toEqual({ ok: false, error: conflict }); + expect(client.getQueryData(key)).toEqual(["existing"]); + expect(hook.result.current.state.indicator).toBe("mutation-conflict"); + expect(hook.result.current.state.overlay).toMatchObject({ + mutationPending: false, + mutationConflict: true, + }); + + await act(() => hook.result.current.resolveConflict()); + expect(hook.result.current.state.indicator).toBeNull(); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/tests/component/async-surface.test.jsx b/tests/component/async-surface.test.jsx index e106465..c6a7b0d 100644 --- a/tests/component/async-surface.test.jsx +++ b/tests/component/async-surface.test.jsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; @@ -29,7 +30,7 @@ describe("async UI state matrix", () => { expect(deriveAsyncState(signals).indicator).toBe(indicator); }); - it("uses deterministic overlay priority for crossed states", () => { + it("makes crossed overlay inputs mutually exclusive by priority", () => { const state = deriveAsyncState({ data: ["value"], isFetching: true, @@ -38,8 +39,8 @@ describe("async UI state matrix", () => { }); expect(state.indicator).toBe("mutation-conflict"); expect(state.overlay).toMatchObject({ - refreshing: true, - mutationPending: true, + refreshing: false, + mutationPending: false, mutationConflict: true, }); }); @@ -52,12 +53,45 @@ describe("async UI state matrix", () => { expect(screen.getByRole("status")).toHaveTextContent("refreshing"); }); + it("connects stale retry and conflict resolution to real callbacks", async () => { + const user = userEvent.setup(); + const retry = vi.fn(); + const resolveConflict = vi.fn(); + const stale = deriveAsyncState({ + data: ["value"], + isStale: true, + isDegraded: true, + }); + const view = render( + + existing content + , + ); + await user.click(screen.getByRole("button", { name: "다시 시도" })); + expect(retry).toHaveBeenCalledOnce(); + + const conflict = deriveAsyncState({ + data: ["value"], + hasMutationConflict: true, + }); + view.rerender( + + existing content + , + ); + await user.click(screen.getByRole("button", { name: "충돌 해결" })); + expect(resolveConflict).toHaveBeenCalledOnce(); + }); + it("renders only safe error vocabulary", () => { const failure = createFailure("SERVER_FAILURE", "LIST", 0, { code: "SERVER_FAILURE", }); const state = deriveAsyncState({ failure }); - render(); + render(); expect(screen.getByRole("alert")).toHaveTextContent( "요청을 완료하지 못했습니다.", diff --git a/tests/fixtures/architecture/forbidden/presentation-imports-tanstack.tsx b/tests/fixtures/architecture/forbidden/presentation-imports-tanstack.tsx new file mode 100644 index 0000000..3a626d2 --- /dev/null +++ b/tests/fixtures/architecture/forbidden/presentation-imports-tanstack.tsx @@ -0,0 +1,3 @@ +import { useQuery } from "@tanstack/react-query"; + +export const leakedQueryHook = useQuery; diff --git a/tests/fixtures/typecheck/invalid-async-overlay.ts b/tests/fixtures/typecheck/invalid-async-overlay.ts new file mode 100644 index 0000000..2341a65 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-async-overlay.ts @@ -0,0 +1,8 @@ +import type { AsyncOverlay } from "../../../src/application/view-models/async-state.js"; + +export const invalidPendingConflict: AsyncOverlay = { + refreshing: false, + staleDegraded: false, + mutationPending: true, + mutationConflict: true, +}; diff --git a/tests/integration/http-execution-contract.test.js b/tests/integration/http-execution-contract.test.js new file mode 100644 index 0000000..e8e766d --- /dev/null +++ b/tests/integration/http-execution-contract.test.js @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createHttpClient } from "../../src/adapters/http/client.js"; +import { queryKeys } from "../../src/contracts/query-keys.js"; + +/** @param {unknown} data */ +function successResponse(data) { + return Response.json({ + success: true, + data, + meta: { requestId: "request-1", traceId: "trace-1" }, + }); +} + +/** @param {number} status */ +function failureResponse(status) { + return Response.json( + { + success: false, + error: { code: "TEMPORARY" }, + meta: { requestId: "request-1", traceId: "trace-1" }, + }, + { status }, + ); +} + +function immediateClock() { + return { now: () => 0, sleep: async () => {} }; +} + +function recordingScheduler() { + const callbacks = /** @type {Array<() => void>} */ ([]); + return { + callbacks, + setTimeout: vi.fn((callback) => { + callbacks.push(callback); + return callbacks.length - 1; + }), + clearTimeout: vi.fn(), + }; +} + +describe("HTTP operation execution contract", () => { + it("sends parsed search/body values and aligns canonical query identity", async () => { + const requests = /** @type {Request[]} */ ([]); + const scheduler = recordingScheduler(); + const fetcher = vi.fn(async (request) => { + requests.push(/** @type {Request} */ (request)); + if (/** @type {Request} */ (request).method === "POST") { + return successResponse({ id: "created", name: "Trimmed" }); + } + return successResponse([]); + }); + const client = createHttpClient({ + baseUrl: "https://api.test", + fetcher, + clock: immediateClock(), + scheduler, + }); + const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 }; + + await client.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + searchParams: filters, + }); + await client.execute({ + operationId: "CREATE_SAMPLE_RESOURCE", + routeId: "SAMPLE_RESOURCE_LIST", + body: { name: " Trimmed " }, + idempotencyKey: "logical-command", + }); + + expect(requests[0].url).toBe( + "https://api.test/api/sample/resources?cursor=a%2Fb&limit=5&tags=open&tags=new", + ); + expect(queryKeys.resource.list(filters).at(-1)).toEqual(filters); + await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" }); + expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command"); + expect(scheduler.setTimeout).toHaveBeenCalledTimes(2); + expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2); + }); + + it("performs no fetch or timer work for invalid request input", async () => { + const fetcher = vi.fn(); + const scheduler = recordingScheduler(); + const client = createHttpClient({ + baseUrl: "https://api.test", + fetcher, + scheduler, + }); + + await expect( + client.execute({ + operationId: "CREATE_SAMPLE_RESOURCE", + routeId: "SAMPLE_RESOURCE_LIST", + body: { name: " " }, + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "VALIDATION_REJECTED" }, + }); + expect(fetcher).not.toHaveBeenCalled(); + expect(scheduler.setTimeout).not.toHaveBeenCalled(); + expect(scheduler.clearTimeout).not.toHaveBeenCalled(); + }); + + it.each([ + [0, 1], + [1, 2], + [2, 3], + ])( + "applies runtime max retry count %i as %i total attempts", + async (maxRetryAttempts, totalAttempts) => { + const fetcher = vi.fn(async () => failureResponse(503)); + const scheduler = recordingScheduler(); + const client = createHttpClient({ + baseUrl: "https://api.test", + fetcher, + clock: immediateClock(), + scheduler, + maxRetryAttempts, + }); + + await expect( + client.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "SERVER_FAILURE" }, + }); + expect(fetcher).toHaveBeenCalledTimes(totalAttempts); + expect(scheduler.setTimeout).toHaveBeenCalledTimes(totalAttempts); + expect(scheduler.clearTimeout).toHaveBeenCalledTimes(totalAttempts); + }, + ); + + it("distinguishes a runtime timeout from caller navigation abort and cleans listeners", async () => { + const scheduler = recordingScheduler(); + const fetcher = vi.fn( + (request) => + new Promise((_resolve, reject) => { + /** @type {Request} */ (request).signal.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }), + ); + const client = createHttpClient({ + baseUrl: "https://api.test", + fetcher, + scheduler, + maxRetryAttempts: 0, + }); + const timeoutResult = client.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + }); + await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1)); + scheduler.callbacks[0](); + await expect(timeoutResult).resolves.toMatchObject({ + ok: false, + error: { kind: "REQUEST_TIMEOUT" }, + }); + + const caller = new AbortController(); + const add = vi.spyOn(caller.signal, "addEventListener"); + const remove = vi.spyOn(caller.signal, "removeEventListener"); + const abortResult = client.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + signal: caller.signal, + }); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + caller.abort("navigation"); + await expect(abortResult).resolves.toMatchObject({ + ok: false, + error: { kind: "REQUEST_ABORTED" }, + }); + expect(add).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledOnce(); + expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2); + }); + + it("never retries unsafe, non-retryable status, or schema failures", async () => { + const unsafeOperation = /** @type {const} */ ({ + method: "POST", + path: "/api/unsafe", + operationId: "UNSAFE", + auth: "none", + timeoutMs: null, + idempotency: "none", + retry: "never", + requestSource: "none", + requestSchema: "unused", + responseSchema: "SampleResourcePayload", + owner: "test", + }); + const unsafeFetch = vi.fn(async () => failureResponse(503)); + const unsafeClient = createHttpClient({ + baseUrl: "https://api.test", + fetcher: unsafeFetch, + clock: immediateClock(), + getOperation: () => unsafeOperation, + }); + await unsafeClient.execute({ + operationId: "UNSAFE", + routeId: "TEST", + }); + expect(unsafeFetch).toHaveBeenCalledOnce(); + + const statusFetch = vi.fn(async () => failureResponse(500)); + const statusClient = createHttpClient({ + baseUrl: "https://api.test", + fetcher: statusFetch, + clock: immediateClock(), + }); + await statusClient.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + }); + expect(statusFetch).toHaveBeenCalledOnce(); + + const schemaFetch = vi.fn(async () => + successResponse([{ id: "one", name: 42 }]), + ); + const schemaScheduler = recordingScheduler(); + const schemaClient = createHttpClient({ + baseUrl: "https://api.test", + fetcher: schemaFetch, + clock: immediateClock(), + scheduler: schemaScheduler, + }); + await schemaClient.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + }); + expect(schemaFetch).toHaveBeenCalledOnce(); + expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/request-builder.test.ts b/tests/unit/request-builder.test.ts new file mode 100644 index 0000000..23a46dc --- /dev/null +++ b/tests/unit/request-builder.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { buildRequestTarget } from "../../src/adapters/http/request-builder.js"; +import type { ApiOperation } from "../../src/contracts/api-operations.js"; + +const operation: ApiOperation = { + method: "GET", + path: "/api/resources/{resourceId}", + operationId: "GET_RESOURCE", + auth: "none", + timeoutMs: null, + idempotency: "safe", + retry: "runtime", + requestSource: "search", + requestSchema: "ResourceQuery", + responseSchema: "ResourcePayload", + owner: "test", +}; + +describe("deterministic HTTP request target", () => { + it("escapes path values and serializes optional/array search in key order", () => { + const result = buildRequestTarget( + "https://api.test/base/", + operation, + { resourceId: "folder/item" }, + { + tags: ["beta", "alpha"], + omitted: undefined, + limit: 20, + cursor: "next page", + }, + ); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.url.href).toBe( + "https://api.test/api/resources/folder%2Fitem?cursor=next+page&limit=20&tags=beta&tags=alpha", + ); + }); + + it("fails closed when a path value or scalar search value is invalid", () => { + expect( + buildRequestTarget("https://api.test", operation, {}, {}), + ).toEqual({ success: false, code: "PATH_PARAMETER_MISSING" }); + expect( + buildRequestTarget( + "https://api.test", + operation, + { resourceId: "one" }, + { nested: { secret: true } }, + ), + ).toEqual({ success: false, code: "SEARCH_PARAMETER_INVALID" }); + }); +}); diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js index 9d9f824..3e4f39a 100644 --- a/tests/unit/runtime-adapters.test.js +++ b/tests/unit/runtime-adapters.test.js @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js"; +import { + createRuntimeAdapters, + createRuntimeHttpClient, +} from "../../src/bootstrap/runtime-adapters.js"; const runtime = { config: { @@ -8,6 +11,8 @@ const runtime = { API_BASE_URL: "http://localhost:8080", TELEMETRY_ENABLED: false, AUTH_MODE: "demo", + REQUEST_TIMEOUT_MS: 4321, + MAX_RETRY_ATTEMPTS: 0, }, }; const release = /** @type {const} */ ({ @@ -53,4 +58,60 @@ describe("runtime adapter composition", () => { }); expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); }); + + it("injects runtime timeout and max-attempt policy into HTTP execution", async () => { + const scheduled = + /** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]); + const scheduler = { + setTimeout: vi.fn((callback, milliseconds) => { + scheduled.push({ callback, milliseconds }); + return scheduled.length; + }), + clearTimeout: vi.fn(), + }; + const fetcher = vi.fn(async () => + Response.json( + { + success: false, + error: { code: "TEMPORARY" }, + meta: { requestId: "request-1", traceId: "trace-1" }, + }, + { status: 503 }, + ), + ); + const authSession = + (await createRuntimeAdapters({ + runtime: + /** @type {Parameters[0]["runtime"]} */ ( + runtime + ), + release, + host: {}, + })).outputPorts.session; + const client = createRuntimeHttpClient({ + runtime: + /** @type {Parameters[0]["runtime"]} */ ( + runtime + ), + authSession: + /** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ ( + authSession + ), + fetcher, + clock: { now: () => 0, sleep: async () => {} }, + scheduler, + }); + + await client.execute({ + operationId: "LIST_SAMPLE_RESOURCES", + routeId: "SAMPLE_RESOURCE_LIST", + }); + + expect(fetcher).toHaveBeenCalledOnce(); + expect(scheduler.setTimeout).toHaveBeenCalledWith( + expect.any(Function), + 4321, + ); + expect(scheduler.clearTimeout).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/unit/system-clock.test.js b/tests/unit/system-clock.test.js index dd94c1b..7f0415a 100644 --- a/tests/unit/system-clock.test.js +++ b/tests/unit/system-clock.test.js @@ -5,9 +5,14 @@ import { systemClock } from "../../src/adapters/platform/system-clock.js"; describe("systemClock", () => { it("resolves after the requested duration", async () => { vi.useFakeTimers(); - const sleeper = systemClock.sleep(250); + const caller = new AbortController(); + const add = vi.spyOn(caller.signal, "addEventListener"); + const remove = vi.spyOn(caller.signal, "removeEventListener"); + const sleeper = systemClock.sleep(250, caller.signal); await vi.advanceTimersByTimeAsync(250); await expect(sleeper).resolves.toBeUndefined(); + expect(add).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledOnce(); vi.useRealTimers(); }); });