@fluojs/di 1.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +129 -26
- package/README.md +129 -26
- package/dist/container.d.ts +79 -17
- package/dist/container.d.ts.map +1 -1
- package/dist/container.js +564 -197
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +3 -2
- package/dist/internal.d.ts +18 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +26 -0
- package/dist/multi-contribution-registry.d.ts +26 -0
- package/dist/multi-contribution-registry.d.ts.map +1 -0
- package/dist/multi-contribution-registry.js +29 -0
- package/dist/provider-normalization.d.ts +17 -0
- package/dist/provider-normalization.d.ts.map +1 -0
- package/dist/provider-normalization.js +216 -0
- package/dist/types.d.ts +10 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -4
- package/package.json +9 -5
package/README.ko.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
<p><a href="./README.md"><kbd>English</kbd></a> <strong><kbd>한국어</kbd></strong></p>
|
|
4
4
|
|
|
5
|
+
Node.js 지원 범위는 `>=24.0.0 <27`입니다. 업그레이드 절차는 [Node.js 지원 및 마이그레이션](../../docs/reference/node-support.ko.md)을 참조하세요.
|
|
6
|
+
|
|
5
7
|
모든 fluo 애플리케이션을 구동하는 최소 토큰 기반 의존성 주입 컨테이너입니다.
|
|
6
8
|
|
|
7
9
|
## 목차
|
|
@@ -10,8 +12,10 @@
|
|
|
10
12
|
- [사용 시점](#사용-시점)
|
|
11
13
|
- [빠른 시작](#빠른-시작)
|
|
12
14
|
- [주요 기능](#주요-기능)
|
|
15
|
+
- [NestJS scope 및 optional 의존성 마이그레이션](#nestjs-scope-및-optional-의존성-마이그레이션)
|
|
13
16
|
- [순환 의존성 처리](#순환-의존성-처리)
|
|
14
17
|
- [테스트 및 모킹](#테스트-및-모킹)
|
|
18
|
+
- [내부 패키지 통합](#내부-패키지-통합)
|
|
15
19
|
- [문제 해결](#문제-해결)
|
|
16
20
|
- [공개 API](#공개-api)
|
|
17
21
|
- [관련 패키지](#관련-패키지)
|
|
@@ -67,7 +71,7 @@ const service = await container.resolve(UserService);
|
|
|
67
71
|
|
|
68
72
|
- **클래스 provider**: `container.register(MyService)` 또는 `{ provide, useClass }`
|
|
69
73
|
- **값 provider**: `{ provide: 'API_URL', useValue: 'https://api.example.com' }`
|
|
70
|
-
- **팩토리 provider**: `{ provide, useFactory, inject }`
|
|
74
|
+
- **팩토리 provider**: `{ provide, useFactory, inject }`. 팩토리가 참조 클래스의 `@Scope(...)` 같은 DI metadata를 상속해야 하고 provider `scope`를 명시하지 않았다면 `resolverClass`를 함께 지정합니다.
|
|
71
75
|
- **별칭(Alias) provider**: `{ provide: ILogger, useExisting: PinoLogger }`를 사용하여 하나의 토큰을 기존에 등록된 다른 provider로 매핑할 수 있습니다.
|
|
72
76
|
|
|
73
77
|
### scope-aware 수명 주기 관리
|
|
@@ -76,11 +80,59 @@ const service = await container.resolve(UserService);
|
|
|
76
80
|
- **request**: `createRequestScope()`마다 새로 생성됩니다.
|
|
77
81
|
- **transient**: resolve할 때마다 새 인스턴스를 만듭니다.
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
singleton provider는 request-scoped provider에 의존할 수 없습니다. 이 mismatch는 그래프의 어떤 provider factory나 constructor도 실행되기 전에 `ScopeMismatchError`를 던지며, 이 검사는 single, alias(`useExisting`), multi-provider 등록을 모두 포함합니다. singleton이 multi token을 주입받을 때도 해당 token의 contribution 중 하나라도 request scope이면 같은 방식으로 실패하므로, contribution 일부만 materialize되는 일이 없습니다.
|
|
84
|
+
|
|
85
|
+
dispose 중에는 각 컨테이너가 single-provider cache와 multi-provider cache 전체에서 성공적으로 materialize된 cached instance를 실제 생성 순서의 역순으로 정리하므로, dependency보다 dependent를 먼저 종료합니다. 각 컨테이너는 자신이 소유한 살아 있는 request scope 자식을 먼저 재귀적으로 정리하므로, 루트가 아닌 request scope를 dispose해도 중첩 request scope를 닫은 뒤 자신의 request cache를 정리합니다. 이후 루트 dispose는 자식 dispose 중 하나 이상이 실패하더라도 루트가 소유한 singleton 정리를 계속 수행합니다. 자식/루트 dispose 실패가 여러 개 발생하면 `dispose()`는 모든 shutdown 실패를 확인할 수 있도록 `AggregateError`로 보고합니다.
|
|
86
|
+
|
|
87
|
+
`dispose()` 시작은 `resolve()`, `register()`, `override()`, `createRequestScope()`에 대해 terminal입니다. 동시 caller는 active disposal 시도를 공유합니다. `onDestroy()` hook이 실패하면 컨테이너는 실패한 hook만 이후 명시적 `dispose()` 재시도를 위해 유지하면서 child-before-parent/root 순서와 생성 역순을 보존합니다. 성공적으로 완료된 hook은 다시 실행하지 않으며, 유지된 hook이 모두 성공한 뒤 disposal은 멱등입니다.
|
|
88
|
+
|
|
89
|
+
#### disposal 재시도 ownership
|
|
90
|
+
|
|
91
|
+
Disposal 재시도는 다음 다섯 ownership 규칙을 따릅니다.
|
|
92
|
+
|
|
93
|
+
1. public `child.dispose()`를 직접 호출하면 request child는 active attempt가 settle된 뒤 parent graph에서 분리됩니다. 유지된 `onDestroy()` hook이 실패해도 분리됩니다.
|
|
94
|
+
2. 분리된 child 참조를 유지한 caller는 `dispose()`를 다시 호출할 수 있습니다. 이 호출은 해당 child의 실패한 hook만 재시도하며 성공한 sibling hook은 반복하지 않습니다.
|
|
95
|
+
3. parent 또는 root disposal이 먼저 진입한 child는 실패 후에도 parent가 계속 추적합니다. 이후 parent 또는 root `dispose()`는 parent나 root가 유지한 hook보다 그 child를 먼저 재시도합니다.
|
|
96
|
+
4. 동시 direct caller와 parent caller는 하나의 active attempt를 공유합니다. shared attempt를 시작한 caller가 direct 또는 parent ownership을 결정합니다. 나중에 참여한 caller는 이를 바꿀 수 없습니다.
|
|
97
|
+
5. parent가 유지한 child를 나중에 `child.dispose()`로 직접 재시도하면 해당 direct attempt가 settle된 뒤 child를 분리합니다. direct 재시도가 다시 실패해도 분리됩니다.
|
|
98
|
+
|
|
99
|
+
실행 가능한 근거는 graph ownership을 검증하는 `packages/di/src/container-disposal-ownership.test.ts`와 failed-hook ordering 및 idempotency를 검증하는 `packages/di/src/container-disposal-retry.test.ts`에 있습니다.
|
|
100
|
+
|
|
101
|
+
### 2.x에서 3.x로 disposal 마이그레이션
|
|
102
|
+
|
|
103
|
+
`@fluojs/di` 2.x에서는 실패한 container-managed `onDestroy()` hook을 한 번만 시도했습니다. 3.x에서는 이후 명시적 `Container.dispose()` 호출이나 동일한 컨테이너에 도달하는 application/application-context `close()`가 실패한 hook만 재시도합니다. 이미 성공적으로 완료된 hook은 exactly-once를 유지합니다. 업그레이드하기 전에 실패할 수 있는 cleanup hook이 다시 시도되어도 안전하도록 만드세요. 부분 cleanup을 끝내는 데 필요한 상태를 보존하고, 이미 해제된 resource를 허용하며, 반복된 실패를 shutdown caller에게 전달해야 합니다.
|
|
104
|
+
|
|
105
|
+
direct `child.dispose()`는 이제 실패한 attempt를 포함해 attempt가 settle된 뒤 request child를 parent에서 분리합니다. direct caller가 해당 실패를 확인하거나 재시도해야 한다면 child 참조를 유지하세요. parent 또는 root가 시작한 disposal의 실패는 cleanup이 성공하거나 이후 direct child attempt가 settle될 때까지 parent hierarchy가 소유합니다. direct caller와 parent caller가 겹치면 shared attempt를 시작한 caller가 detach와 retry semantics를 소유합니다.
|
|
80
106
|
|
|
81
107
|
### provider override
|
|
82
108
|
|
|
83
|
-
테스트나 request-local 경계에서 기존 등록을 의도적으로 교체해야 할 때는 `override(...providers)`를 사용합니다. override는 각 토큰의 현재 provider set을 교체하고 현재 컨테이너와 이미 materialize된 request-scope 자식의 cached instance를 무효화하며, 오래된 instance
|
|
109
|
+
테스트나 request-local 경계에서 기존 등록을 의도적으로 교체해야 할 때는 `override(...providers)`를 사용합니다. override는 각 토큰의 현재 provider set을 교체하고 현재 컨테이너와 이미 materialize된 request-scope 자식의 cached instance를 무효화하며, 다음 replacement resolution이 계속되기 전에 오래된 instance의 dispose가 끝나도록 보장합니다. multi provider override는 해당 토큰의 전체 multi-provider set을 교체하므로 필요한 replacement provider를 한 번에 모두 전달하세요. 같은 토큰에 single replacement와 multi replacement를 한 override 호출에서 섞으면 모호한 교체로 보고 거부합니다. override 호출은 원자적입니다. 배치 전체를 검증한 뒤에야 등록과 캐시를 변경하므로, 거부된 호출은 모든 provider와 cached instance, disposal 소유권을 호출 이전 상태 그대로 남깁니다.
|
|
110
|
+
|
|
111
|
+
### 컨테이너 생성 경계
|
|
112
|
+
|
|
113
|
+
공개된 생성 형태는 `new Container()` 하나뿐이며, 항상 자신의 singleton cache를 소유하는 루트 컨테이너를 만듭니다. child request scope는 package가 소유합니다. parent 연결, request-scope flag, singleton cache 공유는 `createRequestScope()`로만 도달할 수 있는 private construction path입니다.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const root = new Container();
|
|
117
|
+
const requestScope = root.createRequestScope();
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
constructor 인자 전달은 거부됩니다. emitted declaration은 할당 가능한 인자 타입을 받지 않으며, 런타임에서도 caller가 인자를 넘기면 cache ownership을 빌린 컨테이너를 만드는 대신 `ContainerResolutionError`를 던집니다.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// 거부됨: child-scope wiring은 package가 소유합니다.
|
|
124
|
+
Reflect.construct(Container, [root]);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2.x에서 3.x로 컨테이너 생성 마이그레이션
|
|
128
|
+
|
|
129
|
+
`@fluojs/di` 2.x에서는 caller가 child wiring을 직접 넘기는 것이 지원되는 workflow가 아니었음에도, emitted `Container` declaration이 `parent`, `requestScopeEnabled`, `singletonCache` constructor parameter를 노출했습니다. 3.x에서는 이 surface를 봉쇄합니다. constructor는 인자를 받지 않으며, 인자를 전달하면 `ContainerResolutionError`를 던집니다.
|
|
130
|
+
|
|
131
|
+
인자 없는 `new Container()`와 `createRequestScope()`는 그대로이므로 지원되는 코드는 마이그레이션이 필요 없습니다. child 컨테이너를 직접 생성했다면 해당 호출을 `parent.createRequestScope()`로 바꾸세요. 동일한 parent 연결, request-scope flag, 공유 root singleton cache를 제공하면서 disposal ownership도 그대로 유지합니다.
|
|
132
|
+
|
|
133
|
+
실행 가능한 근거는 `packages/di/src/container-construction-boundary.test.ts`에 있습니다.
|
|
134
|
+
|
|
135
|
+
실패한 stale `onDestroy()` hook도 일반 disposal과 동일한 retained-retry 계약을 따릅니다. observing container의 다음 resolution이 그 실패를 한 번 노출해 replacement가 계속될 수 있게 하며, 실패한 instance는 해당 cleanup을 예약한 container가 이후 명시적 `dispose()`로 hook을 다시 호출할 때까지 retain됩니다. 이미 성공한 stale hook은 다시 실행하지 않습니다.
|
|
84
136
|
|
|
85
137
|
### request scope 분리
|
|
86
138
|
|
|
@@ -91,7 +143,28 @@ const scopedService = await requestContainer.resolve(RequestScopedService);
|
|
|
91
143
|
|
|
92
144
|
request scope 컨테이너는 부모 체인의 provider를 해석할 수 있지만, request가 소유하는 등록은 새 singleton provider를 만들 수 없습니다. singleton provider는 request scope를 만들기 전에 루트 컨테이너에 등록하세요. request scope에 로컬 provider를 추가해야 한다면 `scope: 'request'`/`Scope.REQUEST`를 명시하거나 `override()`로 의도적인 request-local 교체를 표현하세요. multi provider에도 같은 규칙이 적용됩니다. 기본 scope의 multi provider는 루트 컨테이너에 등록하고, request-local multi provider는 request scope를 명시하거나 `override()`로 교체해야 합니다.
|
|
93
145
|
|
|
94
|
-
provider 객체는 등록 시점에 검증됩니다. 모든 객체 provider는
|
|
146
|
+
provider 객체는 등록 시점에 검증됩니다. 모든 객체 provider는 string, symbol 또는 constructable class `provide` 토큰과 정확히 하나의 전략(`useClass`, `useValue`, `useFactory`, `useExisting`)을 포함해야 합니다. alias provider의 `useExisting`에도 동일한 유효 토큰 형태가 필요합니다. class provider에서 `inject`를 생략하거나 `undefined`로 지정하면 `useClass`의 `@Inject(...)` 메타데이터로 fallback하며, 그 밖의 명시적 `inject` 값은 유효한 token 또는 올바른 `forwardRef(...)` / `optional(...)` wrapper로 구성된 배열이어야 합니다. value provider는 `inject`를 생략해야 하며, 값이 `undefined`인 경우에도 자체 속성으로 선언하면 거부됩니다. 명시적인 `scope` 값은 `singleton`, `request`, `transient` 중 하나여야 합니다. 잘못된 provider 형태는 컨테이너 그래프에 영향을 주기 전에 `InvalidProviderError`를 발생시킵니다.
|
|
147
|
+
|
|
148
|
+
## NestJS scope 및 optional 의존성 마이그레이션
|
|
149
|
+
|
|
150
|
+
NestJS `@Injectable({ scope: Scope.REQUEST })`와 `@Injectable({ scope: Scope.TRANSIENT })`는 `@Scope('request')` / `@Scope('transient')` 또는 명시적 provider `scope: 'request'` / `scope: 'transient'`를 가진 fluo provider로 매핑합니다. Singleton은 기본값으로 유지됩니다.
|
|
151
|
+
|
|
152
|
+
fluo는 NestJS scope bubbling을 구현하지 않습니다. Request-scoped provider는 `createRequestScope()` child container에서 resolve하세요. Root에서 resolve하면 `RequestScopeResolutionError`가 발생하고, request-scoped provider에 의존하는 singleton은 `ScopeMismatchError`를 발생시킵니다.
|
|
153
|
+
|
|
154
|
+
NestJS `@Optional()`은 클래스 수준 `@Inject(...)` 목록 또는 provider `inject` 배열의 `optional(Token)`으로 매핑합니다. `optional(...)`은 decorator가 아닌 token wrapper이고, 등록이 없으면 `undefined`로 resolve됩니다.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { Inject, Scope } from '@fluojs/core';
|
|
158
|
+
import { optional } from '@fluojs/di';
|
|
159
|
+
|
|
160
|
+
class AuditLogger {}
|
|
161
|
+
|
|
162
|
+
@Scope('request')
|
|
163
|
+
@Inject(optional(AuditLogger))
|
|
164
|
+
class RequestAuditService {
|
|
165
|
+
constructor(private readonly auditLogger: AuditLogger | undefined) {}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
95
168
|
|
|
96
169
|
## 순환 의존성 처리
|
|
97
170
|
|
|
@@ -129,24 +202,52 @@ class ServiceWithOptionalLogger {
|
|
|
129
202
|
|
|
130
203
|
## 테스트 및 모킹
|
|
131
204
|
|
|
132
|
-
`useValue`를
|
|
205
|
+
먼저 전체 의존성 그래프를 등록한 다음, `override(...)`와 `useValue`를 사용해 기존 provider를 mock이나 stub으로 교체하세요. `register(...)`는 새 provider를 추가하며 중복 토큰을 거부하고, `override(...)`는 지원되는 교체 API입니다.
|
|
133
206
|
|
|
134
207
|
```typescript
|
|
208
|
+
import { Inject } from '@fluojs/core';
|
|
135
209
|
import { Container } from '@fluojs/di';
|
|
210
|
+
import { expect, it, vi } from 'vitest';
|
|
136
211
|
|
|
137
|
-
|
|
138
|
-
|
|
212
|
+
class Database {
|
|
213
|
+
async query(): Promise<readonly string[]> {
|
|
214
|
+
return ['real row'];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
139
217
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
218
|
+
@Inject(Database)
|
|
219
|
+
class DataService {
|
|
220
|
+
constructor(private readonly database: Database) {}
|
|
221
|
+
|
|
222
|
+
async load(): Promise<readonly string[]> {
|
|
223
|
+
return this.database.query();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
145
226
|
|
|
146
|
-
|
|
147
|
-
|
|
227
|
+
it('uses a mock database', async () => {
|
|
228
|
+
const mockDb = { query: vi.fn().mockResolvedValue(['mock row']) };
|
|
229
|
+
const container = new Container().register(Database, DataService);
|
|
230
|
+
|
|
231
|
+
container.override({
|
|
232
|
+
provide: Database,
|
|
233
|
+
useValue: mockDb,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const service = await container.resolve(DataService);
|
|
237
|
+
|
|
238
|
+
await expect(service.load()).resolves.toEqual(['mock row']);
|
|
239
|
+
expect(mockDb.query).toHaveBeenCalledOnce();
|
|
240
|
+
});
|
|
148
241
|
```
|
|
149
242
|
|
|
243
|
+
## 내부 패키지 통합
|
|
244
|
+
|
|
245
|
+
`@fluojs/di/internal`은 first-party framework package를 위한 typed integration
|
|
246
|
+
seam입니다. 이 경로는 owning container를 통해 순서가 있는 `multi: true`
|
|
247
|
+
contribution 하나를 해석하며, container의 scope, cache, cycle, ordering, disposal
|
|
248
|
+
semantics를 보존합니다. 애플리케이션 코드는 `Container.resolve(...)`를 사용해야 하며,
|
|
249
|
+
contribution index는 root `Container` API에 속하지 않습니다.
|
|
250
|
+
|
|
150
251
|
## 문제 해결
|
|
151
252
|
|
|
152
253
|
### CircularDependencyError
|
|
@@ -157,17 +258,17 @@ const service = await container.resolve(DataService);
|
|
|
157
258
|
|
|
158
259
|
## 공개 API
|
|
159
260
|
|
|
160
|
-
|
|
|
161
|
-
|
|
162
|
-
| `Container` | 메인 DI 컨테이너 클래스입니다. |
|
|
163
|
-
| `register(...providers)` | 하나 이상의 프로바이더를 등록합니다. |
|
|
164
|
-
| `override(...providers)` | 기존 provider를 교체하고 cached instance를 무효화하며 오래된 instance
|
|
165
|
-
| `resolve<T>(token)` | 토큰을 인스턴스로 비동기 해석합니다. |
|
|
166
|
-
| `inspectResolutionState()` | cache ownership을 보존해야 하는 testing/tooling helper를 위한 지원 대상 framework-owned container introspection seam을 노출합니다. 애플리케이션 코드는 `has(...)`와 `resolve(...)`를 우선 사용하세요. |
|
|
167
|
-
| `createRequestScope()` | 요청 스코프 의존성을 위한 자식 컨테이너를 생성합니다. |
|
|
168
|
-
| `has(token)` | 컨테이너나 부모에 토큰이 등록되어 있는지 확인합니다. |
|
|
169
|
-
| `hasRequestScopedDependency(token)` | 토큰 해석 시 provider 그래프에 request-scoped 의존성이나 순환이 있어 request-scope 컨테이너가 필요할 수 있는지 확인합니다. |
|
|
170
|
-
| `dispose()` | request child
|
|
261
|
+
| Surface | 종류 | 설명 |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `Container` | Root export | 메인 DI 컨테이너 클래스입니다. `new Container()`는 인자를 받지 않고 루트 컨테이너를 만들며, child request scope는 package가 소유하고 `createRequestScope()`로 생성합니다. constructor 인자를 전달하면 `ContainerResolutionError`를 던집니다. |
|
|
264
|
+
| `container.register(...providers)` | `Container` instance method | 하나 이상의 프로바이더를 등록합니다. |
|
|
265
|
+
| `container.override(...providers)` | `Container` instance method | 호출 단위로 원자적으로 기존 provider를 교체하고 cached instance를 무효화하며 다음 replacement resolution이 계속되기 전에 오래된 instance dispose가 settle되도록 보장합니다. |
|
|
266
|
+
| `container.resolve<T>(token)` | `Container` instance method | 토큰을 인스턴스로 비동기 해석합니다. |
|
|
267
|
+
| `container.inspectResolutionState()` | `Container` instance method | snapshot read-only map view, frozen provider record, controlled cache adoption을 통해 cache ownership을 보존해야 하는 testing/tooling helper를 위한 지원 대상 framework-owned container introspection seam을 노출합니다. 애플리케이션 코드는 `has(...)`와 `resolve(...)`를 우선 사용하세요. |
|
|
268
|
+
| `container.createRequestScope()` | `Container` instance method | 요청 스코프 의존성을 위한 자식 컨테이너를 생성합니다. parent에 연결되고 request scope가 활성화되며 root singleton cache를 공유하는 컨테이너를 얻는 유일한 지원 경로입니다. |
|
|
269
|
+
| `container.has(token)` | `Container` instance method | 컨테이너나 부모에 토큰이 등록되어 있는지 확인합니다. |
|
|
270
|
+
| `container.hasRequestScopedDependency(token)` | `Container` instance method | 토큰 해석 시 provider 그래프에 request-scoped 의존성이나 순환이 있어 request-scope 컨테이너가 필요할 수 있는지 확인합니다. |
|
|
271
|
+
| `container.dispose()` | `Container` instance method | parent/root cache보다 request child를 먼저 정리하고 active 시도를 공유하며, 이후 명시적 호출에서 실패한 `onDestroy()` hook만 재시도합니다. |
|
|
171
272
|
| `forwardRef(fn)` | 선언 순서 문제를 위해 조회를 지연하는 토큰 래퍼를 반환합니다. 실제 생성자 순환을 해석 가능하게 만들지는 않습니다. |
|
|
172
273
|
| `isForwardRef(value)` | `forwardRef(...)`가 만든 값인지 확인하는 type guard입니다. 커스텀 provider tooling이 DI token wrapper와 통합될 때 사용할 수 있습니다. |
|
|
173
274
|
| `optional(token)` | 하나의 의존성을 optional로 표시하는 토큰 래퍼를 반환합니다. 누락된 optional dependency는 `undefined`로 해석됩니다. |
|
|
@@ -176,8 +277,10 @@ const service = await container.resolve(DataService);
|
|
|
176
277
|
| Provider types | `Provider`, `ClassProvider`, `FactoryProvider`, `ValueProvider`, `ExistingProvider`는 `register(...)`와 `override(...)`가 받는 공개 registration shape를 설명합니다. |
|
|
177
278
|
| Token wrapper types | `ForwardRefFn`과 `OptionalToken`은 `forwardRef(...)`와 `optional(...)`이 반환하는 wrapper 값을 설명합니다. |
|
|
178
279
|
| Container helper types | `ClassType`, `Disposable`, `RequestScopeContainer`는 typed provider 선언, teardown hook, request-scope helper 경계를 지원합니다. |
|
|
179
|
-
|
|
|
280
|
+
| Container introspection helper types | `ContainerResolutionState`, `ContainerResolutionCacheOwner`, `ContainerFactoryResolutionState`는 `inspectResolutionState()`가 반환하는 read-only graph/cache view와 controlled cache adoption helper를 설명합니다. |
|
|
281
|
+
| `FactoryResolutionKind` | Root export | container 진단과 introspection을 위해 factory provider가 동기적으로 반환했는지(`sync`) 또는 promise를 통해 반환했는지(`async`)를 분류합니다. |
|
|
180
282
|
| `NormalizedProvider` | 컨테이너가 검증한 provider record shape를 위한 compatibility-only 공개 타입입니다. provider를 작성할 때는 `Provider`나 구체 provider interface를 우선 사용하세요. normalized record 생성은 컨테이너가 소유합니다. |
|
|
283
|
+
| `@fluojs/di/internal` | sibling fluo package가 자체 순회 전에 컨테이너의 canonical provider validation을 적용할 수 있도록 `validateProviderInputs(...)`를 노출하는 package-integration seam입니다. 애플리케이션 코드는 계속 `Container`를 통해 provider를 등록해야 합니다. |
|
|
181
284
|
| `DiErrorContext` | DI error에 붙는 구조화된 context입니다. 로그와 테스트가 token, scope, module, dependency chain, hint를 검사할 수 있게 합니다. |
|
|
182
285
|
| 에러 클래스 | `InvalidProviderError`, `ContainerResolutionError`, `RequestScopeResolutionError`, `ScopeMismatchError`, `CircularDependencyError`, `DuplicateProviderError`. |
|
|
183
286
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
<p><strong><kbd>English</kbd></strong> <a href="./README.ko.md"><kbd>한국어</kbd></a></p>
|
|
4
4
|
|
|
5
|
+
Node.js support is `>=24.0.0 <27`. See [Node.js support and migration](../../docs/reference/node-support.md) before upgrading.
|
|
6
|
+
|
|
5
7
|
Minimal token-based dependency injection container powering every fluo application.
|
|
6
8
|
|
|
7
9
|
## Table of Contents
|
|
@@ -10,8 +12,10 @@ Minimal token-based dependency injection container powering every fluo applicati
|
|
|
10
12
|
- [When to Use](#when-to-use)
|
|
11
13
|
- [Quick Start](#quick-start)
|
|
12
14
|
- [Key Capabilities](#key-capabilities)
|
|
15
|
+
- [NestJS Scope and Optional Dependency Migration](#nestjs-scope-and-optional-dependency-migration)
|
|
13
16
|
- [Circular Dependency Handling](#circular-dependency-handling)
|
|
14
17
|
- [Testing and Mocking](#testing-and-mocking)
|
|
18
|
+
- [Internal Package Integrations](#internal-package-integrations)
|
|
15
19
|
- [Troubleshooting](#troubleshooting)
|
|
16
20
|
- [Public API](#public-api)
|
|
17
21
|
- [Related Packages](#related-packages)
|
|
@@ -67,7 +71,7 @@ const result = await service.getStatus();
|
|
|
67
71
|
fluo DI supports four provider shapes:
|
|
68
72
|
- **Class Providers**: `container.register(MyService)` or `{ provide: MyToken, useClass: MyService }`.
|
|
69
73
|
- **Value Providers**: `{ provide: 'API_URL', useValue: 'https://api.example.com' }`.
|
|
70
|
-
- **Factory Providers**: `{ provide: 'ASYNC_CONFIG', useFactory: async (db) => await db.load(), inject: [Database] }`.
|
|
74
|
+
- **Factory Providers**: `{ provide: 'ASYNC_CONFIG', useFactory: async (db) => await db.load(), inject: [Database] }`. Add `resolverClass` when the factory should inherit the referenced class's DI metadata, such as `@Scope(...)`, unless an explicit provider `scope` is set.
|
|
71
75
|
- **Alias Providers**: `{ provide: ILogger, useExisting: PinoLogger }` allows mapping one token to another existing provider.
|
|
72
76
|
|
|
73
77
|
### Scope Management
|
|
@@ -75,11 +79,59 @@ fluo DI supports four provider shapes:
|
|
|
75
79
|
- **Request**: Instance is created once per `createRequestScope()` call.
|
|
76
80
|
- **Transient**: A new instance is created every time it is resolved.
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
A singleton provider must not depend on a request-scoped provider. That mismatch throws `ScopeMismatchError` before any provider factory or constructor in the graph runs, and the check covers single, alias (`useExisting`), and multi-provider registrations. A singleton that injects a multi token fails the same way when any contribution under that token is request-scoped, so no partial contribution set is materialized first.
|
|
83
|
+
|
|
84
|
+
During disposal, each container tears down successfully materialized cached instances in reverse creation order across single-provider and multi-provider caches, so dependents are destroyed before their dependencies. Each container first recursively tears down live request-scope children it owns, so disposing a non-root request scope also closes nested request scopes before its own request cache. Root disposal then continues with root-owned singleton cleanup even if one or more child disposals fail. When multiple child/root disposals fail, `dispose()` reports an `AggregateError` so callers can inspect every shutdown failure without losing cleanup progress.
|
|
85
|
+
|
|
86
|
+
Starting `dispose()` is terminal for `resolve()`, `register()`, `override()`, and `createRequestScope()`. Concurrent callers share the active disposal attempt. If an `onDestroy()` hook fails, the container retains only that failed hook for a later explicit `dispose()` retry, preserving child-before-parent/root and reverse-creation ordering. Hooks that completed successfully are never run again, and disposal becomes idempotent after every retained hook succeeds.
|
|
87
|
+
|
|
88
|
+
#### Disposal retry ownership
|
|
89
|
+
|
|
90
|
+
Disposal retries follow five ownership rules:
|
|
91
|
+
|
|
92
|
+
1. Calling public `child.dispose()` directly detaches the request child from its parent graph after the active attempt settles, even when retained `onDestroy()` hooks failed.
|
|
93
|
+
2. A retained child reference can call `dispose()` again to retry only that child's failed hooks. Successful sibling hooks are not repeated.
|
|
94
|
+
3. A child first reached through parent or root disposal remains parent-tracked after failure, so a later parent or root `dispose()` retries it before retained hooks in the parent or root.
|
|
95
|
+
4. Concurrent direct and parent callers share one active attempt. The caller that starts the shared attempt sets its direct or parent ownership, and later callers cannot change it.
|
|
96
|
+
5. A later direct retry detaches a parent-retained child after settlement, even when that retry fails.
|
|
97
|
+
|
|
98
|
+
Executable evidence lives in `packages/di/src/container-disposal-ownership.test.ts` for graph ownership and `packages/di/src/container-disposal-retry.test.ts` for failed-hook ordering and idempotency.
|
|
99
|
+
|
|
100
|
+
### Migrating disposal from 2.x to 3.x
|
|
101
|
+
|
|
102
|
+
In `@fluojs/di` 2.x, a failed container-managed `onDestroy()` hook was attempted once. In 3.x, a later explicit `Container.dispose()` call or application/application-context `close()` that reaches the same container retries only hooks that failed. Hooks that already completed successfully remain exactly-once. Before upgrading, make cleanup hooks that can fail safe to attempt again: preserve enough state to finish partial cleanup, tolerate resources that were already released, and surface a repeated failure to the shutdown caller.
|
|
103
|
+
|
|
104
|
+
Direct `child.dispose()` now detaches the request child from its parent after the attempt settles, including a failed attempt. Retain the child reference when the direct caller must inspect or retry that failure. A failure from parent- or root-started disposal remains owned by the parent hierarchy until cleanup succeeds or a later direct child attempt settles. When direct and parent callers overlap, the caller that starts the shared attempt owns those detach and retry semantics.
|
|
79
105
|
|
|
80
106
|
### Provider Overrides
|
|
81
107
|
|
|
82
|
-
Use `override(...providers)` when a test or request-local boundary needs to replace existing registrations deliberately. Overrides replace the current provider set for each token, invalidate cached instances in the current container and already-materialized request-scope descendants, and dispose stale instances
|
|
108
|
+
Use `override(...providers)` when a test or request-local boundary needs to replace existing registrations deliberately. Overrides replace the current provider set for each token, invalidate cached instances in the current container and already-materialized request-scope descendants, and dispose stale instances before the next replacement resolution continues. Multi-provider overrides replace the full multi-provider set for that token, so pass every replacement provider together; mixing single and multi replacements for the same token in one override call is rejected as ambiguous. An override call is atomic: the whole batch is validated before any registration or cache changes, so a rejected call leaves every provider, cached instance, and disposal ownership exactly as it was.
|
|
109
|
+
|
|
110
|
+
### Container Construction Boundary
|
|
111
|
+
|
|
112
|
+
`new Container()` is the only supported public construction form, and it always creates a root container that owns its own singleton cache. Child request scopes are package-owned: parent linkage, the request-scope flag, and singleton-cache sharing use a private construction path reachable only through `createRequestScope()`.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const root = new Container();
|
|
116
|
+
const requestScope = root.createRequestScope();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Supplying constructor arguments is rejected. The emitted declaration accepts no assignable argument type, and at runtime a caller-supplied argument throws `ContainerResolutionError` rather than producing a container with borrowed cache ownership.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// Rejected: child-scope wiring is package-owned.
|
|
123
|
+
Reflect.construct(Container, [root]);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Migrating container construction from 2.x to 3.x
|
|
127
|
+
|
|
128
|
+
In `@fluojs/di` 2.x, the emitted `Container` declaration exposed `parent`, `requestScopeEnabled`, and `singletonCache` constructor parameters even though caller-supplied child wiring was never a supported application workflow. In 3.x that surface is sealed: the constructor accepts no arguments, and passing any argument throws `ContainerResolutionError`.
|
|
129
|
+
|
|
130
|
+
Zero-argument `new Container()` and `createRequestScope()` are unchanged, so supported code needs no migration. If you constructed child containers directly, replace that call with `parent.createRequestScope()`, which supplies the same parent linkage, request-scope flag, and shared root singleton cache while keeping disposal ownership intact.
|
|
131
|
+
|
|
132
|
+
Executable evidence lives in `packages/di/src/container-construction-boundary.test.ts`.
|
|
133
|
+
|
|
134
|
+
A failed stale `onDestroy()` hook follows the same retained-retry contract as ordinary disposal. The next resolution on an observing container surfaces that failure once so the replacement can continue, and the failed instance stays retained by the container that scheduled its cleanup until a later explicit `dispose()` on that container invokes the hook again. Stale hooks that already completed successfully are never repeated.
|
|
83
135
|
|
|
84
136
|
### Request Scoping
|
|
85
137
|
Isolated containers can be created to handle per-request state without polluting the root container.
|
|
@@ -91,7 +143,28 @@ const scopedService = await requestContainer.resolve(RequestScopedService);
|
|
|
91
143
|
|
|
92
144
|
Request-scope containers may resolve providers from their parent chain, but request-owned registrations must not introduce new singleton providers. Register singleton providers on the root container before creating request scopes. If a request scope needs local additions, declare them with `scope: 'request'`/`Scope.REQUEST` or use `override()` for an explicit request-local replacement. The same rule applies to multi providers: default-scope multi providers belong on the root container, while request-local multi providers must opt into request scope or be replaced through `override()`.
|
|
93
145
|
|
|
94
|
-
Provider objects are validated at registration time: every object provider must include a
|
|
146
|
+
Provider objects are validated at registration time: every object provider must include a string, symbol, or constructable class `provide` token and exactly one strategy (`useClass`, `useValue`, `useFactory`, or `useExisting`). Alias providers require the same valid token forms for `useExisting`. For class providers, an omitted or `undefined` `inject` value falls back to the `useClass` `@Inject(...)` metadata; any other explicit `inject` value must be an array containing valid tokens or well-formed `forwardRef(...)` / `optional(...)` wrappers. Value providers must omit `inject`; declaring it as an own property is rejected even when its value is `undefined`. Explicit `scope` values must be `singleton`, `request`, or `transient`. Invalid provider shapes throw `InvalidProviderError` before they can affect the container graph.
|
|
147
|
+
|
|
148
|
+
## NestJS Scope and Optional Dependency Migration
|
|
149
|
+
|
|
150
|
+
NestJS `@Injectable({ scope: Scope.REQUEST })` and `@Injectable({ scope: Scope.TRANSIENT })` map to a fluo provider with `@Scope('request')` / `@Scope('transient')`, or an explicit provider `scope: 'request'` / `scope: 'transient'`. Singleton remains the default.
|
|
151
|
+
|
|
152
|
+
fluo does not implement NestJS scope bubbling. Resolve request-scoped providers from a `createRequestScope()` child container: root resolution throws `RequestScopeResolutionError`, and a singleton that depends on a request-scoped provider throws `ScopeMismatchError`.
|
|
153
|
+
|
|
154
|
+
NestJS `@Optional()` maps to `optional(Token)` in a class-level `@Inject(...)` list or a provider `inject` array. `optional(...)` is a token wrapper, not a decorator, and a missing registration resolves to `undefined`.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { Inject, Scope } from '@fluojs/core';
|
|
158
|
+
import { optional } from '@fluojs/di';
|
|
159
|
+
|
|
160
|
+
class AuditLogger {}
|
|
161
|
+
|
|
162
|
+
@Scope('request')
|
|
163
|
+
@Inject(optional(AuditLogger))
|
|
164
|
+
class RequestAuditService {
|
|
165
|
+
constructor(private readonly auditLogger: AuditLogger | undefined) {}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
95
168
|
|
|
96
169
|
## Circular Dependency Handling
|
|
97
170
|
|
|
@@ -129,24 +202,52 @@ class ServiceWithOptionalLogger {
|
|
|
129
202
|
|
|
130
203
|
## Testing and Mocking
|
|
131
204
|
|
|
132
|
-
|
|
205
|
+
Register the complete dependency graph first, then use `override(...)` with `useValue` to replace an existing provider with a mock or stub. `register(...)` adds new providers and rejects duplicate tokens; `override(...)` is the supported replacement API.
|
|
133
206
|
|
|
134
207
|
```typescript
|
|
208
|
+
import { Inject } from '@fluojs/core';
|
|
135
209
|
import { Container } from '@fluojs/di';
|
|
210
|
+
import { expect, it, vi } from 'vitest';
|
|
136
211
|
|
|
137
|
-
|
|
138
|
-
|
|
212
|
+
class Database {
|
|
213
|
+
async query(): Promise<readonly string[]> {
|
|
214
|
+
return ['real row'];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
139
217
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
218
|
+
@Inject(Database)
|
|
219
|
+
class DataService {
|
|
220
|
+
constructor(private readonly database: Database) {}
|
|
221
|
+
|
|
222
|
+
async load(): Promise<readonly string[]> {
|
|
223
|
+
return this.database.query();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
145
226
|
|
|
146
|
-
|
|
147
|
-
|
|
227
|
+
it('uses a mock database', async () => {
|
|
228
|
+
const mockDb = { query: vi.fn().mockResolvedValue(['mock row']) };
|
|
229
|
+
const container = new Container().register(Database, DataService);
|
|
230
|
+
|
|
231
|
+
container.override({
|
|
232
|
+
provide: Database,
|
|
233
|
+
useValue: mockDb,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const service = await container.resolve(DataService);
|
|
237
|
+
|
|
238
|
+
await expect(service.load()).resolves.toEqual(['mock row']);
|
|
239
|
+
expect(mockDb.query).toHaveBeenCalledOnce();
|
|
240
|
+
});
|
|
148
241
|
```
|
|
149
242
|
|
|
243
|
+
## Internal Package Integrations
|
|
244
|
+
|
|
245
|
+
`@fluojs/di/internal` is a typed integration seam for first-party framework
|
|
246
|
+
packages. It resolves one ordered `multi: true` contribution through the
|
|
247
|
+
owning container, preserving the container's scope, cache, cycle, ordering,
|
|
248
|
+
and disposal semantics. Application code must use `Container.resolve(...)`;
|
|
249
|
+
contribution indexes are not part of the root `Container` API.
|
|
250
|
+
|
|
150
251
|
## Troubleshooting
|
|
151
252
|
|
|
152
253
|
### CircularDependencyError
|
|
@@ -157,17 +258,17 @@ Ensure all required providers are registered in the container. If you use `creat
|
|
|
157
258
|
|
|
158
259
|
## Public API
|
|
159
260
|
|
|
160
|
-
|
|
|
161
|
-
|
|
162
|
-
| `Container` | The main DI container class. |
|
|
163
|
-
| `register(...providers)` | Registers one or more providers. |
|
|
164
|
-
| `override(...providers)` | Replaces existing providers, invalidates cached instances, and
|
|
165
|
-
| `resolve<T>(token)` | Asynchronously resolves a token to an instance. |
|
|
166
|
-
| `inspectResolutionState()` | Exposes the supported framework-owned container introspection seam for testing/tooling helpers that must preserve cache ownership. Prefer `has(...)` and `resolve(...)` for application code. |
|
|
167
|
-
| `createRequestScope()` | Creates a child container for request-scoped dependencies. |
|
|
168
|
-
| `has(token)` | Checks if a token is registered in the container or its parents. |
|
|
169
|
-
| `hasRequestScopedDependency(token)` | Checks whether resolving a token may require a request-scope container because its provider graph contains request-scoped dependencies or is cyclic. |
|
|
170
|
-
| `dispose()` | Disposes request children
|
|
261
|
+
| Surface | Kind | Description |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `Container` | Root export | The main DI container class. `new Container()` takes no arguments and creates a root container; child request scopes are package-owned and created with `createRequestScope()`. Supplying constructor arguments throws `ContainerResolutionError`. |
|
|
264
|
+
| `container.register(...providers)` | `Container` instance method | Registers one or more providers. |
|
|
265
|
+
| `container.override(...providers)` | `Container` instance method | Replaces existing providers atomically per call, invalidates cached instances, and ensures stale instance disposal settles before the next replacement resolution continues. |
|
|
266
|
+
| `container.resolve<T>(token)` | `Container` instance method | Asynchronously resolves a token to an instance. |
|
|
267
|
+
| `container.inspectResolutionState()` | `Container` instance method | Exposes the supported framework-owned container introspection seam for testing/tooling helpers that must preserve cache ownership through snapshot read-only map views, frozen provider records, and controlled cache adoption. Prefer `has(...)` and `resolve(...)` for application code. |
|
|
268
|
+
| `container.createRequestScope()` | `Container` instance method | Creates a child container for request-scoped dependencies. This is the only supported path to parent-linked, request-scope-enabled containers that share the root singleton cache. |
|
|
269
|
+
| `container.has(token)` | `Container` instance method | Checks if a token is registered in the container or its parents. |
|
|
270
|
+
| `container.hasRequestScopedDependency(token)` | `Container` instance method | Checks whether resolving a token may require a request-scope container because its provider graph contains request-scoped dependencies or is cyclic. |
|
|
271
|
+
| `container.dispose()` | `Container` instance method | Disposes request children before parent/root caches, shares an active attempt, and retries only failed `onDestroy()` hooks on a later explicit call. |
|
|
171
272
|
| `forwardRef(fn)` | Returns a token wrapper that defers lookup for declaration-order issues; it does not make constructor dependency cycles resolvable. |
|
|
172
273
|
| `isForwardRef(value)` | Type guard for values produced by `forwardRef(...)`; useful when integrating custom provider tooling with DI token wrappers. |
|
|
173
274
|
| `optional(token)` | Returns a token wrapper that marks one dependency as optional; missing optional dependencies resolve to `undefined`. |
|
|
@@ -176,8 +277,10 @@ Ensure all required providers are registered in the container. If you use `creat
|
|
|
176
277
|
| Provider types | `Provider`, `ClassProvider`, `FactoryProvider`, `ValueProvider`, and `ExistingProvider` describe the public registration shapes accepted by `register(...)` and `override(...)`. |
|
|
177
278
|
| Token wrapper types | `ForwardRefFn` and `OptionalToken` describe the wrapper values returned by `forwardRef(...)` and `optional(...)`. |
|
|
178
279
|
| Container helper types | `ClassType`, `Disposable`, and `RequestScopeContainer` support typed provider declarations, teardown hooks, and request-scope helper boundaries. |
|
|
179
|
-
| `ContainerResolutionState`
|
|
280
|
+
| Container introspection helper types | `ContainerResolutionState`, `ContainerResolutionCacheOwner`, and `ContainerFactoryResolutionState` describe the read-only graph/cache views and controlled cache adoption helpers returned by `inspectResolutionState()`. |
|
|
281
|
+
| `FactoryResolutionKind` | Root export | Classifies whether a factory provider returned synchronously (`sync`) or through a promise (`async`) for container diagnostics and introspection. |
|
|
180
282
|
| `NormalizedProvider` | Compatibility-only public type for the container's validated provider record shape. Prefer authoring providers with `Provider` or the specific provider interfaces; the container owns normalized record construction. |
|
|
283
|
+
| `@fluojs/di/internal` | Package-integration seam exposing `validateProviderInputs(...)` so sibling fluo packages can apply the container's canonical provider validation before their own traversal. Application code should continue to register providers through `Container`. |
|
|
181
284
|
| `DiErrorContext` | Structured context attached to DI errors so logs and tests can inspect tokens, scopes, modules, dependency chains, and hints. |
|
|
182
285
|
| Error classes | `InvalidProviderError`, `ContainerResolutionError`, `RequestScopeResolutionError`, `ScopeMismatchError`, `CircularDependencyError`, `DuplicateProviderError`. |
|
|
183
286
|
|