@zudojs/container 1.1.0 → 1.1.1

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.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Token-based dependency injection container for managing application dependencies and service lifetimes.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-container](https://zudojs.oyinlola.site/docs/packages-container) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-container.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -71,7 +77,10 @@ not capture a shorter-lived dependency.
71
77
 
72
78
  `registerValue()` always registers as `SINGLETON`, overriding any
73
79
  `options.scope` you pass — a value provider can only ever return the one
74
- instance you gave it.
80
+ instance you gave it. The container did not create that value, so it
81
+ never disposes it: a pool or logger shared between containers, or owned
82
+ by the host, stays open when a container is disposed. Register a factory
83
+ instead when the container should own the instance.
75
84
 
76
85
  A `useExisting` alias never becomes a second owner of its target's instance:
77
86
  a `SINGLETON` or `SCOPED` target is disposed exactly once by whoever created
@@ -148,7 +157,12 @@ intentionally swap a registration.
148
157
 
149
158
  Replacing or removing a registration **evicts and disposes** its cached
150
159
  singleton — and evicts any cached `useExisting` alias of it — so the next
151
- `resolve()` of either token uses the new provider.
160
+ `resolve()` of either token uses the new provider. Eviction cascades: every
161
+ cached singleton that was built on the replaced token (directly or through
162
+ a `TRANSIENT` in between) is evicted and disposed too, so consumers are
163
+ rebuilt against the new instance instead of keeping the old, disposed one.
164
+ Live scopes drop (and dispose) their cached `SCOPED` copies of the replaced
165
+ token and of its consumers.
152
166
 
153
167
  `DuplicateRegistrationError`, `CircularDependencyError`,
154
168
  `RegistrationNotFoundError` and `ProviderResolutionError` are defined in
@@ -208,6 +222,7 @@ await container.dispose();
208
222
  have a `dispose()` method or implement `Symbol.dispose` /
209
223
  `Symbol.asyncDispose`.
210
224
  - `TRANSIENT` instances are never tracked; dispose them yourself.
225
+ - `registerValue()` values are never disposed by the container.
211
226
  - With `autoDispose: false`, `dispose()` releases tracked references
212
227
  **without disposing them** — disposal becomes your responsibility.
213
228
  - Disposal is terminal: the container is marked disposed even if some
@@ -44,6 +44,11 @@ export class Container {
44
44
  void this.#lifecycle.disposeInstance(token).catch(() => {
45
45
  /* see clearSingletons()/dispose() for error-surfacing disposal */
46
46
  });
47
+ }, (token) => {
48
+ // Live scopes would otherwise keep serving a replaced SCOPED
49
+ // instance, or one built on a replaced dependency.
50
+ for (const scope of this.#liveScopes)
51
+ scope.evictCached(token);
47
52
  });
48
53
  }
49
54
  /**
@@ -17,6 +17,7 @@
17
17
  * ancestor (scope or container) is disposed.
18
18
  */
19
19
  import type { RegistrationToken, ResolvedTokens } from "../containerRegistration/containerRegistration.core.js";
20
+ import type { Token } from "../containerToken/containerToken.type.js";
20
21
  import type { ContainerScopeOptions, ContainerLike } from "./containerCore.type.js";
