@zudojs/container 1.0.0 → 1.1.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.md CHANGED
@@ -73,6 +73,12 @@ not capture a shorter-lived dependency.
73
73
  `options.scope` you pass — a value provider can only ever return the one
74
74
  instance you gave it.
75
75
 
76
+ A `useExisting` alias never becomes a second owner of its target's instance:
77
+ a `SINGLETON` or `SCOPED` target is disposed exactly once by whoever created
78
+ it, and a `SCOPED` alias of a `SINGLETON` leaves the singleton alive when the
79
+ scope is disposed. Only a `TRANSIENT` target captured by a cached alias is
80
+ tracked (and disposed) through the alias.
81
+
76
82
  Tracking covers **every** `SINGLETON`/`SCOPED` instance the container
77
83
  creates — including ones created transitively as dependencies of another
78
84
  resolution — not just the value returned by the `resolve()` call.
@@ -141,7 +147,13 @@ which case the later registration wins. Use `container.replace()` to
141
147
  intentionally swap a registration.
142
148
 
143
149
  Replacing or removing a registration **evicts and disposes** its cached
144
- singleton, so the next `resolve()` uses the new provider.
150
+ singleton — and evicts any cached `useExisting` alias of it — so the next
151
+ `resolve()` of either token uses the new provider.
152
+
153
+ `DuplicateRegistrationError`, `CircularDependencyError`,
154
+ `RegistrationNotFoundError` and `ProviderResolutionError` are defined in
155
+ `@zudojs/errors` and re-exported from this package, so `instanceof` checks
156
+ need only one import.
145
157
 
146
158
  ## Resolution options
147
159
 
@@ -201,6 +213,10 @@ await container.dispose();
201
213
  - Disposal is terminal: the container is marked disposed even if some
202
214
  instances fail to dispose, and every failure is reported in the thrown
203
215
  `AggregateError`. `dispose()` is idempotent; any use after disposal throws.
216
+ The container counts as disposed from the moment `dispose()` is called —
217
+ a `resolve()` racing the disposal throws rather than creating an instance
218
+ nobody will clean up — and concurrent `dispose()` calls all settle when
219
+ the one disposal finishes.
204
220
  - `clearSingletons()` evicts and disposes cached singletons without
205
221
  disposing the container.
206
222
 
