Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/soft-pandas-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@fluojs/di": major
"@fluojs/testing": patch
---

Harden DI request-scope lifecycle and introspection ownership by recursively disposing nested request scopes from their owners, returning read-only introspection state, and keeping testing cache adoption on controlled container-owned APIs.

Migration note: callers that used `inspectResolutionState()` as a mutable escape hatch must stop mutating returned registration/cache maps or normalized provider records. Framework-owned tooling should use the returned `cacheOwner` helpers for controlled cache adoption instead of writing to the maps directly.
6 changes: 3 additions & 3 deletions packages/di/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const service = await container.resolve(UserService);
- **request**: `createRequestScope()`마다 새로 생성됩니다.
- **transient**: resolve할 때마다 새 인스턴스를 만듭니다.

dispose 중에는 루트 컨테이너가 먼저 살아 있는 request scope 자식을 정리한 뒤, 자식 dispose 중 하나 이상이 실패하더라도 루트가 소유한 singleton 정리를 계속 수행합니다. 자식/루트 dispose 실패가 여러 개 발생하면 `dispose()`는 모든 shutdown 실패를 확인할 수 있도록 `AggregateError`로 보고합니다.
dispose 중에는 컨테이너가 자신이 소유한 살아 있는 request scope 자식을 먼저 재귀적으로 정리하므로, 루트가 아닌 request scope를 dispose해도 중첩 request scope를 닫은 뒤 자신의 request cache를 정리합니다. 이후 루트 dispose는 자식 dispose 중 하나 이상이 실패하더라도 루트가 소유한 singleton 정리를 계속 수행합니다. 자식/루트 dispose 실패가 여러 개 발생하면 `dispose()`는 모든 shutdown 실패를 확인할 수 있도록 `AggregateError`로 보고합니다.

### provider override

Expand Down Expand Up @@ -163,7 +163,7 @@ const service = await container.resolve(DataService);
| `register(...providers)` | 하나 이상의 프로바이더를 등록합니다. |
| `override(...providers)` | 기존 provider를 교체하고 cached instance를 무효화하며 오래된 instance를 dispose합니다. |
| `resolve<T>(token)` | 토큰을 인스턴스로 비동기 해석합니다. |
| `inspectResolutionState()` | cache ownership을 보존해야 하는 testing/tooling helper를 위한 지원 대상 framework-owned container introspection seam을 노출합니다. 애플리케이션 코드는 `has(...)`와 `resolve(...)`를 우선 사용하세요. |
| `inspectResolutionState()` | read-only map view, frozen provider record, controlled cache adoption을 통해 cache ownership을 보존해야 하는 testing/tooling helper를 위한 지원 대상 framework-owned container introspection seam을 노출합니다. 애플리케이션 코드는 `has(...)`와 `resolve(...)`를 우선 사용하세요. |
| `createRequestScope()` | 요청 스코프 의존성을 위한 자식 컨테이너를 생성합니다. |
| `has(token)` | 컨테이너나 부모에 토큰이 등록되어 있는지 확인합니다. |
| `hasRequestScopedDependency(token)` | 토큰 해석 시 provider 그래프에 request-scoped 의존성이나 순환이 있어 request-scope 컨테이너가 필요할 수 있는지 확인합니다. |
Expand All @@ -176,7 +176,7 @@ const service = await container.resolve(DataService);
| Provider types | `Provider`, `ClassProvider`, `FactoryProvider`, `ValueProvider`, `ExistingProvider`는 `register(...)`와 `override(...)`가 받는 공개 registration shape를 설명합니다. |
| Token wrapper types | `ForwardRefFn`과 `OptionalToken`은 `forwardRef(...)`와 `optional(...)`이 반환하는 wrapper 값을 설명합니다. |
| Container helper types | `ClassType`, `Disposable`, `RequestScopeContainer`는 typed provider 선언, teardown hook, request-scope helper 경계를 지원합니다. |
| `ContainerResolutionState` | framework testing/tooling integration을 위해 `inspectResolutionState()`가 반환하는 공개 introspection record입니다. |
| Container introspection helper types | `ContainerResolutionState`, `ContainerResolutionCacheOwner`, `ContainerFactoryResolutionState`는 `inspectResolutionState()`가 반환하는 read-only graph/cache view와 controlled cache adoption helper를 설명합니다. |
| `NormalizedProvider` | 컨테이너가 검증한 provider record shape를 위한 compatibility-only 공개 타입입니다. provider를 작성할 때는 `Provider`나 구체 provider interface를 우선 사용하세요. normalized record 생성은 컨테이너가 소유합니다. |
| `DiErrorContext` | DI error에 붙는 구조화된 context입니다. 로그와 테스트가 token, scope, module, dependency chain, hint를 검사할 수 있게 합니다. |
| 에러 클래스 | `InvalidProviderError`, `ContainerResolutionError`, `RequestScopeResolutionError`, `ScopeMismatchError`, `CircularDependencyError`, `DuplicateProviderError`. |
Expand Down
6 changes: 3 additions & 3 deletions packages/di/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ fluo DI supports four provider shapes:
- **Request**: Instance is created once per `createRequestScope()` call.
- **Transient**: A new instance is created every time it is resolved.

