@codefast/di 0.10.0 → 0.11.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 (81) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +71 -7
  3. package/dist/ambient/active-container.d.ts +13 -11
  4. package/dist/ambient/active-container.js +8 -6
  5. package/dist/container/binding-builders.d.ts +25 -7
  6. package/dist/container/binding-builders.js +110 -42
  7. package/dist/container/container.d.ts +8 -8
  8. package/dist/container/container.js +244 -67
  9. package/dist/core/binding-declaration.d.ts +120 -0
  10. package/dist/core/binding-declaration.js +186 -0
  11. package/dist/core/binding-scope.d.ts +2 -2
  12. package/dist/core/binding.d.ts +61 -9
  13. package/dist/core/binding.js +49 -2
  14. package/dist/core/constraint-requirement.d.ts +1 -1
  15. package/dist/core/module.d.ts +10 -7
  16. package/dist/core/module.js +17 -3
  17. package/dist/core/registry.d.ts +17 -13
  18. package/dist/core/registry.js +155 -59
  19. package/dist/core/state-epoch.d.ts +18 -1
  20. package/dist/core/state-epoch.js +17 -0
  21. package/dist/core/tag.js +1 -1
  22. package/dist/core/token.d.ts +1 -1
  23. package/dist/core/types.d.ts +5 -5
  24. package/dist/decorators/decorator-metadata.d.ts +9 -0
  25. package/dist/decorators/decorator-metadata.js +20 -0
  26. package/dist/decorators/inject.d.ts +3 -3
  27. package/dist/decorators/inject.js +7 -6
  28. package/dist/decorators/injectable.d.ts +2 -2
  29. package/dist/decorators/injectable.js +5 -3
  30. package/dist/decorators/lifecycle-decorators.js +9 -3
  31. package/dist/errors/errors.d.ts +85 -14
  32. package/dist/errors/errors.js +100 -20
  33. package/dist/index.d.ts +37 -35
  34. package/dist/index.js +20 -19
  35. package/dist/injection/descriptor.d.ts +3 -3
  36. package/dist/injection/descriptor.js +3 -7
  37. package/dist/injection/resolve-options.d.ts +3 -3
  38. package/dist/injection/resolve-options.js +7 -5
  39. package/dist/introspection/dependency-graph.d.ts +10 -5
  40. package/dist/introspection/dependency-graph.js +51 -28
  41. package/dist/introspection/graph-adapters/cytoscape.d.ts +1 -1
  42. package/dist/introspection/graph-adapters/dot.d.ts +1 -1
  43. package/dist/introspection/graph-adapters/mermaid.d.ts +1 -1
  44. package/dist/introspection/graph-adapters/reactflow.d.ts +1 -1
  45. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  46. package/dist/introspection/inspector.d.ts +4 -4
  47. package/dist/introspection/inspector.js +9 -12
  48. package/dist/lifecycle/lifecycle-manager.d.ts +4 -4
  49. package/dist/lifecycle/lifecycle-manager.js +18 -6
  50. package/dist/lifecycle/scope-manager.d.ts +2 -2
  51. package/dist/lifecycle/scope-manager.js +31 -13
  52. package/dist/metadata/metadata-reader-token.d.ts +2 -2
  53. package/dist/metadata/metadata-reader-token.js +1 -1
  54. package/dist/metadata/metadata-types.d.ts +3 -3
  55. package/dist/metadata/symbol-metadata-reader.d.ts +3 -3
  56. package/dist/metadata/symbol-metadata-reader.js +1 -1
  57. package/dist/metadata/verifying-metadata-reader.d.ts +5 -4
  58. package/dist/metadata/verifying-metadata-reader.js +30 -8
  59. package/dist/resolution/async-fan-out.d.ts +12 -0
  60. package/dist/resolution/async-fan-out.js +26 -0
  61. package/dist/resolution/cache/activation-need.d.ts +4 -5
  62. package/dist/resolution/cache/activation-need.js +11 -18
  63. package/dist/resolution/cache/binding-lookup-cache.d.ts +5 -12
  64. package/dist/resolution/cache/binding-lookup-cache.js +32 -19
  65. package/dist/resolution/cache/class-introspector.d.ts +16 -5
  66. package/dist/resolution/cache/class-introspector.js +73 -56
  67. package/dist/resolution/context.d.ts +17 -25
  68. package/dist/resolution/context.js +47 -56
  69. package/dist/resolution/path/resolution-path.d.ts +48 -13
  70. package/dist/resolution/path/resolution-path.js +90 -39
  71. package/dist/resolution/plan/instantiation-plan.d.ts +5 -5
  72. package/dist/resolution/plan/instantiation-plan.js +66 -26
  73. package/dist/resolution/plan/plan-codegen.d.ts +10 -7
  74. package/dist/resolution/plan/plan-codegen.js +62 -34
  75. package/dist/resolution/resolver.d.ts +16 -17
  76. package/dist/resolution/resolver.js +316 -273
  77. package/dist/resolution/select/binding-select.d.ts +6 -5
  78. package/dist/resolution/select/binding-select.js +5 -4
  79. package/dist/resolution/select/constraints.d.ts +3 -3
  80. package/dist/resolution/select/constraints.js +4 -4
  81. package/package.json +14 -2