21
22
  export declare class ContainerScopeContext {
22
23
  private disposed;
@@ -89,6 +90,12 @@ export declare class ContainerScopeContext {
89
90
  * Returns the container that owns the whole scope tree.
90
91
  */
91
92
  getContainer(): ContainerLike;
93
+ /**
94
+ * @internal Drops a cached SCOPED instance whose registration (or a
95
+ * dependency of it) was replaced or removed, disposing it best-effort,
96
+ * in this scope and every child scope.
97
+ */
98
+ evictCached(token: Token): void;
92
99
  /** @internal Detaches a disposed child scope. */
93
100
  releaseChild(scope: ContainerScopeContext): void;
94
101
  /**
@@ -156,6 +156,19 @@ export class ContainerScopeContext {
156
156
  getContainer() {
157
157
  return this.container;
158
158
  }
159
+ /**
160
+ * @internal Drops a cached SCOPED instance whose registration (or a
161
+ * dependency of it) was replaced or removed, disposing it best-effort,
162
+ * in this scope and every child scope.
163
+ */
164
+ evictCached(token) {
165
+ if (this.disposed)
166
+ return;
167
+ this.cache.delete?.(token);
168
+ void this.lifecycle.disposeInstance(token).catch(() => undefined);
169
+ for (const child of this.children)
170
+ child.evictCached(token);
171
+ }
159
172
  /** @internal Detaches a disposed child scope. */
160
173
  releaseChild(scope) {
161
174
  this.children.delete(scope);
@@ -32,7 +32,16 @@ export declare class ContainerResolver {
32
32
  private readonly registry;
33
33
  private readonly singletonCache;
34
34
  private readonly onSingletonEvicted;
35
- constructor(registry: ContainerRegistry, onSingletonEvicted?: (token: Token<unknown>) => void);
35
+ private readonly onTokenInvalidated;
36
+ private readonly dependents;
37
+ /**
38
+ * @param onSingletonEvicted Called for each evicted cached singleton so
39
+ * the owner can dispose it.
40
+ * @param onTokenInvalidated Called for every token invalidated by a
41
+ * `replace()`/`remove()` (the token itself and each cached consumer),
42
+ * so owners of scope caches can drop and dispose their SCOPED copies.
43
+ */
44
+ constructor(registry: ContainerRegistry, onSingletonEvicted?: (token: Token<unknown>) => void, onTokenInvalidated?: (token: Token<unknown>) => void);
36
45
  resolve<T>(token: RegistrationToken<T>, options?: ResolutionOptions): T;
37
46
  resolveDetailed<T>(token: RegistrationToken<T>, options?: ResolutionOptions): ResolutionResult<T>;
38
47
  private resolveInternal;
@@ -32,6 +32,7 @@ import { unwrapToken } from "../containerToken/containerToken.type.js";
32
32
  import { CircularDependencyError, ProviderResolutionError, RegistrationNotFoundError, } from "@zudojs/errors";
33
33
  import { AsyncProviderError, CaptiveDependencyError, DependencyResolutionError, MaxResolutionDepthError, ScopedResolutionError, } from "./containerResolution.error.js";
34
34
  import { describeToken } from "../containerToken/containerToken.type.js";
35
+ import { DependentIndex } from "./containerResolution.dependents.js";
35
36
  /**
36
37
  * Scope cache that falls back to its parent scope's cache for lookups while
37
38
  * writing only to its own map. Nested scopes therefore see SCOPED instances
@@ -55,6 +56,9 @@ class ChainedResolutionCache {
55
56
  set(token, value) {
56
57
  this.#own.set(token, value);
57
58
  }
59
+ delete(token) {
60
+ return this.#own.delete(token);
61
+ }
58
62
  clear() {
59
63
  this.#own.clear();
60
64
  }
@@ -63,9 +67,19 @@ export class ContainerResolver {
63
67
  registry;
64
68
  singletonCache = new Map();
65
69
  onSingletonEvicted;
66
- constructor(registry, onSingletonEvicted) {
70
+ onTokenInvalidated;
71
+ dependents = new DependentIndex();
72
+ /**
73
+ * @param onSingletonEvicted Called for each evicted cached singleton so
74
+ * the owner can dispose it.
75
+ * @param onTokenInvalidated Called for every token invalidated by a
76
+ * `replace()`/`remove()` (the token itself and each cached consumer),
77
+ * so owners of scope caches can drop and dispose their SCOPED copies.
78
+ */
79
+ constructor(registry, onSingletonEvicted, onTokenInvalidated) {
67
80
  this.registry = registry;
68
81
  this.onSingletonEvicted = onSingletonEvicted;
82
+ this.onTokenInvalidated = onTokenInvalidated;
69
83
  registry.subscribe((event) => this.handleRegistryChange(event));
70
84
  }
71
85
  resolve(token, options = {}) {
@@ -108,6 +122,7 @@ export class ContainerResolver {
108
122
  }
109
123
  }
110
124
  const currentPath = [...state.path, token];
125
+ this.dependents.record(token, state.path, (t) => this.registry.get(t)?.scope);
111
126
  if (registration.scope === Scope.SINGLETON) {
112
127
  if (this.singletonCache.has(token)) {
113
128
  return {
@@ -173,8 +188,11 @@ export class ContainerResolver {
173
188
  const provider = normalizeProvider(registration.provider);
174
189
  const token = getRegistrationToken(registration);
175
190
  try {
191
+ // A pre-built value belongs to whoever built it (often shared
192
+ // between containers); the container did not create it, so it
193
+ // must not dispose it.
176
194
  if (isValueProvider(provider))
177
- return { value: provider.useValue, owned: true };
195
+ return { value: provider.useValue, owned: false };
178
196
  if (isExistingProvider(provider)) {
179
197
  const target = unwrapToken(provider.useExisting);
180
198
  if (!this.registry.has(target) &&
@@ -252,14 +270,21 @@ export class ContainerResolver {
252
270
  case RegistryOperation.REPLACE:
253
271
  case RegistryOperation.REMOVE: {
254
272
  const token = unwrapToken(event.token);
273
+ // Consumers first, so each is disposed before what it consumed.
274
+ for (const dependent of this.dependents.take(token)) {
275
+ this.evictSingleton(dependent);
276
+ this.onTokenInvalidated?.(dependent);
277
+ }
255
278
  this.evictSingleton(token);
256
279
  this.evictAliasesOf(token);
280
+ this.onTokenInvalidated?.(token);
257
281
  break;
258
282
  }
259
283
  case RegistryOperation.CLEAR:
260
284
  case RegistryOperation.RESTORE: {
261
285
  for (const t of [...this.singletonCache.keys()])
262
286
  this.evictSingleton(t);
287
+ this.dependents.clear();
263
288
  break;
264
289
  }
265
290
  default:
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Reverse dependency index for cached container instances.
3
+ *
4
+ * `replace()`/`remove()` used to evict only the changed token, so a
5
+ * singleton that had already captured it kept serving the old — by then
6
+ * disposed — instance. Recording which cached instance consumed which
7
+ * token lets eviction cascade to every consumer.
8
+ */
9
+ import { ContainerScope as Scope } from "../containerScope/containerScope.type.js";
10
+ import type { Token } from "../containerToken/containerToken.type.js";
11
+ /** Looks up the lifetime of a registered token. */
12
+ export type LifetimeLookup = (token: Token<unknown>) => Scope | undefined;
13
+ /**
14
+ * Maps a token to the cached (SINGLETON or SCOPED) instances whose
15
+ * construction resolved it.
16
+ */
17
+ export declare class DependentIndex {
18
+ #private;
19
+ /**
20
+ * Records that the nearest cached ancestor on `path` depends on
21
+ * `token`. Transient ancestors are skipped: they are rebuilt on every
22
+ * resolution, so the cached instance above them is the real consumer.
23
+ */
24
+ record(token: Token<unknown>, path: readonly Token<unknown>[], lifetimeOf: LifetimeLookup): void;
25
+ /**
26
+ * Returns every token that transitively depends on `token`, nearest
27
+ * consumers last, and forgets them.
28
+ */
29
+ take(token: Token<unknown>): readonly Token<unknown>[];
30
+ /** Forgets every recorded dependency. */
31
+ clear(): void;
32
+ }
33
+ //# sourceMappingURL=containerResolution.dependents.d.ts.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Reverse dependency index for cached container instances.
3
+ *
4
+ * `replace()`/`remove()` used to evict only the changed token, so a
5
+ * singleton that had already captured it kept serving the old — by then
6
+ * disposed — instance. Recording which cached instance consumed which
7
+ * token lets eviction cascade to every consumer.
8
+ */
9
+ import { ContainerScope as Scope } from "../containerScope/containerScope.type.js";
10
+ /**
11
+ * Maps a token to the cached (SINGLETON or SCOPED) instances whose
12
+ * construction resolved it.
13
+ */
14
+ export class DependentIndex {
15
+ #dependents = new Map();
16
+ /**
17
+ * Records that the nearest cached ancestor on `path` depends on
18
+ * `token`. Transient ancestors are skipped: they are rebuilt on every
19
+ * resolution, so the cached instance above them is the real consumer.
20
+ */
21
+ record(token, path, lifetimeOf) {
22
+ for (let i = path.length - 1; i >= 0; i--) {
23
+ const owner = path[i];
24
+ const lifetime = lifetimeOf(owner);
25
+ if (lifetime === Scope.SINGLETON || lifetime === Scope.SCOPED) {
26
+ if (owner === token)
27
+ return;
28
+ let set = this.#dependents.get(token);
29
+ if (set === undefined) {
30
+ set = new Set();
31
+ this.#dependents.set(token, set);
32
+ }
33
+ set.add(owner);
34
+ return;
35
+ }
36
+ }
37
+ }
38
+ /**
39
+ * Returns every token that transitively depends on `token`, nearest
40
+ * consumers last, and forgets them.
41
+ */
42
+ take(token) {
43
+ const ordered = [];
44
+ const visited = new Set([token]);
45
+ const visit = (current) => {
46
+ const direct = this.#dependents.get(current);
47
+ this.#dependents.delete(current);
48
+ if (direct === undefined)
49
+ return;
50
+ for (const dependent of direct) {
51
+ if (visited.has(dependent))
52
+ continue;
53
+ visited.add(dependent);
54
+ visit(dependent);
55
+ ordered.push(dependent);
56
+ }
57
+ };
58
+ visit(token);
59
+ return ordered;
60
+ }
61
+ /** Forgets every recorded dependency. */
62
+ clear() {
63
+ this.#dependents.clear();
64
+ }
65
+ }
66
+ //# sourceMappingURL=containerResolution.dependents.js.map
@@ -15,6 +15,8 @@ export interface ResolutionCache {
15
15
  has(token: Token<unknown>): boolean;
16
16
  get(token: Token<unknown>): unknown;
17
17
  set(token: Token<unknown>, value: unknown): void;
18
+ /** Drops one entry from this cache (not its parents). Optional. */
19
+ delete?(token: Token<unknown>): boolean;
18
20
  clear(): void;
19
21
  }
20
22
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/container",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Token-based dependency injection container for managing application dependencies and service lifetimes.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,7 @@
18
18
  "!dist/.tsbuildinfo"
19
19
  ],
20
20
  "dependencies": {
21
- "@zudojs/errors": "1.0.1"
21
+ "@zudojs/errors": "1.1.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "7.0.2",