During disposal, the root container first tears down live request-scope children and 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.
During disposal, 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.

### Provider Overrides

Expand Down Expand Up @@ -163,7 +163,7 @@ Ensure all required providers are registered in the container. If you use `creat
| `register(...providers)` | Registers one or more providers. |
| `override(...providers)` | Replaces existing providers, invalidates cached instances, and disposes stale instances. |
| `resolve<T>(token)` | Asynchronously resolves a token to an instance. |
| `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. |
| `inspectResolutionState()` | Exposes the supported framework-owned container introspection seam for testing/tooling helpers that must preserve cache ownership through read-only map views, frozen provider records, and controlled cache adoption. Prefer `has(...)` and `resolve(...)` for application code. |
| `createRequestScope()` | Creates a child container for request-scoped dependencies. |
| `has(token)` | Checks if a token is registered in the container or its parents. |
| `hasRequestScopedDependency(token)` | Checks whether resolving a token may require a request-scope container because its provider graph contains request-scoped dependencies or is cyclic. |
Expand All @@ -176,7 +176,7 @@ Ensure all required providers are registered in the container. If you use `creat
| Provider types | `Provider`, `ClassProvider`, `FactoryProvider`, `ValueProvider`, and `ExistingProvider` describe the public registration shapes accepted by `register(...)` and `override(...)`. |
| Token wrapper types | `ForwardRefFn` and `OptionalToken` describe the wrapper values returned by `forwardRef(...)` and `optional(...)`. |
| Container helper types | `ClassType`, `Disposable`, and `RequestScopeContainer` support typed provider declarations, teardown hooks, and request-scope helper boundaries. |
| `ContainerResolutionState` | Public introspection record returned by `inspectResolutionState()` for framework testing/tooling integrations. |
| Container introspection helper types | `ContainerResolutionState`, `ContainerResolutionCacheOwner`, and `ContainerFactoryResolutionState` describe the read-only graph/cache views and controlled cache adoption helpers returned by `inspectResolutionState()`. |
| `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. |
| `DiErrorContext` | Structured context attached to DI errors so logs and tests can inspect tokens, scopes, modules, dependency chains, and hints. |
| Error classes | `InvalidProviderError`, `ContainerResolutionError`, `RequestScopeResolutionError`, `ScopeMismatchError`, `CircularDependencyError`, `DuplicateProviderError`. |
Expand Down
138 changes: 138 additions & 0 deletions packages/di/src/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,31 @@ describe('Container', () => {
expect(afterOverride).not.toBe(beforeOverride);
});

it('invalidates nested request-scope descendants when an ancestor dependency is overridden', async () => {
const CONFIG = Symbol('NestedConfig');

class RequestConsumer {
constructor(readonly config: string) {}
}

const root = new Container().register(
{ provide: CONFIG, useValue: 'before-override' },
{ provide: RequestConsumer, scope: Scope.REQUEST, useClass: RequestConsumer, inject: [CONFIG] },
);
const parentScope = root.createRequestScope();
const descendantScope = parentScope.createRequestScope();

const beforeOverride = await descendantScope.resolve<RequestConsumer>(RequestConsumer);

root.override({ provide: CONFIG, useValue: 'after-override' });

const afterOverride = await descendantScope.resolve<RequestConsumer>(RequestConsumer);

expect(beforeOverride.config).toBe('before-override');
expect(afterOverride.config).toBe('after-override');
expect(afterOverride).not.toBe(beforeOverride);
});