@@ -1,23 +1,24 @@
1
- import { BindingChain } from "#/container/binding-builders";
2
- import { NO_INSTANCE } from "#/core/binding";
3
- import { effectiveBindingScope } from "#/core/binding-scope";
4
- import { constraintRequirementsOf } from "#/core/constraint-requirement";
5
- import { getOrInsert, getOrInsertComputed } from "#/core/map-upsert";
6
- import { isSyncModule, MODULE_SETUP } from "#/core/module";
7
- import { BindingRegistry } from "#/core/registry";
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";
12
- import { buildDependencyGraph } from "#/introspection/dependency-graph";
13
- 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";
20
- import { DependencyResolver } from "#/resolution/resolver";
1
+ import { BindingChain } from "#container/binding-builders";
2
+ import { NO_INSTANCE } from "#core/binding";
3
+ import { effectiveBindingScope } from "#core/binding-scope";
4
+ import { constraintRequirementsOf } from "#core/constraint-requirement";
5
+ import { getOrInsert, getOrInsertComputed } from "#core/map-upsert";
6
+ import { isSyncModule, MODULE_SETUP } from "#core/module";
7
+ import { BindingRegistry } from "#core/registry";
8
+ import { advanceDisposeEpoch, disposeEpochRef } from "#core/state-epoch";
9
+ import { tokenName } from "#core/token";
10
+ import { RESOLUTION_DIAGNOSTICS } from "#errors/diagnostics";
11
+ import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, SyncDisposalNotSupportedError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors/errors";
12
+ import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#injection/resolve-options";
13
+ import { buildDependencyGraph } from "#introspection/dependency-graph";
14
+ import { Inspector } from "#introspection/inspector";
15
+ import { LifecycleManager } from "#lifecycle/lifecycle-manager";
16
+ import { ScopeManager } from "#lifecycle/scope-manager";
17
+ import { MetadataReaderToken } from "#metadata/metadata-reader-token";
18
+ import { defaultMetadataReader } from "#metadata/symbol-metadata-reader";
19
+ import { verifyingMetadataReader } from "#metadata/verifying-metadata-reader";
20
+ import { ROOT_BRANCH } from "#resolution/path/resolution-path";
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) {
23
24
  if (requirement.tokenName !== undefined) {
@@ -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,8 +68,13 @@ 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) {
68
79
  this.#parent = parent;
69
80
  this.#registry = new BindingRegistry();
@@ -80,7 +91,7 @@ class DefaultContainer {
80
91
  if (this.#inspector !== undefined) {
81
92
  builtSubsystems.push("container.inspector");
82
93
  }
83
- if (this.#moduleRefs !== undefined || this.#moduleBindingIds !== undefined) {
94
+ if (this.#moduleRefs !== undefined || this.#moduleBindingIds !== undefined || this.#moduleImports !== undefined) {
84
95
  builtSubsystems.push("container.moduleTables");
85
96
  }
86
97
  if (this.#registry.isRecordMapBuilt) {
@@ -103,13 +114,14 @@ class DefaultContainer {
103
114
  }
104
115
  #initResolver(configuredReader) {
105
116
  const parent = this.#parent;
117
+ this.#hasConfiguredReader = configuredReader !== undefined || (parent !== undefined && parent.#hasConfiguredReader);
106
118
  const metadataReader = verifyingMetadataReader(configuredReader ?? (parent === undefined ? defaultMetadataReader : parent.#readerForChild()));
107
119
  const parentResolver = parent === undefined ? undefined : parent.#resolver;
108
120
  this.#resolver = new DependencyResolver(this.#registry, this.#scope, this.#lifecycle, metadataReader, this, parentResolver);
109
121
  }
110
- /** What a container being constructed under this one inherits: a reader bound here, else this one's. */
122
+ /** What a container being constructed under this one inherits: a configured reader, else one bound here, else this one's. */
111
123
  #readerForChild() {
112
- if (this.#registry.has(MetadataReaderToken)) {
124
+ if (!this.#hasConfiguredReader && this.#registry.has(MetadataReaderToken)) {
113
125
  try {
114
126
  return this.#resolver.resolve(MetadataReaderToken, undefined, []);
115
127
  }
@@ -124,7 +136,7 @@ class DefaultContainer {
124
136
  return this.#resolver.metadataReader;
125
137
  }
126
138
  get isDisposed() {
127
- return this.#disposed;
139
+ return this.#isChainDisposed();
128
140
  }
129
141
  // ── Binding ────────────────────────────────────────────────────────────────────────────────────────────────────────
130
142
  bind(token) {
@@ -133,15 +145,40 @@ class DefaultContainer {
133
145
  }
134
146
  /** The registration every non-module chain shares, so `bind()` allocates only the builder. */
135
147
  #ownRegistration() {
136
- return (this.#registration ??= { registry: this.#registry, scope: this.#scope, moduleBindingIds: undefined });
148
+ return (this.#registration ??= {
149
+ registry: this.#registry,
150
+ scope: this.#scope,
151
+ moduleBindingIds: undefined,
152
+ onDisplaced: (binding) => {
153
+ this.#recordDisplaced(binding);
154
+ },
155
+ onRestored: (binding) => {
156
+ this.#orphanedBindings?.delete(binding.identifier);
157
+ },
158
+ });
159
+ }
160
+ // A displaced binding holding a cached instance or owing a constant's deactivation is remembered;
161
+ // a later restore takes it back out, and every other displaced binding is dropped as before.
162
+ #recordDisplaced(binding) {
163
+ if (binding.instance !== NO_INSTANCE || this.#owesConstantDeactivation(binding)) {
164
+ (this.#orphanedBindings ??= new Map()).set(binding.identifier, binding);
165
+ }
137
166
  }
138
- /** One registration per module load, holding that module's id list directly. */
167
+ /**
168
+ * One registration per module load, holding that module's id list directly.
169
+ *
170
+ * @remarks It borrows the container's own displacement callbacks, so a constant a module's bind
171
+ * displaces is parked for dispose exactly as one displaced by `bind()` is.
172
+ */
139
173
  #moduleRegistration(moduleRef) {
140
174
  this.#moduleBindingIds ??= new Map();
175
+ const { onDisplaced, onRestored } = this.#ownRegistration();
141
176
  return {
142
177
  registry: this.#registry,
143
178
  scope: this.#scope,
144
179
  moduleBindingIds: getOrInsert(this.#moduleBindingIds, moduleRef, []),
180
+ onDisplaced,
181
+ onRestored,
145
182
  };
146
183
  }
147
184
  #createBindToBuilder(token, registration = this.#ownRegistration()) {
@@ -154,25 +191,39 @@ class DefaultContainer {
154
191
  /** Remove bindings from registry + scope and collect [binding, instance] pairs for deactivation. */
155
192
  #collectDeactivationPairs(tokenOrId) {
156
193
  if (typeof tokenOrId === "number") {
157
- const binding = this.#registry.removeById(tokenOrId);
194
+ const binding = this.#registry.removeById(tokenOrId) ?? this.#retireNotLive(tokenOrId);
158
195
  return binding === undefined ? NO_DEACTIVATION_PAIRS : this.#drainSingletons([binding]);
159
196
  }
160
197
  // Dropping the whole token in one pass: removing each binding by id instead would re-scan and
161
198
  // re-index the token's binding list once per binding.
162
199
  return this.#drainSingletons(this.#registry.removeByToken(tokenOrId));
163
200
  }
201
+ /**
202
+ * Retires an id the registry no longer holds, returning the displaced binding still owed a teardown.
203
+ *
204
+ * @remarks Counts as a registry write, so no chain holding the binding parked can restore it.
205
+ */
206
+ #retireNotLive(id) {
207
+ this.#registry.touch();
208
+ const orphaned = this.#orphanedBindings?.get(id);
209
+ if (orphaned !== undefined) {
210
+ this.#orphanedBindings.delete(id);
211
+ }
212
+ return orphaned;
213
+ }
164
214
  /** Drain scope entries for already-removed bindings, and pair each one that still owes a deactivation. */
165
215
  #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.
216
+ // One flat list, allocated by the first pair owed: an unbind or rebind of a binding nothing
217
+ // ever cached — the hot-swap shape — owes no deactivation and hands the shared empty list back.
168
218
  let pairs;
169
- for (const binding of bindings) {
219
+ for (let index = 0; index < bindings.length; index += 1) {
220
+ const binding = bindings[index];
170
221
  if (binding.instance !== NO_INSTANCE) {
171
- (pairs ??= []).push([binding, binding.instance]);
222
+ (pairs ??= []).push(binding, binding.instance);
172
223
  this.#scope.deleteSingleton(binding);
173
224
  }
174
225
  else if (this.#owesConstantDeactivation(binding)) {
175
- (pairs ??= []).push([binding, binding.value]);
226
+ (pairs ??= []).push(binding, binding.value);
176
227
  }
177
228
  this.#scope.deleteScoped(binding.identifier);
178
229
  }
@@ -195,32 +246,36 @@ class DefaultContainer {
195
246
  return;
196
247
  }
197
248
  const reader = this.#getMetadataReader();
198
- const errors = [];
199
- for (const [binding, instance] of pairs) {
249
+ let errors;
250
+ for (let index = 0; index < pairs.length; index += 2) {
200
251
  try {
201
- this.#lifecycle.runDeactivationSync(binding, instance, reader);
252
+ this.#lifecycle.runDeactivationSync(pairs[index], pairs[index + 1], reader);
202
253
  }
203
254
  catch (error) {
204
- errors.push(error);
255
+ (errors ??= []).push(error);
205
256
  }
206
257
  }
207
- throwCollected(errors, "unbind completed, but deactivation hooks threw");
258
+ if (errors !== undefined) {
259
+ throwCollected(errors, "unbind completed, but deactivation hooks threw");
260
+ }
208
261
  }
209
262
  async #deactivatePairs(pairs) {
210
263
  if (pairs.length === 0) {
211
264
  return;
212
265
  }
213
266
  const reader = this.#getMetadataReader();
214
- const errors = [];
215
- for (const [binding, instance] of pairs) {
267
+ let errors;
268
+ for (let index = 0; index < pairs.length; index += 2) {
216
269
  try {
217
- await this.#lifecycle.runDeactivation(binding, instance, reader);
270
+ await this.#lifecycle.runDeactivation(pairs[index], pairs[index + 1], reader);
218
271
  }
219
272
  catch (error) {
220
- errors.push(error);
273
+ (errors ??= []).push(error);
221
274
  }
222
275
  }
223
- throwCollected(errors, "unbind completed, but deactivation hooks threw");
276
+ if (errors !== undefined) {
277
+ throwCollected(errors, "unbind completed, but deactivation hooks threw");
278
+ }
224
279
  }
225
280
  #unbindSync(tokenOrId) {
226
281
  this.#deactivatePairsSync(this.#collectDeactivationPairs(tokenOrId));
@@ -232,19 +287,46 @@ class DefaultContainer {
232
287
  unbindAll() {
233
288
  this.#assertNotDisposed();
234
289
  this.#deactivatePairsSync(this.#drainSingletons(this.#registry.clear()));
290
+ this.#resetModuleTables();
235
291
  }
236
292
  async unbindAllAsync() {
237
293
  this.#assertNotDisposed();
238
294
  await this.#deactivatePairs(this.#drainSingletons(this.#registry.clear()));
295
+ this.#resetModuleTables();
296
+ }
297
+ // Clearing the registry also clears module bookkeeping, so a later `load()` of the same module is
298
+ // not silently skipped as already-loaded. Run after deactivation so the module's hooks still fire.
299
+ #resetModuleTables() {
300
+ this.#moduleRefs = undefined;
301
+ this.#moduleBindingIds = undefined;
302
+ this.#moduleImports = undefined;
239
303
  }
240
304
  rebind(token) {
241
305
  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);
306
+ // A lone default binding that owes no deactivation is replaced by the new chain's own
307
+ // registration, so the swap unbinds nothing up front. Anything else is unbound first, so an
308
+ // async deactivation throws here — before a replacement exists — exactly as `unbind()` does.
309
+ const lone = this.#registry.getFastDefault(token);
310
+ if (lone === undefined || lone.instance !== NO_INSTANCE || this.#owesConstantDeactivation(lone)) {
311
+ if (!this.#registry.has(token)) {
312
+ throw new RebindUnboundTokenError(tokenName(token));
313
+ }
314
+ this.#unbindSync(token);
315
+ return this.#createBindToBuilder(token);
316
+ }
317
+ return new BindingChain(token, this.#rebindRegistration());
318
+ }
319
+ #displacingRegistration;
320
+ /** A registration whose displaced binding is deactivated instead of parked, for `rebind`. */
321
+ #rebindRegistration() {
322
+ return (this.#displacingRegistration ??= {
323
+ registry: this.#registry,
324
+ scope: this.#scope,
325
+ moduleBindingIds: undefined,
326
+ deactivateDisplaced: (binding) => {
327
+ this.#deactivatePairsSync(this.#drainSingletons([binding]));
328
+ },
329
+ });
248
330
  }
249
331
  // ── Module ─────────────────────────────────────────────────────────────────────────────────────────────────────────
250
332
  load(...modules) {
@@ -266,14 +348,23 @@ class DefaultContainer {
266
348
  continue;
267
349
  }
268
350
  moduleRefs.set(moduleRef, 1);
269
- const builder = this.#createModuleBuilder(moduleRef);
270
- try {
271
- module[MODULE_SETUP](builder);
351
+ this.#applySyncModule(module, moduleRef);
352
+ }
353
+ }
354
+ /** Runs a sync module's setup, or files its declarations, rolling the load back if either throws. */
355
+ #applySyncModule(module, moduleRef) {
356
+ const setup = module[MODULE_SETUP];
357
+ try {
358
+ if (typeof setup === "function") {
359
+ setup(this.#createModuleBuilder(moduleRef));
272
360
  }
273
- catch (error) {
274
- throw this.#rollbackFailedLoadSync(moduleRef, error);
361
+ else {
362
+ BindingChain.registerDeclared(setup, this.#moduleRegistration(moduleRef));
275
363
  }
276
364
  }
365
+ catch (error) {
366
+ throw this.#rollbackFailedLoadSync(moduleRef, error);
367
+ }
277
368
  }
278
369
  async loadAsync(...modules) {
279
370
  this.#assertNotDisposed();
@@ -313,13 +404,7 @@ class DefaultContainer {
313
404
  }
314
405
  moduleRefs.set(moduleRef, 1);
315
406
  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
- }
407
+ this.#applySyncModule(module, moduleRef);
323
408
  }
324
409
  else {
325
410
  const importPromises = [];
@@ -344,6 +429,7 @@ class DefaultContainer {
344
429
  return {
345
430
  bind: (token) => this.#createBindToBuilder(token, registration),
346
431
  import: (...modules) => {
432
+ this.#recordModuleImports(moduleRef, modules);
347
433
  this.#loadSyncModules(modules);
348
434
  },
349
435
  };
@@ -353,12 +439,21 @@ class DefaultContainer {
353
439
  return {
354
440
  bind: (token) => this.#createBindToBuilder(token, registration),
355
441
  import: (...modules) => {
442
+ this.#recordModuleImports(moduleRef, modules);
356
443
  for (const module of modules) {
357
444
  importPromises.push(this.#loadOneModuleAsync(module));
358
445
  }
359
446
  },
360
447
  };
361
448
  }
449
+ // Records that `moduleRef` imported each module, so unloading it later releases the reference the
450
+ // import took — otherwise an imported module's ref-count only ever climbs.
451
+ #recordModuleImports(moduleRef, modules) {
452
+ const list = getOrInsert((this.#moduleImports ??= new Map()), moduleRef, []);
453
+ for (const module of modules) {
454
+ list.push(module);
455
+ }
456
+ }
362
457
  unload(...modules) {
363
458
  this.#assertNotDisposed();
364
459
  for (const module of modules) {
@@ -368,11 +463,12 @@ class DefaultContainer {
368
463
  /** Unregister module bindings and collect [binding, instance] pairs for deactivation. */
369
464
  #removeModuleBindings(ref) {
370
465
  this.#moduleRefs?.delete(ref);
466
+ this.#moduleImports?.delete(ref);
371
467
  const ids = this.#moduleBindingIds?.get(ref) ?? [];
372
468
  this.#moduleBindingIds?.delete(ref);
373
469
  const removed = [];
374
470
  for (const id of ids) {
375
- const binding = this.#registry.removeById(id);
471
+ const binding = this.#registry.removeById(id) ?? this.#retireNotLive(id);
376
472
  if (binding !== undefined) {
377
473
  removed.push(binding);
378
474
  }
@@ -382,7 +478,15 @@ class DefaultContainer {
382
478
  #unloadModuleSync(ref) {
383
479
  const count = this.#moduleRefs?.get(ref) ?? 0;
384
480
  if (count <= 1) {
481
+ // Read the imports before removal clears them, then release each — a child only unbinds once
482
+ // its own ref-count reaches zero, mirroring the increment `import` took on load.
483
+ const imported = this.#moduleImports?.get(ref);
385
484
  this.#deactivatePairsSync(this.#removeModuleBindings(ref));
485
+ if (imported !== undefined) {
486
+ for (const child of imported) {
487
+ this.#unloadModuleSync(child);
488
+ }
489
+ }
386
490
  }
387
491
  else {
388
492
  this.#moduleRefs.set(ref, count - 1);
@@ -397,7 +501,13 @@ class DefaultContainer {
397
501
  async #unloadModuleAsync(ref) {
398
502
  const count = this.#moduleRefs?.get(ref) ?? 0;
399
503
  if (count <= 1) {
504
+ const imported = this.#moduleImports?.get(ref);
400
505
  await this.#deactivatePairs(this.#removeModuleBindings(ref));
506
+ if (imported !== undefined) {
507
+ for (const child of imported) {
508
+ await this.#unloadModuleAsync(child);
509
+ }
510
+ }
401
511
  }
402
512
  else {
403
513
  this.#moduleRefs.set(ref, count - 1);
@@ -485,6 +595,8 @@ class DefaultContainer {
485
595
  this.#disposed = true;
486
596
  // Refuses new materializations immediately; in-flight ones are drained just below.
487
597
  this.#scope.markClosed();
598
+ // Descendants read this to learn their chain closed — a resolve through a disposed ancestor is refused.
599
+ advanceDisposeEpoch();
488
600
  await this.#scope.settleInflight();
489
601
  const reader = this.#getMetadataReader();
490
602
  const errors = [];
@@ -515,6 +627,20 @@ class DefaultContainer {
515
627
  }
516
628
  }
517
629
  }
630
+ // Constants a plain last-wins bind() displaced left the registry with their hook still owed; one
631
+ // whose activation cached an instance was drained above, so only the untouched ones remain.
632
+ if (this.#orphanedBindings !== undefined) {
633
+ for (const binding of this.#orphanedBindings.values()) {
634
+ if (binding.instance === NO_INSTANCE && this.#owesConstantDeactivation(binding)) {
635
+ try {
636
+ await this.#lifecycle.runDeactivation(binding, binding.value, reader);
637
+ }
638
+ catch (error) {
639
+ errors.push(error);
640
+ }
641
+ }
642
+ }
643
+ }
518
644
  this.#scope.clearAll();
519
645
  throwCollected(errors, "dispose() completed, but deactivation hooks threw");
520
646
  }
@@ -566,6 +692,11 @@ class DefaultContainer {
566
692
  if (!this.#isBoundInChain(hookToken)) {
567
693
  throw new UnreachableLifecycleHookError(tokenName(hookToken), phase);
568
694
  }
695
+ // A deactivation only ever runs for a singleton or constant, so a hook on a token whose every
696
+ // binding is scoped or transient can never fire — the builder blocks it, `container.onDeactivation` cannot.
697
+ if (phase === "onDeactivation" && !this.#hasDeactivatableBindingInChain(hookToken)) {
698
+ throw new UnreachableLifecycleHookError(tokenName(hookToken), phase, "no-deactivatable-binding");
699
+ }
569
700
  }
570
701
  this.#validateConstraintRequirements(allBindings);
571
702
  }
@@ -608,6 +739,16 @@ class DefaultContainer {
608
739
  const parent = this.#parent;
609
740
  return parent !== undefined && parent.#isBoundInChain(token);
610
741
  }
742
+ // Whether the token has a binding a deactivation hook can run for — a singleton or a constant.
743
+ #hasDeactivatableBindingInChain(token) {
744
+ for (const binding of this.#registry.getAll(token)) {
745
+ if (effectiveBindingScope(binding) === "singleton") {
746
+ return true;
747
+ }
748
+ }
749
+ const parent = this.#parent;
750
+ return parent !== undefined && parent.#hasDeactivatableBindingInChain(token);
751
+ }
611
752
  #isSingletonStaticAnalyzableBinding(binding) {
612
753
  if (effectiveBindingScope(binding) !== "singleton") {
613
754
  return false;
@@ -701,10 +842,7 @@ class DefaultContainer {
701
842
  #collectStaticDependencyEdges(binding, reader) {
702
843
  const edges = [];
703
844
  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
- }
845
+ // An optional dependency that is absent peeks no candidate; one that is bound is captured like any other.
708
846
  const depOptions = injectionSlotToResolveOptions(dep);
709
847
  for (const candidate of this.#peekDependencyCandidates(dep, depOptions)) {
710
848
  const terminal = this.#followAliasChainToTerminal(candidate, depOptions);
@@ -718,7 +856,16 @@ class DefaultContainer {
718
856
  // ── Introspection ──────────────────────────────────────────────────────────────────────────────────────────────────
719
857
  has(token, options) {
720
858
  this.#assertNotDisposed();
721
- return this.#getInspector().hasOwn(token, options) || (this.#parent?.has(token, options) ?? false);
859
+ return this.#hasInChain(token, options);
860
+ }
861
+ // Recurses through the parent's registry, not its public `has`, so a live child answers even when
862
+ // an ancestor is disposed — the disposed-guard belongs on the entry point, not every chain hop.
863
+ #hasInChain(token, options) {
864
+ if (this.#getInspector().hasOwn(token, options)) {
865
+ return true;
866
+ }
867
+ const parent = this.#parent;
868
+ return parent !== undefined && parent.#hasInChain(token, options);
722
869
  }
723
870
  hasOwn(token, options) {
724
871
  this.#assertNotDisposed();
@@ -734,13 +881,43 @@ class DefaultContainer {
734
881
  }
735
882
  generateDependencyGraph(options) {
736
883
  this.#assertNotDisposed();
737
- return buildDependencyGraph(this.#registry, this.#getMetadataReader(), options, this.#parent === undefined ? undefined : this.#parent.#registry);
884
+ const ancestorRegistries = [];
885
+ for (let ancestor = this.#parent; ancestor !== undefined; ancestor = ancestor.#parent) {
886
+ ancestorRegistries.push(ancestor.#registry);
887
+ }
888
+ return buildDependencyGraph(this.#registry, this.#getMetadataReader(), options, ancestorRegistries);
738
889
  }
739
890
  // ── Internal ───────────────────────────────────────────────────────────────────────────────────────────────────────
891
+ // Hot guard, one call-free epoch read and one compare for every container, root or child. Every
892
+ // dispose anywhere — self-dispose included, since `#runDispose` bumps the epoch — moves the epoch,
893
+ // so an epoch unchanged since we last confirmed the chain live means it is still live. A moved epoch
894
+ // drops to the cold refresh, which walks self and ancestors and either re-confirms or throws.
895
+ // Hot guard. A root's whole answer is its own `#disposed`, kept the one field read it always was —
896
+ // the epoch is never read on the root path. A child adds a call-free epoch compare: an unchanged
897
+ // epoch means no container anywhere has been disposed since we last confirmed the chain live, so the
898
+ // ancestors need no walk; a moved epoch drops to the cold refresh that walks them and re-confirms or throws.
740
899
  #assertNotDisposed() {
741
900
  if (this.#disposed) {
742
901
  throw new DisposedContainerError();
743
902
  }
903
+ if (this.#parent !== undefined && disposeEpochRef.value !== this.#liveGeneration) {
904
+ this.#refreshAncestors();
905
+ }
906
+ }
907
+ #refreshAncestors() {
908
+ if (this.#parent.#isChainDisposed()) {
909
+ throw new DisposedContainerError();
910
+ }
911
+ this.#liveGeneration = disposeEpochRef.value;
912
+ }
913
+ // Whether this container or any ancestor is disposed — the plain walk, taken only on the cold refresh
914
+ // and by the `isDisposed` getter, so it needs no memo of its own.
915
+ #isChainDisposed() {
916
+ if (this.#disposed) {
917
+ return true;
918
+ }
919
+ const parent = this.#parent;
920
+ return parent !== undefined && parent.#isChainDisposed();
744
921
  }
745
922
  }
746
923
  /** Reports collected hook failures once teardown finished: the one error as itself, several aggregated. */