Compare commits

..
Author SHA1 Message Date
donghyeon-ka ad55e21a3d feat: execute HTTP and query runtime contracts 2026-07-26 14:05:12 +09:00
donghyeon-ka 8aaaa033c0 merge: application boundary runtime 2026-07-26 13:52:43 +09:00
29 changed files with 1326 additions and 185 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ module.exports = {
{ {
name: "presentation-does-not-know-adapters", name: "presentation-does-not-know-adapters",
severity: "error", severity: "error",
from: { path: "^src/presentation" }, from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" }, to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
}, },
{ {
+2 -1
View File
@@ -78,7 +78,8 @@
{ "script": "check:types:fixture:ts-port", "expect": "fail" }, { "script": "check:types:fixture:ts-port", "expect": "fail" },
{ "script": "check:types:fixture:ts-result", "expect": "fail" }, { "script": "check:types:fixture:ts-result", "expect": "fail" },
{ "script": "check:types:fixture:application-output", "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", "logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"], "evidence": ["artifacts/quality/check-types.txt"],
+3 -1
View File
@@ -29,6 +29,8 @@
"auth", "auth",
"timeoutMs", "timeoutMs",
"idempotency", "idempotency",
"retry",
"requestSource",
"requestSchema", "requestSchema",
"responseSchema", "responseSchema",
"owner" "owner"
@@ -111,6 +113,6 @@
], ],
"compatibilityImpact": { "compatibilityImpact": {
"allowed": ["none", "additive", "behavior-change", "breaking"], "allowed": ["none", "additive", "behavior-change", "breaking"],
"current": "additive" "current": "behavior-change"
} }
} }
@@ -33,13 +33,13 @@
특히 다음은 선행 해결이 필요하다. 특히 다음은 선행 해결이 필요하다.
1. React 화면이 호출할 application input API와 런타임 주입 경계 RP-01~RP-03에서 TypeScript 도구 안전망, application runtime 주입,
2. TanStack Query를 사용하는 표준 query/mutation inbound adapter query/mutation inbound adapter와 HTTP 실행 계약은 구현됐다. 현재 선행 해결
3. TypeScript 전환 전에 TS 파일까지 검사하도록 만드는 도구 안전망 대상은 다음과 같다.
4. path/query/body projection과 runtime timeout/retry가 정확히 연결된 HTTP 계층
5. 선언과 실행이 일치하는 typed route 계약 1. 선언과 실행이 일치하는 typed route 계약
6. 전체를 제거할 수 있는 실제 reference feature 2. 전체를 제거할 수 있는 실제 reference feature
7. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치 3. 폼, 페이지 템플릿, 확장된 디자인 시스템과 컴포넌트 워크벤치
따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다. 따라서 현재 상태를 “프론트 공통부가 모두 구현됐다”고 표현하면 범위가 과장된다.
더 정확한 표현은 다음과 같다. 더 정확한 표현은 다음과 같다.
@@ -63,13 +63,13 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 | | 부트·런타임 설정 | 준비됨 | `src/bootstrap`, runtime schema, release 검사 | 현 상태 유지, TS 전환 시 동일 게이트 유지 |
| 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 | | 계층 의존 방향 | 부분 준비 | `.dependency-cruiser.cjs`, `src/application/ports` | inbound/outbound 명명과 `contracts` 소유권까지 집행 |
| application facade | 부분 준비 | `create-application.js`는 있으나 `main.jsx`에서 우회 | UI에는 input API만 주입 | | application facade | 준비 | typed input/output catalog, provider, production composition test | feature input use case를 contribution으로 확장 |
| HTTP client | 부분 준비 | timeout, abort, retry, auth, envelope, Zod가 존재 | path/query, parsed body, runtime 설정, 정리 로직 보완 | | HTTP client | 준비 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 부분 준비 | safe/keyed 요청 정책 존재 | retry 소유자 단일화, 설정 연결, telemetry | | retry | 준비 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper | | 오류 모델 | 부분 준비 | 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 저장소는 추가하지 않음 | | 인증 연동 | 준비됨/프로젝트 선택 | 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 예제 | | 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 | | 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 | | 라우팅 | 부분 준비 | lazy route, access hint, registry 존재 | typed runtime map, codec, recovery, metadata 집행 |
@@ -82,7 +82,7 @@
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 | | 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 | | logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 | | 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 통합, 위험 시나리오 보강 | | 단위·통합·E2E | 준비됨 | Vitest, RTL, MSW, Playwright 3엔진 | TS 테스트 검사, 실제 bootstrap 통합, 위험 시나리오 보강 |
| UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 | | UI 회귀 검증 | 미제공 | axe/reflow는 있으나 visual baseline 없음 | Storybook 또는 동급 workshop과 시각 회귀 |
| 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 | | 샘플 제거 | 부분 준비 | fixture 제거 테스트 존재 | sample domain/registry/runtime 전체 제거 검증 |
@@ -94,13 +94,12 @@
### 5.1 P0: 기능 개발을 막는 항목 ### 5.1 P0: 기능 개발을 막는 항목
#### application 계층이 런타임에서 우회된다 #### RP-02에서 application 런타임 우회 해결
`src/bootstrap/composition-root.js` application을 생성하지만 `src/bootstrap/composition-root.js`가 만든 typed application input API는
`src/bootstrap/main.jsx`는 이를 라우터에 주입하지 않는다. UI에는 auth, storage, production `ApplicationProvider`에 주입된다. raw auth, storage, telemetry와
telemetry 같은 raw outbound dependency와 concrete QueryClient가 전달된다. release port는 closure 안에 남고 UI는 session, preference, diagnostics와 runtime
`src/application/create-application.js`도 use case 중심 input API보다 outbound query만 사용한다.
capability를 다시 노출하는 형태다.
목표 상태: 목표 상태:
@@ -110,12 +109,13 @@ capability를 다시 노출하는 형태다.
- bootstrap만 concrete outbound adapter를 알고 조합한다. - bootstrap만 concrete outbound adapter를 알고 조합한다.
- 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다. - 실제 bootstrap부터 reference page까지 연결한 통합 테스트가 있다.
#### 서버 상태 라이브러리는 마운트됐지만 사용할 수 없다 #### RP-03에서 표준 서버 상태 bridge 구현
`QueryClientProvider`는 존재하지만 저장소의 제품 코드에서 `useQuery` `src/presentation/adapters/query` 한 경계만 `@tanstack/**`를 import한다.
`useMutation`을 사용하지 않는다. 동시에 presentation의 `@tanstack/**` import는 `useApplicationQuery``useApplicationMutation`은 application result를 React
금지돼 있다. 현재 `QueryCachePort`는 명령형 read/write/invalidate만 제공하여 lifecycle에 연결하며 cancellation, stale failure, duplicate submit, optimistic
React 구독, 요청 상태, cancellation, optimistic update를 대신할 수 없다. 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을 - loading, empty, refreshing, stale, offline, error, conflict, optimistic rollback을
reference feature에서 보여 준다. reference feature에서 보여 준다.
#### TypeScript 전환 전에 검사 도구가 TS를 인식해야 한다 #### RP-01에서 TypeScript 검사 도구 안전망 구현
현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은 현재 source는 모두 JS/JSX이고 `strict + allowJs + checkJs`를 사용한다. 이는 좋은
중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다. 중간 안전망이지만 다음 도구는 TS migration을 그대로 따라가지 못한다.
@@ -143,18 +143,14 @@ TypeScript 전환은
[TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html) [TypeScript의 JavaScript migration 가이드](https://www.typescriptlang.org/docs/handbook/migrating-from-javascript.html)
처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다. 처럼 점진적으로 진행하되, 이 저장소에서는 tooling glob과 CI를 먼저 고쳐야 한다.
#### HTTP 계약에 선언과 실행의 차이가 있다 #### RP-03에서 HTTP 선언과 실행의 차이 해결
현재 HTTP 계층은 공통화 수준이 높지만 다음 정확성 문제가 남아 있다. HTTP request builder는 path segment escaping, canonical optional/array search,
Zod default/trim 결과의 실제 query/body 전송을 담당한다. runtime timeout과
- runtime config의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`가 client 생성에 0/1/N max retry가 client factory에 주입되고 caller abort와 timeout을 다른 typed
전달되지 않는다. failure로 투영한다. validation 조기 반환은 fetch/timer 0회이며 success, schema
- operation path에 path parameter와 search parameter를 투영하는 표준 builder가 failure, abort, timeout과 exhausted retry는 scheduler/listener cleanup을
다. 검증한다. HTTP 사건의 semantic telemetry 연결은 RP-09 범위다.
- sample filter는 cache key에만 반영되고 실제 요청 URL에는 반영되지 않는다.
- Zod의 parsed/transformed request body 대신 원본 body를 전송한다.
- request validation의 조기 반환 경로에서 timeout/listener 정리가 늦어진다.
- HTTP failure, retry, recovery가 telemetry 사건과 이어지지 않는다.
client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth, client를 거대한 범용 함수로 계속 확장하지 말고 transport, request builder, auth,
timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP timeout, retry, decoder, mapper 책임을 분리해야 한다. application에는 범용 HTTP
@@ -141,17 +141,26 @@ RP-02 구현으로 다음 경계는 실행 경로에 연결됐다.
- presentation의 direct fetch/browser storage/concrete adapter/TanStack import와 - presentation의 direct fetch/browser storage/concrete adapter/TanStack import와
application의 React/concrete adapter import는 negative fixture가 거절한다. 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에서 1. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
`useQuery` 또는 `useMutation`을 연결하는 query bridge가 없다.
2. route registry의 `paramsSchema`, `searchSchema`, `loadingSurface`,
`errorSurface`, `chunkId` 일부는 실행 route와 연결되지 않았다. `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는 다른 경로에 operation, Zod schema, mapper, domain model과 query key는 다른 경로에
남는다. 남는다.
4. runtime의 `REQUEST_TIMEOUT_MS`, `MAX_RETRY_ATTEMPTS`는 검증되지만
concrete HTTP client 구성에 전달되지 않는다.
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한 이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다. 불일치를 제거하는 것이다.
@@ -387,6 +387,20 @@ retry 조건:
timer와 event listener는 성공, 실패, validation 조기 반환, external abort 모든 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 소유권 ## 6. 인증과 token 소유권
기본 skeleton은 token manager를 제공하지 않는다. 기본 skeleton은 token manager를 제공하지 않는다.
@@ -91,12 +91,14 @@ Vitest의 변환 성공을 TypeScript typecheck의 대체물로 취급하지 않
- boot error shell의 safe metadata - boot error shell의 safe metadata
- StrictMode와 unmount cleanup - StrictMode와 unmount cleanup
#### TanStack Query의 React integration test가 없다 #### TanStack Query의 React integration test 기반
Query cache adapter의 명령형 `read/write/invalidate` unit test는 있지만 `tests/component/application-query.test.jsx`는 production query inbound
`useQuery`, `useMutation`, cancellation, stale/background refresh, optimistic adapter의 query/mutation lifecycle을 검증한다. cancellation, initial terminal
rollback을 사용하는 production presentation adapter가 아직 없다. 따라서 query failure, background stale-failure latch와 retry 복구, duplicate submit,
provider가 마운트되어도 React 사용자의 실제 상태 전환은 검증되지 않는다. optimistic commit/rollback, conflict 해제와 namespace invalidation이 실제
QueryClient 위에서 실행된다. HTTP 자동 retry가 소유자이므로 이 adapter의
query/mutation vendor retry는 꺼져 있다.
#### Form 테스트가 단일 TextField 흐름에 머문다 #### Form 테스트가 단일 TextField 흐름에 머문다
+14
View File
@@ -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}`], files: [`src/adapters/**/*.${sourceExtensions}`],
rules: { rules: {
+1
View File
@@ -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: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-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: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: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: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", "test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
+103 -26
View File
@@ -12,6 +12,7 @@ import {
validateOperationPayload, validateOperationPayload,
validateOperationRequest, validateOperationRequest,
} from "./schema-registry.js"; } from "./schema-registry.js";
import { buildRequestTarget } from "./request-builder.js";
const noAuthSession = const noAuthSession =
/** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({ /** @type {import("../../application/ports/auth-session-port.js").AuthSessionPort} */ ({
@@ -22,6 +23,14 @@ const noAuthSession =
}); });
/** @typedef {import("../../contracts/errors.js").ApiFailure} HttpFailure */ /** @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<string, string> } | * @typedef {{ ok: true, value: unknown, meta: Record<string, string> } |
@@ -38,7 +47,11 @@ const noAuthSession =
* validatePayload?: (schemaId: string, value: unknown) => * validatePayload?: (schemaId: string, value: unknown) =>
* { success: true, data: unknown } | { success: false }, * { success: true, data: unknown } | { success: false },
* mapPayload?: (operationId: string, payload: unknown) => unknown, * mapPayload?: (operationId: string, payload: unknown) => unknown,
* idempotencyKeyFactory?: () => string * idempotencyKeyFactory?: () => string,
* timeoutMs?: number,
* maxRetryAttempts?: number,
* scheduler?: Scheduler,
* getOperation?: typeof getApiOperation
* }} dependencies * }} dependencies
*/ */
export function createHttpClient(dependencies) { export function createHttpClient(dependencies) {
@@ -51,19 +64,46 @@ export function createHttpClient(dependencies) {
const mapPayload = dependencies.mapPayload ?? mapOperationPayload; const mapPayload = dependencies.mapPayload ?? mapOperationPayload;
const idempotencyKeyFactory = const idempotencyKeyFactory =
dependencies.idempotencyKeyFactory ?? (() => crypto.randomUUID()); 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<typeof setTimeout>} */ (handle),
),
});
/** /**
* @param {string} operationId * @param {string | OperationRequestInput} request
* @param {{ * @param {{
* body?: unknown, * body?: unknown,
* routeId?: string, * routeId?: string,
* pathParams?: Record<string, string | number>,
* searchParams?: unknown,
* signal?: AbortSignal, * signal?: AbortSignal,
* idempotencyKey?: string * idempotencyKey?: string
* }} [input] * }} [legacyInput]
* @returns {Promise<HttpResult>} * @returns {Promise<HttpResult>}
*/ */
async function execute(operationId, input = {}) { async function execute(request, legacyInput = {}) {
const operation = getApiOperation(operationId); 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 = const logicalIdempotencyKey =
operation.idempotency === "keyed" operation.idempotency === "keyed"
? input.idempotencyKey ?? idempotencyKeyFactory() ? input.idempotencyKey ?? idempotencyKeyFactory()
@@ -105,7 +145,14 @@ export function createHttpClient(dependencies) {
return outcome; return outcome;
} }
if (!shouldRetry(operation, outcome.error, retryCount)) { if (
!shouldRetry(
operation,
outcome.error,
retryCount,
maxRetryAttempts,
)
) {
return outcome; return outcome;
} }
@@ -117,7 +164,7 @@ export function createHttpClient(dependencies) {
} catch { } catch {
return { return {
ok: false, ok: false,
error: failure("REQUEST_ABORTED", operationId, retryCount, { error: failure("REQUEST_ABORTED", input.operationId, retryCount, {
code: "REQUEST_ABORTED", code: "REQUEST_ABORTED",
}), }),
}; };
@@ -128,7 +175,7 @@ export function createHttpClient(dependencies) {
/** /**
* @param {{ * @param {{
* operation: ReturnType<typeof getApiOperation>, * operation: ReturnType<typeof getApiOperation>,
* input: { body?: unknown, routeId?: string, signal?: AbortSignal }, * input: OperationRequestInput,
* attempt: number, * attempt: number,
* idempotencyKey?: string * idempotencyKey?: string
* }} context * }} context
@@ -136,23 +183,19 @@ export function createHttpClient(dependencies) {
*/ */
async function performAttempt(context) { async function performAttempt(context) {
const { operation, input, attempt, idempotencyKey } = context; const { operation, input, attempt, idempotencyKey } = context;
const controller = new AbortController(); /** @type {unknown} */
let timedOut = false; let parsedSearch = {};
const timeout = setTimeout(() => { let parsedBody;
timedOut = true; const requestValue =
controller.abort("timeout"); operation.requestSource === "search"
}, operation.timeoutMs); ? input.searchParams ?? {}
const onExternalAbort = () => controller.abort(input.signal?.reason); : operation.requestSource === "body"
input.signal?.addEventListener("abort", onExternalAbort, { once: true }); ? input.body
: {};
const headers = new Headers({ Accept: "application/json" }); if (operation.requestSource !== "none") {
if (input.body !== undefined) headers.set("Content-Type", "application/json");
if (idempotencyKey) headers.set("Idempotency-Key", idempotencyKey);
if (input.body !== undefined) {
const requestValidation = validateOperationRequest( const requestValidation = validateOperationRequest(
operation.requestSchema, operation.requestSchema,
input.body, requestValue,
); );
if (!requestValidation.success) { if (!requestValidation.success) {
return { 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, method: operation.method,
headers, headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body), body: parsedBody === undefined ? undefined : JSON.stringify(parsedBody),
signal: controller.signal, signal: controller.signal,
}); });
@@ -237,7 +314,7 @@ export function createHttpClient(dependencies) {
}), }),
}; };
} finally { } finally {
clearTimeout(timeout); scheduler.clearTimeout(timeout);
input.signal?.removeEventListener("abort", onExternalAbort); input.signal?.removeEventListener("abort", onExternalAbort);
} }
} }
+73
View File
@@ -0,0 +1,73 @@
import type { ApiOperation } from "../../contracts/api-operations.js";
export type OperationRequestInput = Readonly<{
operationId: string;
routeId: string;
pathParams?: Readonly<Record<string, string | number>>;
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<Record<string, string | number>> = {},
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<Record<string, unknown>>;
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 };
}
+2 -1
View File
@@ -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 {{ kind: string, retryAfterMs?: number, httpStatus?: number }} failure
* @param {number} retryCount * @param {number} retryCount
* @param {number} [maxRetries] * @param {number} [maxRetries]
*/ */
export function shouldRetry(operation, failure, retryCount, maxRetries = 2) { export function shouldRetry(operation, failure, retryCount, maxRetries = 2) {
if (operation.retry === "never") return false;
if (retryCount >= maxRetries) return false; if (retryCount >= maxRetries) return false;
if (!retryKinds.has(failure.kind)) return false; if (!retryKinds.has(failure.kind)) return false;
if ( if (
+1
View File
@@ -57,6 +57,7 @@ const requestSchemas =
.object({ .object({
cursor: z.string().optional(), cursor: z.string().optional(),
limit: z.int().min(1).max(100).default(20), limit: z.int().min(1).max(100).default(20),
tags: z.array(z.string().trim().min(1)).optional(),
}) })
.strict(), .strict(),
CreateSampleResourceCommand: z CreateSampleResourceCommand: z
+5 -1
View File
@@ -8,9 +8,13 @@ export const systemClock = Object.freeze({
return; return;
} }
const timer = setTimeout(resolve, milliseconds); const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
const onAbort = () => { const onAbort = () => {
clearTimeout(timer); clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
reject(signal?.reason); reject(signal?.reason);
}; };
signal?.addEventListener("abort", onAbort, { once: true }); signal?.addEventListener("abort", onAbort, { once: true });
@@ -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;
}
+156
View File
@@ -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,
});
}
+25
View File
@@ -3,6 +3,7 @@ import {
createExternalAuthSessionAdapter, createExternalAuthSessionAdapter,
createUnavailableSessionAdapter, createUnavailableSessionAdapter,
} from "../adapters/auth/external-session-adapter.js"; } from "../adapters/auth/external-session-adapter.js";
import { createHttpClient } from "../adapters/http/client.js";
import { import {
createQueryClient, createQueryClient,
} from "../adapters/query-cache/tanstack-query-cache.js"; } from "../adapters/query-cache/tanstack-query-cache.js";
@@ -40,6 +41,30 @@ function storageOrUndefined(value) {
: undefined; : undefined;
} }
/**
* Runtime-aware transport factory. Feature gateway composition calls this
* factory when a registered API capability is installed.
*
* @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
* authSession: import("../application/ports/auth-session-port.js").AuthSessionPort,
* fetcher?: typeof fetch,
* clock?: import("../application/ports/clock-port.js").ClockPort,
* scheduler?: Parameters<typeof createHttpClient>[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 {{ * @param {{
* runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>, * runtime: Awaited<ReturnType<typeof import("./load-runtime-config.js").loadRuntimeConfig>>,
+9 -3
View File
@@ -4,8 +4,10 @@
* path: string, * path: string,
* operationId: string, * operationId: string,
* auth: "none" | "external-session", * auth: "none" | "external-session",
* timeoutMs: number, * timeoutMs: number | null,
* idempotency: "safe" | "keyed" | "none", * idempotency: "safe" | "keyed" | "none",
* retry: "runtime" | "never",
* requestSource: "search" | "body" | "none",
* requestSchema: string, * requestSchema: string,
* responseSchema: string, * responseSchema: string,
* owner: string * owner: string
@@ -21,8 +23,10 @@ export const API_OPERATIONS = Object.freeze({
path: "/api/sample/resources", path: "/api/sample/resources",
operationId: "LIST_SAMPLE_RESOURCES", operationId: "LIST_SAMPLE_RESOURCES",
auth: "external-session", auth: "external-session",
timeoutMs: 10_000, timeoutMs: null,
idempotency: "safe", idempotency: "safe",
retry: "runtime",
requestSource: "search",
requestSchema: "SampleResourceListQuery", requestSchema: "SampleResourceListQuery",
responseSchema: "SampleResourceListPayload", responseSchema: "SampleResourceListPayload",
owner: "feature-sample-feature-slice-contract-fixture", owner: "feature-sample-feature-slice-contract-fixture",
@@ -32,8 +36,10 @@ export const API_OPERATIONS = Object.freeze({
path: "/api/sample/resources", path: "/api/sample/resources",
operationId: "CREATE_SAMPLE_RESOURCE", operationId: "CREATE_SAMPLE_RESOURCE",
auth: "external-session", auth: "external-session",
timeoutMs: 10_000, timeoutMs: null,
idempotency: "keyed", idempotency: "keyed",
retry: "runtime",
requestSource: "body",
requestSchema: "CreateSampleResourceCommand", requestSchema: "CreateSampleResourceCommand",
responseSchema: "SampleResourcePayload", responseSchema: "SampleResourcePayload",
owner: "feature-sample-feature-slice-contract-fixture", owner: "feature-sample-feature-slice-contract-fixture",
@@ -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<Value> =
| 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<Value>(
options: Readonly<{
queryKey: readonly unknown[];
execute(context: Readonly<{ signal: AbortSignal }>): Promise<
ApplicationResult<Value>
>;
enabled?: boolean;
}>,
): Readonly<{
data: Value | undefined;
state: AsyncState;
retry(): Promise<void>;
}> {
const { queryKey, execute, enabled = true } = options;
const [staleFailure, setStaleFailure] = useState(false);
const query = useQuery<Value, ApplicationQueryError>({
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<Input, Value>(
options: Readonly<{
execute(input: Input): Promise<ApplicationResult<Value>>;
invalidate?: readonly (readonly unknown[])[];
optimistic?: Readonly<{
queryKey: readonly unknown[];
update(previous: unknown, input: Input): unknown;
}>;
currentData?: unknown;
}>,
): Readonly<{
state: AsyncState;
submit(input: Input): Promise<ApplicationResult<Value>>;
resolveConflict(): Promise<void>;
}> {
const queryClient = useQueryClient();
const { execute, invalidate = [], optimistic, currentData } = options;
const [conflict, setConflict] = useState<ApiFailure | null>(null);
const inFlight = useRef<Promise<ApplicationResult<Value>> | null>(null);
const mutation = useMutation<Value, ApplicationQueryError, Input>({
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<ApplicationResult<Value>> => {
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",
};
}
+5
View File
@@ -0,0 +1,5 @@
export {
useApplicationMutation,
useApplicationQuery,
type ApplicationResult,
} from "./application-query.js";
+23 -9
View File
@@ -65,7 +65,7 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
data-message-key={userMessageKey} data-message-key={userMessageKey}
> >
<h2 id={messageId}>{errorMessage(userMessageKey)}</h2> <h2 id={messageId}>{errorMessage(userMessageKey)}</h2>
{action !== "none" && ( {action !== "none" && onAction && (
<Button onClick={onAction}>{actionLabels[action]}</Button> <Button onClick={onAction}>{actionLabels[action]}</Button>
)} )}
</section> </section>
@@ -76,10 +76,18 @@ export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
* @param {{ * @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>, * state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode, * children?: React.ReactNode,
* onAction?: () => void * onAction?: () => void,
* onRetry?: () => void,
* onResolveConflict?: () => void
* }} props * }} props
*/ */
export function AsyncSurface({ state, children, onAction }) { export function AsyncSurface({
state,
children,
onAction,
onRetry,
onResolveConflict,
}) {
if (state.base === "initial-loading") return <LoadingSurface />; if (state.base === "initial-loading") return <LoadingSurface />;
if (state.base === "empty") return <EmptySurface />; if (state.base === "empty") return <EmptySurface />;
if (state.base === "terminal-error" && state.failure) { if (state.base === "terminal-error" && state.failure) {
@@ -87,18 +95,24 @@ export function AsyncSurface({ state, children, onAction }) {
<TerminalErrorSurface <TerminalErrorSurface
userMessageKey={state.failure.userMessageKey} userMessageKey={state.failure.userMessageKey}
action={state.failure.action} action={state.failure.action}
onAction={onAction} onAction={onRetry ?? onAction}
/> />
); );
} }
return ( return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}> <section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && ( {state.indicator ? (
<p role="status" aria-live="polite"> <div role="status" aria-live="polite">
{state.indicator} <span>{state.indicator}</span>
</p> {state.indicator === "stale-degraded" && onRetry ? (
)} <Button onClick={onRetry}>다시 시도</Button>
) : null}
{state.indicator === "mutation-conflict" && onResolveConflict ? (
<Button onClick={onResolveConflict}>충돌 해결</Button>
) : null}
</div>
) : null}
{children} {children}
</section> </section>
); );
+218
View File
@@ -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 (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
};
}
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<typeof hook.result.current.submit> | null} */
let first = null;
/** @type {ReturnType<typeof hook.result.current.submit> | 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);
});
});
+39 -5
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { render, screen } from "@testing-library/react"; 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 { deriveAsyncState } from "../../src/application/view-models/async-state.js";
import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx";
@@ -29,7 +30,7 @@ describe("async UI state matrix", () => {
expect(deriveAsyncState(signals).indicator).toBe(indicator); 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({ const state = deriveAsyncState({
data: ["value"], data: ["value"],
isFetching: true, isFetching: true,
@@ -38,8 +39,8 @@ describe("async UI state matrix", () => {
}); });
expect(state.indicator).toBe("mutation-conflict"); expect(state.indicator).toBe("mutation-conflict");
expect(state.overlay).toMatchObject({ expect(state.overlay).toMatchObject({
refreshing: true, refreshing: false,
mutationPending: true, mutationPending: false,
mutationConflict: true, mutationConflict: true,
}); });
}); });
@@ -52,12 +53,45 @@ describe("async UI state matrix", () => {
expect(screen.getByRole("status")).toHaveTextContent("refreshing"); 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(
<AsyncSurface state={stale} onRetry={retry}>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "다시 시도" }));
expect(retry).toHaveBeenCalledOnce();
const conflict = deriveAsyncState({
data: ["value"],
hasMutationConflict: true,
});
view.rerender(
<AsyncSurface
state={conflict}
onResolveConflict={resolveConflict}
>
existing content
</AsyncSurface>,
);
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
expect(resolveConflict).toHaveBeenCalledOnce();
});
it("renders only safe error vocabulary", () => { it("renders only safe error vocabulary", () => {
const failure = createFailure("SERVER_FAILURE", "LIST", 0, { const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
code: "SERVER_FAILURE", code: "SERVER_FAILURE",
}); });
const state = deriveAsyncState({ failure }); const state = deriveAsyncState({ failure });
render(<AsyncSurface state={state} />); render(<AsyncSurface state={state} onRetry={vi.fn()} />);
expect(screen.getByRole("alert")).toHaveTextContent( expect(screen.getByRole("alert")).toHaveTextContent(
"요청을 완료하지 못했습니다.", "요청을 완료하지 못했습니다.",
@@ -0,0 +1,3 @@
import { useQuery } from "@tanstack/react-query";
export const leakedQueryHook = useQuery;
+8
View File
@@ -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,
};
@@ -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();
});
});
+54
View File
@@ -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" });
});
});
+63 -2
View File
@@ -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 = { const runtime = {
config: { config: {
@@ -8,6 +11,8 @@ const runtime = {
API_BASE_URL: "http://localhost:8080", API_BASE_URL: "http://localhost:8080",
TELEMETRY_ENABLED: false, TELEMETRY_ENABLED: false,
AUTH_MODE: "demo", AUTH_MODE: "demo",
REQUEST_TIMEOUT_MS: 4321,
MAX_RETRY_ATTEMPTS: 0,
}, },
}; };
const release = /** @type {const} */ ({ const release = /** @type {const} */ ({
@@ -53,4 +58,60 @@ describe("runtime adapter composition", () => {
}); });
expect(adapters.outputPorts.session.getState()).toBe("integration-failed"); 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<typeof createRuntimeAdapters>[0]["runtime"]} */ (
runtime
),
release,
host: {},
})).outputPorts.session;
const client = createRuntimeHttpClient({
runtime:
/** @type {Parameters<typeof createRuntimeHttpClient>[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();
});
}); });
+6 -1
View File
@@ -5,9 +5,14 @@ import { systemClock } from "../../src/adapters/platform/system-clock.js";
describe("systemClock", () => { describe("systemClock", () => {
it("resolves after the requested duration", async () => { it("resolves after the requested duration", async () => {
vi.useFakeTimers(); 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 vi.advanceTimersByTimeAsync(250);
await expect(sleeper).resolves.toBeUndefined(); await expect(sleeper).resolves.toBeUndefined();
expect(add).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });