@codefast/di 0.10.1 → 0.12.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +376 -0
  2. package/README.md +94 -17
  3. package/dist/{ambient/active-container.d.ts → ambient-container.d.ts} +7 -2
  4. package/dist/{ambient/active-container.js → ambient-container.js} +6 -1
  5. package/dist/container/{binding-builders.d.ts → binding-chain.d.ts} +27 -16
  6. package/dist/container/binding-chain.js +254 -0
  7. package/dist/container/container.d.ts +11 -2
  8. package/dist/container/container.js +293 -73
  9. package/dist/core/binding-builders.d.ts +114 -0
  10. package/dist/core/binding-declaration.d.ts +127 -0
  11. package/dist/core/binding-declaration.js +187 -0
  12. package/dist/core/binding.d.ts +62 -138
  13. package/dist/core/binding.js +54 -17
  14. package/dist/core/module.d.ts +8 -5
  15. package/dist/core/module.js +17 -3
  16. package/dist/core/registry.d.ts +17 -28
  17. package/dist/core/registry.js +92 -133
  18. package/dist/core/state-epoch.d.ts +18 -1
  19. package/dist/core/state-epoch.js +17 -0
  20. package/dist/core/tag.js +1 -1
  21. package/dist/decorators/inject.js +6 -5
  22. package/dist/decorators/injectable.js +4 -2
  23. package/dist/decorators/{lifecycle-decorators.js → lifecycle.js} +9 -3
  24. package/dist/decorators/metadata-record.d.ts +9 -0
  25. package/dist/decorators/metadata-record.js +20 -0
  26. package/dist/{errors/errors.d.ts → errors.d.ts} +84 -9
  27. package/dist/{errors/errors.js → errors.js} +101 -17
  28. package/dist/index.d.ts +14 -10
  29. package/dist/index.js +7 -6
  30. package/dist/injection/{resolve-options.js → dependency-slot.js} +6 -4
  31. package/dist/injection/descriptor.d.ts +1 -1
  32. package/dist/injection/descriptor.js +3 -7
  33. package/dist/introspection/dependency-graph.d.ts +8 -3
  34. package/dist/introspection/dependency-graph.js +48 -26
  35. package/dist/introspection/explanation.d.ts +101 -0
  36. package/dist/introspection/explanation.js +151 -0
  37. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  38. package/dist/introspection/inspector.d.ts +9 -2
  39. package/dist/introspection/inspector.js +30 -28
  40. package/dist/lifecycle/{lifecycle-manager.d.ts → hooks.d.ts} +9 -2
  41. package/dist/lifecycle/{lifecycle-manager.js → hooks.js} +59 -30
  42. package/dist/lifecycle/{scope-manager.d.ts → scopes.d.ts} +3 -5
  43. package/dist/lifecycle/{scope-manager.js → scopes.js} +36 -29
  44. package/dist/metadata/{metadata-reader-token.d.ts → reader-token.d.ts} +1 -1
  45. package/dist/metadata/{symbol-metadata-reader.d.ts → symbol-reader.d.ts} +1 -1
  46. package/dist/metadata/{symbol-metadata-reader.js → symbol-reader.js} +1 -1
  47. package/dist/metadata/{metadata-types.d.ts → types.d.ts} +1 -1
  48. package/dist/metadata/types.js +1 -0
  49. package/dist/metadata/verify.d.ts +43 -0
  50. package/dist/metadata/verify.js +133 -0
  51. package/dist/metadata/{verifying-metadata-reader.d.ts → verifying-reader.d.ts} +5 -4
  52. package/dist/metadata/verifying-reader.js +53 -0
  53. package/dist/resolution/async-fan-out.d.ts +12 -0
  54. package/dist/resolution/async-fan-out.js +26 -0
  55. package/dist/resolution/cache/activation-need.d.ts +1 -2
  56. package/dist/resolution/cache/activation-need.js +16 -19
  57. package/dist/resolution/cache/class-introspector.d.ts +38 -36
  58. package/dist/resolution/cache/class-introspector.js +79 -159
  59. package/dist/resolution/cache/{binding-lookup-cache.d.ts → lookup.d.ts} +7 -9
  60. package/dist/resolution/cache/{binding-lookup-cache.js → lookup.js} +70 -25
  61. package/dist/resolution/context.d.ts +16 -24
  62. package/dist/resolution/context.js +47 -56
  63. package/dist/resolution/path.d.ts +128 -0
  64. package/dist/resolution/path.js +167 -0
  65. package/dist/resolution/plan/{plan-codegen.d.ts → codegen.d.ts} +5 -4
  66. package/dist/resolution/plan/{plan-codegen.js → codegen.js} +58 -32
  67. package/dist/resolution/plan/{instantiation-plan.d.ts → compiler.d.ts} +1 -1
  68. package/dist/resolution/plan/{instantiation-plan.js → compiler.js} +64 -24
  69. package/dist/resolution/resolver.d.ts +11 -12
  70. package/dist/resolution/resolver.js +573 -356
  71. package/dist/resolution/select/{binding-select.d.ts → candidates.d.ts} +30 -0
  72. package/dist/resolution/select/{binding-select.js → candidates.js} +51 -10
  73. package/dist/resolution/select/constraints.js +1 -1
  74. package/package.json +87 -67
  75. package/dist/container/binding-builders.js +0 -305
  76. package/dist/core/binding-scope.d.ts +0 -12
  77. package/dist/core/binding-scope.js +0 -12
  78. package/dist/metadata/verifying-metadata-reader.js +0 -31
  79. package/dist/resolution/path/resolution-path.d.ts +0 -93
  80. package/dist/resolution/path/resolution-path.js +0 -116
  81. /package/dist/{metadata/metadata-types.js → core/binding-builders.js} +0 -0
  82. /package/dist/decorators/{lifecycle-decorators.d.ts → lifecycle.d.ts} +0 -0
  83. /package/dist/injection/{resolve-options.d.ts → dependency-slot.d.ts} +0 -0
  84. /package/dist/{errors → introspection}/diagnostics.d.ts +0 -0
  85. /package/dist/{errors → introspection}/diagnostics.js +0 -0
  86. /package/dist/metadata/{metadata-keys.d.ts → keys.d.ts} +0 -0
  87. /package/dist/metadata/{metadata-keys.js → keys.js} +0 -0
  88. /package/dist/metadata/{metadata-reader-token.js → reader-token.js} +0 -0
@@ -1,22 +1,23 @@
1
- import { BindingChain } from "#container/binding-builders";
2
- import { NO_INSTANCE } from "#core/binding";
3
- import { effectiveBindingScope } from "#core/binding-scope";
1
+ import { BindingChain } from "#container/binding-chain";
2
+ import { effectiveBindingScope, NO_INSTANCE } from "#core/binding";
4
3
  import { constraintRequirementsOf } from "#core/constraint-requirement";
5
4
  import { getOrInsert, getOrInsertComputed } from "#core/map-upsert";
6
5
  import { isSyncModule, MODULE_SETUP } from "#core/module";
7
6
  import { BindingRegistry } from "#core/registry";
7
+ import { advanceDisposeEpoch, disposeEpochRef } from "#core/state-epoch";
8
8
  import { tokenName } from "#core/token";
9
- import { RESOLUTION_DIAGNOSTICS } from "#errors/diagnostics";
10
- import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, SyncDisposalNotSupportedError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors/errors";
11
- import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#injection/resolve-options";
9
+ import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, SyncDisposalNotSupportedError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors";
10
+ import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#injection/dependency-slot";
12
11
  import { buildDependencyGraph } from "#introspection/dependency-graph";
12
+ import { RESOLUTION_DIAGNOSTICS } from "#introspection/diagnostics";
13
+ import { explainRequest } from "#introspection/explanation";
13
14
  import { Inspector } from "#introspection/inspector";
14
- import { LifecycleManager } from "#lifecycle/lifecycle-manager";
15
- import { ScopeManager } from "#lifecycle/scope-manager";
16
- import { MetadataReaderToken } from "#metadata/metadata-reader-token";
17
- import { defaultMetadataReader } from "#metadata/symbol-metadata-reader";
18
- import { verifyingMetadataReader } from "#metadata/verifying-metadata-reader";
19
- import { ROOT_BRANCH } from "#resolution/path/resolution-path";
15
+ import { LifecycleManager } from "#lifecycle/hooks";
16
+ import { ScopeManager } from "#lifecycle/scopes";
17
+ import { MetadataReaderToken } from "#metadata/reader-token";
18
+ import { defaultMetadataReader } from "#metadata/symbol-reader";
19
+ import { verifyingMetadataReader } from "#metadata/verifying-reader";
20
+ import { ROOT_BRANCH } from "#resolution/path";
20
21
  import { DependencyResolver } from "#resolution/resolver";
21
22
  /** Whether a requirement's name is declared — on its token when it names one, on any token otherwise. */
22
23
  function isSlotNameDeclared(declared, requirement) {
@@ -49,12 +50,17 @@ const NO_DEACTIVATION_PAIRS = Object.freeze([]);
49
50
  // ── DefaultContainer ─────────────────────────────────────────────────────────────────────────────────────────────────
50
51
  class DefaultContainer {
51
52
  #disposed = false;
53
+ // The dispose epoch at which this child's chain was last confirmed live. Every dispose anywhere
54
+ // bumps the epoch, so an unchanged epoch means no ancestor closed since — the chain needs no walk.
55
+ #liveGeneration = -1;
52
56
  // The one teardown run — every dispose() call returns it once it exists.
53
57
  #disposePromise;
54
58
  #registry;
55
59
  #scope;
56
60
  #lifecycle;
57
61
  #resolver;
62
+ // Whether the reader came from `ContainerOptions` here or on an ancestor, which no token binding may override.
63
+ #hasConfiguredReader = false;
58
64
  // Built on the first introspecting call — a container that only binds and resolves never needs it.
59
65
  #inspector;
60
66
  #parent;
@@ -62,9 +68,16 @@ class DefaultContainer {
62
68
  #moduleRefs;
63
69
  // Module bindings: module -> array of binding IDs registered by it
64
70
  #moduleBindingIds;
71
+ // Module imports: module -> the modules it imported, so unload decrements their ref-count too.
72
+ #moduleImports;
65
73
  // One shared registration for every chain this container's own `bind()` creates.
66
74
  #registration;
75
+ // Bindings a plain last-wins bind displaced with a teardown still owed, by id: an unbind of that id or an
76
+ // unload of its module drains it, and dispose runs a constant's hook, which neither the registry nor the cache holds.
77
+ #orphanedBindings;
67
78
  constructor(parent, options) {
79
+ // oxlint-disable-next-line typescript/unbound-method -- installed as this instance's own method, so `this` is it
80
+ this.resolve = parent === undefined ? DefaultContainer.#rootResolve : DefaultContainer.#childResolve;
68
81
  this.#parent = parent;
69
82
  this.#registry = new BindingRegistry();
70
83
  this.#scope = new ScopeManager(parent !== undefined);
@@ -80,7 +93,7 @@ class DefaultContainer {
80
93
  if (this.#inspector !== undefined) {
81
94
  builtSubsystems.push("container.inspector");
82
95
  }
83
- if (this.#moduleRefs !== undefined || this.#moduleBindingIds !== undefined) {
96
+ if (this.#moduleRefs !== undefined || this.#moduleBindingIds !== undefined || this.#moduleImports !== undefined) {
84
97
  builtSubsystems.push("container.moduleTables");
85
98
  }
86
99
  if (this.#registry.isRecordMapBuilt) {
@@ -103,13 +116,14 @@ class DefaultContainer {
103
116
  }
104
117
  #initResolver(configuredReader) {
105
118
  const parent = this.#parent;
119
+ this.#hasConfiguredReader = configuredReader !== undefined || (parent !== undefined && parent.#hasConfiguredReader);
106
120
  const metadataReader = verifyingMetadataReader(configuredReader ?? (parent === undefined ? defaultMetadataReader : parent.#readerForChild()));
107
121
  const parentResolver = parent === undefined ? undefined : parent.#resolver;
108
122
  this.#resolver = new DependencyResolver(this.#registry, this.#scope, this.#lifecycle, metadataReader, this, parentResolver);
109
123
  }
110
- /** What a container being constructed under this one inherits: a reader bound here, else this one's. */
124
+ /** What a container being constructed under this one inherits: a configured reader, else one bound here, else this one's. */
111
125
  #readerForChild() {
112
- if (this.#registry.has(MetadataReaderToken)) {
126
+ if (!this.#hasConfiguredReader && this.#registry.has(MetadataReaderToken)) {
113
127
  try {
114
128
  return this.#resolver.resolve(MetadataReaderToken, undefined, []);
115
129
  }
@@ -124,7 +138,7 @@ class DefaultContainer {
124
138
  return this.#resolver.metadataReader;
125
139
  }
126
140
  get isDisposed() {
127
- return this.#disposed;
141
+ return this.#isChainDisposed();
128
142
  }
129
143
  // ── Binding ────────────────────────────────────────────────────────────────────────────────────────────────────────
130
144
  bind(token) {
@@ -133,15 +147,36 @@ class DefaultContainer {
133
147
  }
134
148
  /** The registration every non-module chain shares, so `bind()` allocates only the builder. */
135
149
  #ownRegistration() {
136
- return (this.#registration ??= { registry: this.#registry, scope: this.#scope, moduleBindingIds: undefined });
150
+ return (this.#registration ??= {
151
+ registry: this.#registry,
152
+ scope: this.#scope,
153
+ moduleBindingIds: undefined,
154
+ onDisplaced: (binding) => {
155
+ this.#recordDisplaced(binding);
156
+ },
157
+ });
137
158
  }
138
- /** One registration per module load, holding that module's id list directly. */
159
+ // A displaced binding holding a cached instance or owing a constant's deactivation is remembered until
160
+ // dispose, an unbind of its id or its module's unload; every other displaced binding is dropped.
161
+ #recordDisplaced(binding) {
162
+ if (binding.instance !== NO_INSTANCE || this.#owesConstantDeactivation(binding)) {
163
+ (this.#orphanedBindings ??= new Map()).set(binding.identifier, binding);
164
+ }
165
+ }
166
+ /**
167
+ * One registration per module load, holding that module's id list directly.
168
+ *
169
+ * @remarks It borrows the container's own displacement callback, so a constant a module's bind
170
+ * displaces is kept for dispose exactly as one displaced by `bind()` is.
171
+ */
139
172
  #moduleRegistration(moduleRef) {
140
173
  this.#moduleBindingIds ??= new Map();
174
+ const { onDisplaced } = this.#ownRegistration();
141
175
  return {
142
176
  registry: this.#registry,
143
177
  scope: this.#scope,
144
178
  moduleBindingIds: getOrInsert(this.#moduleBindingIds, moduleRef, []),
179
+ onDisplaced,
145
180
  };
146
181
  }
147
182
  #createBindToBuilder(token, registration = this.#ownRegistration()) {
@@ -154,30 +189,43 @@ class DefaultContainer {
154
189
  /** Remove bindings from registry + scope and collect [binding, instance] pairs for deactivation. */
155
190
  #collectDeactivationPairs(tokenOrId) {
156
191
  if (typeof tokenOrId === "number") {
157
- const binding = this.#registry.removeById(tokenOrId);
192
+ const binding = this.#registry.removeById(tokenOrId) ?? this.#retireNotLive(tokenOrId);
158
193
  return binding === undefined ? NO_DEACTIVATION_PAIRS : this.#drainSingletons([binding]);
159
194
  }
160
195
  // Dropping the whole token in one pass: removing each binding by id instead would re-scan and
161
196
  // re-index the token's binding list once per binding.
162
197
  return this.#drainSingletons(this.#registry.removeByToken(tokenOrId));
163
198
  }
199
+ /** Retires an id the registry no longer holds, returning the displaced binding still owed a teardown. */
200
+ #retireNotLive(id) {
201
+ const orphaned = this.#orphanedBindings?.get(id);
202
+ if (orphaned !== undefined) {
203
+ this.#orphanedBindings.delete(id);
204
+ }
205
+ return orphaned;
206
+ }
164
207
  /** Drain scope entries for already-removed bindings, and pair each one that still owes a deactivation. */
165
208
  #drainSingletons(bindings) {
166
- // Allocated by the first pair owed: an unbind or rebind of a binding nothing ever cached — the
167
- // hot-swap shape — owes no deactivation and hands the shared empty list back.
209
+ // One flat list, allocated by the first pair owed: an unbind or rebind of a binding nothing
210
+ // ever cached — the hot-swap shape — owes no deactivation and hands the shared empty list back.
168
211
  let pairs;
169
- for (const binding of bindings) {
170
- if (binding.instance !== NO_INSTANCE) {
171
- (pairs ??= []).push([binding, binding.instance]);
172
- this.#scope.deleteSingleton(binding);
173
- }
174
- else if (this.#owesConstantDeactivation(binding)) {
175
- (pairs ??= []).push([binding, binding.value]);
176
- }
177
- this.#scope.deleteScoped(binding.identifier);
212
+ for (let index = 0; index < bindings.length; index += 1) {
213
+ pairs = this.#drainSingleton(bindings[index], pairs);
178
214
  }
179
215
  return pairs ?? NO_DEACTIVATION_PAIRS;
180
216
  }
217
+ /** One binding's share of {@link DefaultContainer.#drainSingletons}, allocating `pairs` on the first pair owed. */
218
+ #drainSingleton(binding, pairs) {
219
+ if (binding.instance !== NO_INSTANCE) {
220
+ (pairs ??= []).push(binding, binding.instance);
221
+ this.#scope.deleteSingleton(binding);
222
+ }
223
+ else if (this.#owesConstantDeactivation(binding)) {
224
+ (pairs ??= []).push(binding, binding.value);
225
+ }
226
+ this.#scope.deleteScoped(binding.scopedCacheKey);
227
+ return pairs;
228
+ }
181
229
  /**
182
230
  * Whether a constant still owes its deactivation.
183
231
  *
@@ -195,32 +243,38 @@ class DefaultContainer {
195
243
  return;
196
244
  }
197
245
  const reader = this.#getMetadataReader();
198
- const errors = [];
199
- for (const [binding, instance] of pairs) {
246
+ const lifecycle = this.#lifecycle;
247
+ let errors;
248
+ for (let index = 0; index < pairs.length; index += 2) {
200
249
  try {
201
- this.#lifecycle.runDeactivationSync(binding, instance, reader);
250
+ lifecycle.runDeactivationSync(pairs[index], pairs[index + 1], reader);
202
251
  }
203
252
  catch (error) {
204
- errors.push(error);
253
+ (errors ??= []).push(error);
205
254
  }
206
255
  }
207
- throwCollected(errors, "unbind completed, but deactivation hooks threw");
256
+ if (errors !== undefined) {
257
+ throwCollected(errors, "unbind completed, but deactivation hooks threw");
258
+ }
208
259
  }
209
260
  async #deactivatePairs(pairs) {
210
261
  if (pairs.length === 0) {
211
262
  return;
212
263
  }
213
264
  const reader = this.#getMetadataReader();
214
- const errors = [];
215
- for (const [binding, instance] of pairs) {
265
+ const lifecycle = this.#lifecycle;
266
+ let errors;
267
+ for (let index = 0; index < pairs.length; index += 2) {
216
268
  try {
217
- await this.#lifecycle.runDeactivation(binding, instance, reader);
269
+ await lifecycle.runDeactivation(pairs[index], pairs[index + 1], reader);
218
270
  }
219
271
  catch (error) {
220
- errors.push(error);
272
+ (errors ??= []).push(error);
221
273
  }
222
274
  }
223
- throwCollected(errors, "unbind completed, but deactivation hooks threw");
275
+ if (errors !== undefined) {
276
+ throwCollected(errors, "unbind completed, but deactivation hooks threw");
277
+ }
224
278
  }
225
279
  #unbindSync(tokenOrId) {
226
280
  this.#deactivatePairsSync(this.#collectDeactivationPairs(tokenOrId));
@@ -232,19 +286,50 @@ class DefaultContainer {
232
286
  unbindAll() {
233
287
  this.#assertNotDisposed();
234
288
  this.#deactivatePairsSync(this.#drainSingletons(this.#registry.clear()));
289
+ this.#resetModuleTables();
235
290
  }
236
291
  async unbindAllAsync() {
237
292
  this.#assertNotDisposed();
238
293
  await this.#deactivatePairs(this.#drainSingletons(this.#registry.clear()));
294
+ this.#resetModuleTables();
295
+ }
296
+ // Clearing the registry also clears module bookkeeping, so a later `load()` of the same module is
297
+ // not silently skipped as already-loaded. Run after deactivation so the module's hooks still fire.
298
+ #resetModuleTables() {
299
+ this.#moduleRefs = undefined;
300
+ this.#moduleBindingIds = undefined;
301
+ this.#moduleImports = undefined;
239
302
  }
240
303
  rebind(token) {
241
304
  this.#assertNotDisposed();
242
- if (!this.#registry.has(token)) {
243
- throw new RebindUnboundTokenError(tokenName(token));
244
- }
245
- // Unbind existing (sync — if async deactivation, will throw AsyncDeactivationError)
246
- this.#unbindSync(token);
247
- return this.#createBindToBuilder(token);
305
+ // A lone default binding that owes no deactivation is replaced by the new chain's own
306
+ // registration, so the swap unbinds nothing up front. Anything else is unbound first, so an
307
+ // async deactivation throws here — before a replacement exists — exactly as `unbind()` does.
308
+ const lone = this.#registry.getFastDefault(token);
309
+ if (lone === undefined || lone.instance !== NO_INSTANCE || this.#owesConstantDeactivation(lone)) {
310
+ if (!this.#registry.has(token)) {
311
+ throw new RebindUnboundTokenError(tokenName(token));
312
+ }
313
+ this.#unbindSync(token);
314
+ return this.#createBindToBuilder(token);
315
+ }
316
+ return new BindingChain(token, this.#rebindRegistration());
317
+ }
318
+ #displacingRegistration;
319
+ /** A registration whose displaced binding is deactivated on the spot instead of kept for dispose, for `rebind`. */
320
+ #rebindRegistration() {
321
+ return (this.#displacingRegistration ??= {
322
+ registry: this.#registry,
323
+ scope: this.#scope,
324
+ moduleBindingIds: undefined,
325
+ // One binding at a time, so the hot-swap rebind that owes nothing allocates nothing.
326
+ deactivateDisplaced: (binding) => {
327
+ const pairs = this.#drainSingleton(binding, undefined);
328
+ if (pairs !== undefined) {
329
+ this.#deactivatePairsSync(pairs);
330
+ }
331
+ },
332
+ });
248
333
  }
249
334
  // ── Module ─────────────────────────────────────────────────────────────────────────────────────────────────────────
250
335
  load(...modules) {
@@ -266,14 +351,23 @@ class DefaultContainer {
266
351
  continue;
267
352
  }
268
353
  moduleRefs.set(moduleRef, 1);
269
- const builder = this.#createModuleBuilder(moduleRef);
270
- try {
271
- module[MODULE_SETUP](builder);
354
+ this.#applySyncModule(module, moduleRef);
355
+ }
356
+ }
357
+ /** Runs a sync module's setup, or files its declarations, rolling the load back if either throws. */
358
+ #applySyncModule(module, moduleRef) {
359
+ const setup = module[MODULE_SETUP];
360
+ try {
361
+ if (typeof setup === "function") {
362
+ setup(this.#createModuleBuilder(moduleRef));
272
363
  }
273
- catch (error) {
274
- throw this.#rollbackFailedLoadSync(moduleRef, error);
364
+ else {
365
+ BindingChain.registerDeclared(setup, this.#moduleRegistration(moduleRef));
275
366
  }
276
367
  }
368
+ catch (error) {
369
+ throw this.#rollbackFailedLoadSync(moduleRef, error);
370
+ }
277
371
  }
278
372
  async loadAsync(...modules) {
279
373
  this.#assertNotDisposed();
@@ -313,13 +407,7 @@ class DefaultContainer {
313
407
  }
314
408
  moduleRefs.set(moduleRef, 1);
315
409
  if (isSyncModule(module)) {
316
- const builder = this.#createModuleBuilder(moduleRef);
317
- try {
318
- module[MODULE_SETUP](builder);
319
- }
320
- catch (error) {
321
- throw this.#rollbackFailedLoadSync(moduleRef, error);
322
- }
410
+ this.#applySyncModule(module, moduleRef);
323
411
  }
324
412
  else {
325
413
  const importPromises = [];
@@ -344,6 +432,7 @@ class DefaultContainer {
344
432
  return {
345
433
  bind: (token) => this.#createBindToBuilder(token, registration),
346
434
  import: (...modules) => {
435
+ this.#recordModuleImports(moduleRef, modules);
347
436
  this.#loadSyncModules(modules);
348
437
  },
349
438
  };
@@ -353,12 +442,21 @@ class DefaultContainer {
353
442
  return {
354
443
  bind: (token) => this.#createBindToBuilder(token, registration),
355
444
  import: (...modules) => {
445
+ this.#recordModuleImports(moduleRef, modules);
356
446
  for (const module of modules) {
357
447
  importPromises.push(this.#loadOneModuleAsync(module));
358
448
  }
359
449
  },
360
450
  };
361
451
  }
452
+ // Records that `moduleRef` imported each module, so unloading it later releases the reference the
453
+ // import took — otherwise an imported module's ref-count only ever climbs.
454
+ #recordModuleImports(moduleRef, modules) {
455
+ const list = getOrInsert((this.#moduleImports ??= new Map()), moduleRef, []);
456
+ for (const module of modules) {
457
+ list.push(module);
458
+ }
459
+ }
362
460
  unload(...modules) {
363
461
  this.#assertNotDisposed();
364
462
  for (const module of modules) {
@@ -368,11 +466,12 @@ class DefaultContainer {
368
466
  /** Unregister module bindings and collect [binding, instance] pairs for deactivation. */
369
467
  #removeModuleBindings(ref) {
370
468
  this.#moduleRefs?.delete(ref);
469
+ this.#moduleImports?.delete(ref);
371
470
  const ids = this.#moduleBindingIds?.get(ref) ?? [];
372
471
  this.#moduleBindingIds?.delete(ref);
373
472
  const removed = [];
374
473
  for (const id of ids) {
375
- const binding = this.#registry.removeById(id);
474
+ const binding = this.#registry.removeById(id) ?? this.#retireNotLive(id);
376
475
  if (binding !== undefined) {
377
476
  removed.push(binding);
378
477
  }
@@ -382,7 +481,15 @@ class DefaultContainer {
382
481
  #unloadModuleSync(ref) {
383
482
  const count = this.#moduleRefs?.get(ref) ?? 0;
384
483
  if (count <= 1) {
484
+ // Read the imports before removal clears them, then release each — a child only unbinds once
485
+ // its own ref-count reaches zero, mirroring the increment `import` took on load.
486
+ const imported = this.#moduleImports?.get(ref);
385
487
  this.#deactivatePairsSync(this.#removeModuleBindings(ref));
488
+ if (imported !== undefined) {
489
+ for (const child of imported) {
490
+ this.#unloadModuleSync(child);
491
+ }
492
+ }
386
493
  }
387
494
  else {
388
495
  this.#moduleRefs.set(ref, count - 1);
@@ -397,7 +504,13 @@ class DefaultContainer {
397
504
  async #unloadModuleAsync(ref) {
398
505
  const count = this.#moduleRefs?.get(ref) ?? 0;
399
506
  if (count <= 1) {
507
+ const imported = this.#moduleImports?.get(ref);
400
508
  await this.#deactivatePairs(this.#removeModuleBindings(ref));
509
+ if (imported !== undefined) {
510
+ for (const child of imported) {
511
+ await this.#unloadModuleAsync(child);
512
+ }
513
+ }
401
514
  }
402
515
  else {
403
516
  this.#moduleRefs.set(ref, count - 1);
@@ -420,9 +533,10 @@ class DefaultContainer {
420
533
  this.#assertNotDisposed();
421
534
  this.#lifecycle.registerDeactivation(token, handler);
422
535
  }
423
- // ── Resolution ─────────────────────────────────────────────────────────────────────────────────────────────────────
424
- resolve(token, options) {
425
- this.#assertNotDisposed();
536
+ static #rootResolve(token, options) {
537
+ if (this.#disposed) {
538
+ throw new DisposedContainerError();
539
+ }
426
540
  const rootStack = this.#resolver.rootStack;
427
541
  // A resolve already holding the shared stack means this one is nested; it mints its own.
428
542
  if (rootStack.length !== 0) {
@@ -435,8 +549,28 @@ class DefaultContainer {
435
549
  }
436
550
  return this.#resolver.resolve(token, options, rootStack);
437
551
  }
552
+ // The root's body again behind the child's check, so neither role's compiled resolve carries the other's path.
553
+ static #childResolve(token, options) {
554
+ this.#assertChainLive();
555
+ const rootStack = this.#resolver.rootStack;
556
+ if (rootStack.length !== 0) {
557
+ return options === undefined
558
+ ? this.#resolver.resolveFromContext(token, [])
559
+ : this.#resolver.resolve(token, options, []);
560
+ }
561
+ if (options === undefined) {
562
+ return this.#resolver.resolveFromContext(token, rootStack);
563
+ }
564
+ return this.#resolver.resolve(token, options, rootStack);
565
+ }
438
566
  resolveAsync(token, options) {
439
- this.#assertNotDisposed();
567
+ // Not `async`: the guard's throw becomes the rejection this method promises.
568
+ try {
569
+ this.#assertNotDisposed();
570
+ }
571
+ catch (disposedError) {
572
+ return Promise.reject(disposedError);
573
+ }
440
574
  if (options === undefined) {
441
575
  return this.#resolver.resolveAsyncFromRoot(token);
442
576
  }
@@ -450,7 +584,12 @@ class DefaultContainer {
450
584
  : this.#resolver.resolveOptional(token, options, []);
451
585
  }
452
586
  resolveOptionalAsync(token, options) {
453
- this.#assertNotDisposed();
587
+ try {
588
+ this.#assertNotDisposed();
589
+ }
590
+ catch (disposedError) {
591
+ return Promise.reject(disposedError);
592
+ }
454
593
  return this.#resolver.resolveOptionalAsync(token, options, [], ROOT_BRANCH);
455
594
  }
456
595
  resolveAll(token, options) {
@@ -464,7 +603,12 @@ class DefaultContainer {
464
603
  : this.#resolver.resolveAll(token, options, []);
465
604
  }
466
605
  resolveAllAsync(token, options) {
467
- this.#assertNotDisposed();
606
+ try {
607
+ this.#assertNotDisposed();
608
+ }
609
+ catch (disposedError) {
610
+ return Promise.reject(disposedError);
611
+ }
468
612
  if (options === undefined) {
469
613
  return this.#resolver.resolveRootCollectionAsync(token);
470
614
  }
@@ -473,7 +617,10 @@ class DefaultContainer {
473
617
  // ── Child ──────────────────────────────────────────────────────────────────────────────────────────────────────────
474
618
  createChild() {
475
619
  this.#assertNotDisposed();
476
- return new DefaultContainer(this);
620
+ const child = new DefaultContainer(this);
621
+ // Born confirmed live: the parent's chain was checked just above, so the child's first read skips the walk.
622
+ child.#liveGeneration = disposeEpochRef.value;
623
+ return child;
477
624
  }
478
625
  // ── Dispose ────────────────────────────────────────────────────────────────────────────────────────────────────────
479
626
  dispose() {
@@ -485,6 +632,8 @@ class DefaultContainer {
485
632
  this.#disposed = true;
486
633
  // Refuses new materializations immediately; in-flight ones are drained just below.
487
634
  this.#scope.markClosed();
635
+ // Descendants read this to learn their chain closed — a resolve through a disposed ancestor is refused.
636
+ advanceDisposeEpoch();
488
637
  await this.#scope.settleInflight();
489
638
  const reader = this.#getMetadataReader();
490
639
  const errors = [];
@@ -515,6 +664,20 @@ class DefaultContainer {
515
664
  }
516
665
  }
517
666
  }
667
+ // Constants a plain last-wins bind() displaced left the registry with their hook still owed; one
668
+ // whose activation cached an instance was drained above, so only the untouched ones remain.
669
+ if (this.#orphanedBindings !== undefined) {
670
+ for (const binding of this.#orphanedBindings.values()) {
671
+ if (binding.instance === NO_INSTANCE && this.#owesConstantDeactivation(binding)) {
672
+ try {
673
+ await this.#lifecycle.runDeactivation(binding, binding.value, reader);
674
+ }
675
+ catch (error) {
676
+ errors.push(error);
677
+ }
678
+ }
679
+ }
680
+ }
518
681
  this.#scope.clearAll();
519
682
  throwCollected(errors, "dispose() completed, but deactivation hooks threw");
520
683
  }
@@ -566,6 +729,11 @@ class DefaultContainer {
566
729
  if (!this.#isBoundInChain(hookToken)) {
567
730
  throw new UnreachableLifecycleHookError(tokenName(hookToken), phase);
568
731
  }
732
+ // A deactivation only ever runs for a singleton or constant, so a hook on a token whose every
733
+ // binding is scoped or transient can never fire — the builder blocks it, `container.onDeactivation` cannot.
734
+ if (phase === "onDeactivation" && !this.#hasDeactivatableBindingInChain(hookToken)) {
735
+ throw new UnreachableLifecycleHookError(tokenName(hookToken), phase, "no-deactivatable-binding");
736
+ }
569
737
  }
570
738
  this.#validateConstraintRequirements(allBindings);
571
739
  }
@@ -608,6 +776,16 @@ class DefaultContainer {
608
776
  const parent = this.#parent;
609
777
  return parent !== undefined && parent.#isBoundInChain(token);
610
778
  }
779
+ // Whether the token has a binding a deactivation hook can run for — a singleton or a constant.
780
+ #hasDeactivatableBindingInChain(token) {
781
+ for (const binding of this.#registry.getAll(token)) {
782
+ if (effectiveBindingScope(binding) === "singleton") {
783
+ return true;
784
+ }
785
+ }
786
+ const parent = this.#parent;
787
+ return parent !== undefined && parent.#hasDeactivatableBindingInChain(token);
788
+ }
611
789
  #isSingletonStaticAnalyzableBinding(binding) {
612
790
  if (effectiveBindingScope(binding) !== "singleton") {
613
791
  return false;
@@ -701,10 +879,7 @@ class DefaultContainer {
701
879
  #collectStaticDependencyEdges(binding, reader) {
702
880
  const edges = [];
703
881
  for (const dep of this.#staticDependencies(binding, reader)) {
704
- // An optional dependency imposes no scope constraint: it may legitimately be absent.
705
- if (dep.optional) {
706
- continue;
707
- }
882
+ // An optional dependency that is absent peeks no candidate; one that is bound is captured like any other.
708
883
  const depOptions = injectionSlotToResolveOptions(dep);
709
884
  for (const candidate of this.#peekDependencyCandidates(dep, depOptions)) {
710
885
  const terminal = this.#followAliasChainToTerminal(candidate, depOptions);
@@ -718,7 +893,16 @@ class DefaultContainer {
718
893
  // ── Introspection ──────────────────────────────────────────────────────────────────────────────────────────────────
719
894
  has(token, options) {
720
895
  this.#assertNotDisposed();
721
- return this.#getInspector().hasOwn(token, options) || (this.#parent?.has(token, options) ?? false);
896
+ return this.#hasInChain(token, options);
897
+ }
898
+ // Recurses through the parent's registry, not its public `has`, so a live child answers even when
899
+ // an ancestor is disposed — the disposed-guard belongs on the entry point, not every chain hop.
900
+ #hasInChain(token, options) {
901
+ if (this.#getInspector().hasOwn(token, options)) {
902
+ return true;
903
+ }
904
+ const parent = this.#parent;
905
+ return parent !== undefined && parent.#hasInChain(token, options);
722
906
  }
723
907
  hasOwn(token, options) {
724
908
  this.#assertNotDisposed();
@@ -732,15 +916,51 @@ class DefaultContainer {
732
916
  this.#assertNotDisposed();
733
917
  return this.#getInspector().inspect();
734
918
  }
919
+ explain(token, options) {
920
+ this.#assertNotDisposed();
921
+ return explainRequest([this.#registry, ...this.#ancestorRegistries()], token, options);
922
+ }
735
923
  generateDependencyGraph(options) {
736
924
  this.#assertNotDisposed();
737
- return buildDependencyGraph(this.#registry, this.#getMetadataReader(), options, this.#parent === undefined ? undefined : this.#parent.#registry);
925
+ return buildDependencyGraph(this.#registry, this.#getMetadataReader(), options, this.#ancestorRegistries());
926
+ }
927
+ /** The registries above this container, nearest first — the order every chain walk reads them in. */
928
+ #ancestorRegistries() {
929
+ const registries = [];
930
+ for (let ancestor = this.#parent; ancestor !== undefined; ancestor = ancestor.#parent) {
931
+ registries.push(ancestor.#registry);
932
+ }
933
+ return registries;
738
934
  }
739
935
  // ── Internal ───────────────────────────────────────────────────────────────────────────────────────────────────────
936
+ // Two field reads and no epoch on a live root. This and `#assertChainLive` each stay under V8's small-function
937
+ // size, so every entry point inlines both outright; the walk and the throw sit in `#confirmChainLive`.
740
938
  #assertNotDisposed() {
741
- if (this.#disposed) {
939
+ if (this.#disposed || this.#parent !== undefined) {
940
+ this.#assertChainLive();
941
+ }
942
+ }
943
+ // An unchanged dispose epoch means no container anywhere closed since this chain was last confirmed live;
944
+ // every dispose advances it, this container's own included, so a disposed container always reaches the walk.
945
+ #assertChainLive() {
946
+ if (disposeEpochRef.value !== this.#liveGeneration) {
947
+ this.#confirmChainLive();
948
+ }
949
+ }
950
+ #confirmChainLive() {
951
+ if (this.#isChainDisposed()) {
742
952
  throw new DisposedContainerError();
743
953
  }
954
+ this.#liveGeneration = disposeEpochRef.value;
955
+ }
956
+ // Whether this container or any ancestor is disposed — the plain walk, taken only on the cold refresh
957
+ // and by the `isDisposed` getter, so it needs no memo of its own.
958
+ #isChainDisposed() {
959
+ if (this.#disposed) {
960
+ return true;
961
+ }
962
+ const parent = this.#parent;
963
+ return parent !== undefined && parent.#isChainDisposed();
744
964
  }
745
965
  }
746
966
  /** Reports collected hook failures once teardown finished: the one error as itself, several aggregated. */