@@ -114,8 +114,15 @@ export declare class Container implements ContainerLike {
114
114
  * Disposal is terminal: the container is marked disposed even when some
115
115
  * instances fail to dispose; every failure is reported in the thrown
116
116
  * AggregateError. Idempotent.
117
+ *
118
+ * The container is marked disposed *before* any cleanup runs, so a
119
+ * `resolve()` racing the disposal throws instead of creating a singleton
120
+ * that would be tracked after the disposal snapshot and then silently
121
+ * dropped. Concurrent `dispose()` calls share the in-flight disposal and
122
+ * settle only when it has finished.
117
123
  */
118
124
  dispose(): Promise<void>;
125
+ private runDispose;
119
126
  get resolutionOptions(): {
120
127
  autoRegisterClasses: boolean;
121
128
  detectCircularDependencies: boolean;
@@ -30,6 +30,7 @@ export class Container {
30
30
  #liveScopes = new Set();
31
31
  #started = false;
32
32
  #disposed = false;
33
+ #disposing;
33
34
  constructor(options = {}) {
34
35
  this.options = resolveContainerOptions(options);
35
36
  this.name = this.options.name;
@@ -221,10 +222,26 @@ export class Container {
221
222
  * Disposal is terminal: the container is marked disposed even when some
222
223
  * instances fail to dispose; every failure is reported in the thrown
223
224
  * AggregateError. Idempotent.
225
+ *
226
+ * The container is marked disposed *before* any cleanup runs, so a
227
+ * `resolve()` racing the disposal throws instead of creating a singleton
228
+ * that would be tracked after the disposal snapshot and then silently
229
+ * dropped. Concurrent `dispose()` calls share the in-flight disposal and
230
+ * settle only when it has finished.
224
231
  */
225
- async dispose() {
232
+ dispose() {
233
+ if (this.#disposing)
234
+ return this.#disposing;
226
235
  if (this.#disposed)
227
- return;
236
+ return Promise.resolve();
237
+ this.#disposed = true;
238
+ this.#started = false;
239
+ this.#disposing = this.runDispose().finally(() => {
240
+ this.#disposing = undefined;
241
+ });
242
+ return this.#disposing;
243
+ }
244
+ async runDispose() {
228
245
  const failures = [];
229
246
  for (const scope of [...this.#liveScopes]) {
230
247
  try {
@@ -245,8 +262,6 @@ export class Container {
245
262
  }
246
263
  this.#lifecycle.shutdown();
247
264
  this.#resolver.clearSingletonCache();
248
- this.#disposed = true;
249
- this.#started = false;
250
265
  if (failures.length > 0)
251
266
  throw new AggregateError(failures, `Container "${this.name}" was disposed, but ${failures.length} cleanup step(s) failed.`);
252
267
  }
@@ -20,6 +20,7 @@ import type { RegistrationToken, ResolvedTokens } from "../containerRegistration
20
20
  import type { ContainerScopeOptions, ContainerLike } from "./containerCore.type.js";
21
21
  export declare class ContainerScopeContext {
22
22
  private disposed;
23
+ private disposing;
23
24
  private readonly cache;
24
25
  private readonly lifecycle;
25
26
  private readonly container;
@@ -70,9 +71,11 @@ export declare class ContainerScopeContext {
70
71
  * from its parent. Container-owned singletons are not touched. Idempotent.
71
72
  *
72
73
  * Every failure is collected; the scope is marked disposed regardless and
73
- * an AggregateError listing the failures is thrown afterwards.
74
+ * an AggregateError listing the failures is thrown afterwards. Concurrent
75
+ * callers share the in-flight disposal rather than returning early.
74
76
  */
75
77
  dispose(): Promise<void>;
78
+ private runDispose;
76
79
  /**
77
80
  * Returns whether the scope has been disposed.
78
81
  */
@@ -20,6 +20,7 @@ import { ContainerLifecycle, ContainerLifecycleOwner, } from "../containerLifecy
20
20
  import { ContainerScope } from "../containerScope/containerScope.type.js";
21
21
  export class ContainerScopeContext {
22
22
  disposed = false;
23
+ disposing;
23
24
  cache;
24
25
  lifecycle;
25
26
  container;
@@ -94,12 +95,21 @@ export class ContainerScopeContext {
94
95
  * from its parent. Container-owned singletons are not touched. Idempotent.
95
96
  *
96
97
  * Every failure is collected; the scope is marked disposed regardless and
97
- * an AggregateError listing the failures is thrown afterwards.
98
+ * an AggregateError listing the failures is thrown afterwards. Concurrent
99
+ * callers share the in-flight disposal rather than returning early.
98
100
  */
99
- async dispose() {
101
+ dispose() {
102
+ if (this.disposing)
103
+ return this.disposing;
100
104
  if (this.disposed)
101
- return;
105
+ return Promise.resolve();
102
106
  this.disposed = true;
107
+ this.disposing = this.runDispose().finally(() => {
108
+ this.disposing = undefined;
109
+ });
110
+ return this.disposing;
111
+ }
112
+ async runDispose() {
103
113
  const failures = [];
104
114
  for (const child of [...this.children].reverse()) {
105
115
  try {
@@ -120,6 +120,10 @@ export class ContainerLifecycle {
120
120
  async dispose(owner) {
121
121
  if (this.disposed)
122
122
  return;
123
+ // Mark before the first await: an instance tracked while disposal is in
124
+ // flight would be dropped by `shutdown()` without ever being disposed.
125
+ if (owner === undefined)
126
+ this.disposed = true;
123
127
  const tracked = [...this.instances.values()].reverse();
124
128
  const selected = owner
125
129
  ? tracked.filter((entry) => entry.owner === owner)
@@ -141,8 +145,6 @@ export class ContainerLifecycle {
141
145
  break;
142
146
  }
143
147
  }
144
- if (owner === undefined)
145
- this.disposed = true;
146
148
  if (errors.length > 0)
147
149
  throw new ContainerDisposalError(errors, failedTokens);
148
150
  }
@@ -54,5 +54,14 @@ export declare class ContainerResolver {
54
54
  canResolve<T>(token: RegistrationToken<T>, autoRegisterClasses?: boolean): boolean;
55
55
  private handleRegistryChange;
56
56
  private evictSingleton;
57
+ /**
58
+ * Evicts every cached singleton whose `useExisting` chain ends at `target`.
59
+ *
60
+ * A cached alias holds the target's instance under its own token, so
61
+ * evicting the target alone left the alias serving the old — by now
62
+ * disposed — instance after `replace()`/`remove()`.
63
+ */
64
+ private evictAliasesOf;
65
+ private aliasChainReaches;
57
66
  }
58
67
  //# sourceMappingURL=containerResolution.core.d.ts.map
@@ -140,8 +140,9 @@ export class ContainerResolver {
140
140
  state.path.push(token);
141
141
  state.pathSet.add(token);
142
142
  let value;
143
+ let owned;
143
144
  try {
144
- value = this.createInstance(registration, state, nextAncestor);
145
+ ({ value, owned } = this.createInstance(registration, state, nextAncestor));
145
146
  }
146
147
  finally {
147
148
  state.path.pop();
@@ -159,7 +160,13 @@ export class ContainerResolver {
159
160
  fromCache: false,
160
161
  path: currentPath,
161
162
  };
162
- state.onInstanceCreated?.(result);
163
+ // A `useExisting` alias of a cached (SINGLETON/SCOPED) target does not
164
+ // own the instance it hands out — the target's own creation already
165
+ // reported it. Reporting it again registered a second owner for the same
166
+ // object: the container disposed it twice, and a SCOPED alias let a
167
+ // scope dispose a container-owned singleton.
168
+ if (owned)
169
+ state.onInstanceCreated?.(result);
163
170
  return result;
164
171
  }
165
172
  createInstance(registration, state, singletonAncestor) {
@@ -167,7 +174,7 @@ export class ContainerResolver {
167
174
  const token = getRegistrationToken(registration);
168
175
  try {
169
176
  if (isValueProvider(provider))
170
- return provider.useValue;
177
+ return { value: provider.useValue, owned: true };
171
178
  if (isExistingProvider(provider)) {
172
179
  const target = unwrapToken(provider.useExisting);
173
180
  if (!this.registry.has(target) &&
@@ -175,8 +182,13 @@ export class ContainerResolver {
175
182
  throw new Error(`useExisting target "${describeToken(target)}" for token ` +
176
183
  `"${describeToken(token)}" is not registered.`);
177
184
  }
178
- return this.resolveInternal(target, state, singletonAncestor)
179
- .value;
185
+ const resolved = this.resolveInternal(target, state, singletonAncestor);
186
+ // Only a TRANSIENT target has no owner of its own; a cached alias
187
+ // of it is the one place the instance can be tracked.
188
+ return {
189
+ value: resolved.value,
190
+ owned: resolved.scope === Scope.TRANSIENT,
191
+ };
180
192
  }
181
193
  if (isFactoryProvider(provider)) {
182
194
  const deps = provider.inject ?? [];
@@ -185,14 +197,14 @@ export class ContainerResolver {
185
197
  const produced = provider.useFactory(...args);
186
198
  if (registration.scope !== Scope.TRANSIENT && isPromiseLike(produced))
187
199
  throw new AsyncProviderError(describeToken(token), registration.scope);
188
- return produced;
200
+ return { value: produced, owned: true };
189
201
  }
190
202
  if (isClassProvider(provider)) {
191
203
  const deps = provider.inject ?? [];
192
204
  const args = deps.map((d) => this.resolveInternal(unwrapToken(d), state, singletonAncestor)
193
205
  .value);
194
206
  const ctor = provider.useClass;
195
- return new ctor(...args);
207
+ return { value: new ctor(...args), owned: true };
196
208
  }
197
209
  throw new Error("Unsupported container provider.");
198
210
  }
@@ -239,7 +251,9 @@ export class ContainerResolver {
239
251
  switch (event.operation) {
240
252
  case RegistryOperation.REPLACE:
241
253
  case RegistryOperation.REMOVE: {
242
- this.evictSingleton(unwrapToken(event.token));
254
+ const token = unwrapToken(event.token);
255
+ this.evictSingleton(token);
256
+ this.evictAliasesOf(token);
243
257
  break;
244
258
  }
245
259
  case RegistryOperation.CLEAR:
@@ -258,6 +272,40 @@ export class ContainerResolver {
258
272
  this.singletonCache.delete(token);
259
273
  this.onSingletonEvicted?.(token);
260
274
  }
275
+ /**
276
+ * Evicts every cached singleton whose `useExisting` chain ends at `target`.
277
+ *
278
+ * A cached alias holds the target's instance under its own token, so
279
+ * evicting the target alone left the alias serving the old — by now
280
+ * disposed — instance after `replace()`/`remove()`.
281
+ */
282
+ evictAliasesOf(target) {
283
+ for (const cached of [...this.singletonCache.keys()]) {
284
+ if (cached === target)
285
+ continue;
286
+ if (this.aliasChainReaches(cached, target))
287
+ this.evictSingleton(cached);
288
+ }
289
+ }
290
+ aliasChainReaches(token, target) {
291
+ const visited = new Set();
292
+ let current = token;
293
+ for (;;) {
294
+ const registration = this.registry.get(current);
295
+ if (!registration)
296
+ return false;
297
+ const provider = normalizeProvider(registration.provider);
298
+ if (!isExistingProvider(provider))
299
+ return false;
300
+ const next = unwrapToken(provider.useExisting);
301
+ if (next === target)
302
+ return true;
303
+ if (visited.has(next))
304
+ return false;
305
+ visited.add(next);
306
+ current = next;
307
+ }
308
+ }
261
309
  }
262
310
  function isPromiseLike(value) {
263
311
  return (typeof value === "object" &&
package/dist/index.d.ts CHANGED
@@ -12,4 +12,5 @@ export * from "./containerRegistry/index.js";
12
12
  export * from "./containerResolution/index.js";
13
13
  export * from "./containerScope/index.js";
14
14
  export * from "./containerToken/index.js";
15
+ export { CircularDependencyError, DuplicateRegistrationError, RegistrationNotFoundError, ProviderResolutionError, } from "@zudojs/errors";
15
16
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -12,4 +12,7 @@ export * from "./containerRegistry/index.js";
12
12
  export * from "./containerResolution/index.js";
13
13
  export * from "./containerScope/index.js";
14
14
  export * from "./containerToken/index.js";
15
+ // Error classes the container throws but that live in @zudojs/errors,
16
+ // re-exported so `instanceof` checks need only this package.
17
+ export { CircularDependencyError, DuplicateRegistrationError, RegistrationNotFoundError, ProviderResolutionError, } from "@zudojs/errors";
15
18
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/container",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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,13 +18,17 @@
18
18
  "!dist/.tsbuildinfo"
19
19
  ],
20
20
  "dependencies": {
21
- "@zudojs/errors": "1.0.0"
21
+ "@zudojs/errors": "1.0.1"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "7.0.2",
25
25
  "vitest": "^4.1.11"
26
26
  },
27
27
  "license": "MIT",
28
+ "author": {
29
+ "name": "Oluwayemi Oyinlola",
30
+ "url": "https://github.com/oyinlola-tech"
31
+ },
28
32
  "publishConfig": {
29
33
  "access": "public"
30
34
  },