it('replaces existing multi providers when overriding a token', async () => {
const token = Symbol('plugins');
const container = new Container().register(
Expand Down Expand Up @@ -805,6 +830,20 @@ describe('Container', () => {
expect(() => new Container().register(provider)).toThrow('provide token');
});

it('throws InvalidProviderError when an object provider uses null provide', () => {
const provider = { provide: null, useValue: 'missing-token' } as unknown as Provider;

expect(() => new Container().register(provider)).toThrow(InvalidProviderError);
expect(() => new Container().register(provider)).toThrow('provide token');
});

it('throws InvalidProviderError when an object provider has no strategy', () => {
const provider = { provide: Symbol('strategy-less-provider') } as unknown as Provider;

expect(() => new Container().register(provider)).toThrow(InvalidProviderError);
expect(() => new Container().register(provider)).toThrow('exactly one');
});

it('throws InvalidProviderError when an object provider has more than one strategy', () => {
const token = Symbol('ambiguous-provider');
const provider = { provide: token, useValue: 'value', useFactory: () => 'factory' } as unknown as Provider;
Expand Down Expand Up @@ -1425,6 +1464,38 @@ describe('Container', () => {
});
});

describe('resolution introspection', () => {
it('returns read-only map views and frozen provider records', async () => {
const token = Symbol('introspection-token');
const plugins = Symbol('introspection-plugins');
const container = new Container().register(
{ provide: token, useValue: 'value' },
{ provide: plugins, useValue: 'plugin', multi: true },
);

await container.resolve(token);
await container.resolve(plugins);

const state = container.inspectResolutionState();
const provider = state.registrations.get(token);
const multiProviders = state.multiRegistrations.get(plugins);

if (!provider || !multiProviders) {
expect.unreachable('expected introspection state to expose registered providers');
}

expect(Object.isFrozen(provider)).toBe(true);
expect(Object.isFrozen(provider.inject)).toBe(true);
expect(Object.isFrozen(multiProviders)).toBe(true);
expect(Reflect.get(state.registrations, 'set')).toBeUndefined();
expect(Reflect.get(state.multiRegistrations, 'clear')).toBeUndefined();
expect(Reflect.get(state.singletonCache, 'delete')).toBeUndefined();
expect(Reflect.get(state.multiSingletonCache, 'set')).toBeUndefined();
await expect(container.resolve(token)).resolves.toBe('value');
await expect(container.resolve<string[]>(plugins)).resolves.toEqual(['plugin']);
});
});

describe('dispose', () => {
it('calls onDestroy for resolved singleton instances in reverse creation order', async () => {
const events: string[] = [];
Expand Down Expand Up @@ -1483,6 +1554,40 @@ describe('Container', () => {
expect(events).toEqual(['request', 'singleton']);
});

it('recursively disposes nested request scopes when a non-root request scope is disposed', async () => {
const events: string[] = [];

class ParentRequestService {
onDestroy() {
events.push('parent');
}
}

class ChildRequestService {
onDestroy() {
events.push('child');
}
}

const root = new Container().register(
{ provide: ParentRequestService, scope: Scope.REQUEST, useClass: ParentRequestService },
{ provide: ChildRequestService, scope: Scope.REQUEST, useClass: ChildRequestService },
);
const parentScope = root.createRequestScope();
const childScope = parentScope.createRequestScope();

await parentScope.resolve(ParentRequestService);
await childScope.resolve(ChildRequestService);

await parentScope.dispose();

expect(events).toEqual(['child', 'parent']);

await root.dispose();

expect(events).toEqual(['child', 'parent']);
});

it('removes materialized request scopes from the root child scope registry on dispose', async () => {
class RequestStore {}

Expand Down Expand Up @@ -1707,6 +1812,39 @@ describe('Container', () => {
expect(events).toEqual(['consumer:before-override', 'consumer:after-override']);
});

it('disposes stale nested request-scope consumers invalidated by ancestor overrides exactly once', async () => {
const events: string[] = [];
const CONFIG = Symbol('NestedDisposableConsumerConfig');

class RequestConsumer {
constructor(readonly config: string) {}

onDestroy() {
events.push(`consumer:${this.config}`);
}
}

const root = new Container().register(
{ provide: CONFIG, useValue: 'before-override' },
{ provide: RequestConsumer, scope: Scope.REQUEST, useClass: RequestConsumer, inject: [CONFIG] },
);
const parentScope = root.createRequestScope();
const descendantScope = parentScope.createRequestScope();

await descendantScope.resolve(RequestConsumer);

root.override({ provide: CONFIG, useValue: 'after-override' });
await Promise.resolve();

expect(events).toEqual(['consumer:before-override']);

await descendantScope.resolve(RequestConsumer);
await parentScope.dispose();
await root.dispose();

expect(events).toEqual(['consumer:before-override', 'consumer:after-override']);
});

it('disposes stale overridden multi singleton instances immediately and exactly once', async () => {
const events: string[] = [];
const token = Symbol('multi-rotating-disposable-token');
Expand Down
Loading