@codefast/di 0.5.0-canary.7 → 0.5.0-canary.8

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 (100) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +3 -1
  3. package/dist/binding.d.ts +47 -5
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +44 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/constructor-type.d.ts +4 -5
  8. package/dist/constructor-type.d.ts.map +1 -1
  9. package/dist/container/binding-builders.d.ts +32 -11
  10. package/dist/container/binding-builders.d.ts.map +1 -1
  11. package/dist/container/binding-builders.js +140 -191
  12. package/dist/container/binding-builders.js.map +1 -1
  13. package/dist/container/container.d.ts.map +1 -1
  14. package/dist/container/container.js +75 -92
  15. package/dist/container/container.js.map +1 -1
  16. package/dist/decorators/inject.d.ts +2 -4
  17. package/dist/decorators/inject.d.ts.map +1 -1
  18. package/dist/decorators/inject.js.map +1 -1
  19. package/dist/errors.d.ts +14 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +17 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/introspection/inspector.js +1 -1
  28. package/dist/introspection/inspector.js.map +1 -1
  29. package/dist/metadata/metadata-keys.d.ts +3 -6
  30. package/dist/metadata/metadata-keys.d.ts.map +1 -1
  31. package/dist/metadata/metadata-keys.js +3 -6
  32. package/dist/metadata/metadata-keys.js.map +1 -1
  33. package/dist/registry.d.ts +14 -2
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +35 -45
  36. package/dist/registry.js.map +1 -1
  37. package/dist/resolution/activation-need.d.ts +25 -0
  38. package/dist/resolution/activation-need.d.ts.map +1 -0
  39. package/dist/resolution/activation-need.js +64 -0
  40. package/dist/resolution/activation-need.js.map +1 -0
  41. package/dist/resolution/binding-lookup-cache.d.ts +41 -0
  42. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -0
  43. package/dist/resolution/binding-lookup-cache.js +102 -0
  44. package/dist/resolution/binding-lookup-cache.js.map +1 -0
  45. package/dist/resolution/binding-select.js +1 -4
  46. package/dist/resolution/binding-select.js.map +1 -1
  47. package/dist/resolution/class-introspector.d.ts +27 -0
  48. package/dist/resolution/class-introspector.d.ts.map +1 -0
  49. package/dist/resolution/class-introspector.js +60 -0
  50. package/dist/resolution/class-introspector.js.map +1 -0
  51. package/dist/resolution/diagnostics.d.ts +41 -0
  52. package/dist/resolution/diagnostics.d.ts.map +1 -0
  53. package/dist/resolution/diagnostics.js +18 -0
  54. package/dist/resolution/diagnostics.js.map +1 -0
  55. package/dist/resolution/environment.d.ts +22 -1
  56. package/dist/resolution/environment.d.ts.map +1 -1
  57. package/dist/resolution/environment.js +27 -1
  58. package/dist/resolution/environment.js.map +1 -1
  59. package/dist/resolution/instantiation-plan.d.ts +15 -15
  60. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  61. package/dist/resolution/instantiation-plan.js +68 -48
  62. package/dist/resolution/instantiation-plan.js.map +1 -1
  63. package/dist/resolution/lifecycle.d.ts +2 -0
  64. package/dist/resolution/lifecycle.d.ts.map +1 -1
  65. package/dist/resolution/lifecycle.js +16 -11
  66. package/dist/resolution/lifecycle.js.map +1 -1
  67. package/dist/resolution/resolution-path.d.ts +4 -13
  68. package/dist/resolution/resolution-path.d.ts.map +1 -1
  69. package/dist/resolution/resolution-path.js +3 -16
  70. package/dist/resolution/resolution-path.js.map +1 -1
  71. package/dist/resolution/resolver.d.ts +7 -2
  72. package/dist/resolution/resolver.d.ts.map +1 -1
  73. package/dist/resolution/resolver.js +116 -328
  74. package/dist/resolution/resolver.js.map +1 -1
  75. package/dist/resolution/scope.d.ts +7 -15
  76. package/dist/resolution/scope.d.ts.map +1 -1
  77. package/dist/resolution/scope.js +48 -44
  78. package/dist/resolution/scope.js.map +1 -1
  79. package/package.json +7 -97
  80. package/src/binding.ts +104 -5
  81. package/src/constructor-type.ts +4 -5
  82. package/src/container/binding-builders.ts +180 -283
  83. package/src/container/container.ts +90 -103
  84. package/src/decorators/inject.ts +3 -5
  85. package/src/errors.ts +21 -0
  86. package/src/index.ts +1 -0
  87. package/src/introspection/inspector.ts +1 -1
  88. package/src/metadata/metadata-keys.ts +3 -6
  89. package/src/registry.ts +38 -59
  90. package/src/resolution/activation-need.ts +81 -0
  91. package/src/resolution/binding-lookup-cache.ts +132 -0
  92. package/src/resolution/binding-select.ts +1 -4
  93. package/src/resolution/class-introspector.ts +74 -0
  94. package/src/resolution/diagnostics.ts +43 -0
  95. package/src/resolution/environment.ts +31 -1
  96. package/src/resolution/instantiation-plan.ts +113 -61
  97. package/src/resolution/lifecycle.ts +16 -11
  98. package/src/resolution/resolution-path.ts +5 -20
  99. package/src/resolution/resolver.ts +142 -371
  100. package/src/resolution/scope.ts +50 -49
@@ -1,5 +1,5 @@
1
1
  import type { Binding, BindingSlot } from "#/binding";
2
- import type { ConstructorInvocation } from "#/constructor-type";
2
+ import { NO_INSTANCE } from "#/binding";
3
3
  import type { Container } from "#/container/container";
4
4
  import type { InjectionDescriptor } from "#/decorators/inject";
5
5
  import {
@@ -12,17 +12,21 @@ import {
12
12
  NoMatchingBindingError,
13
13
  TokenNotBoundError,
14
14
  } from "#/errors";
15
- import type { ConstructorMetadata, MetadataReader } from "#/metadata/metadata-types";
15
+ import type { MetadataReader } from "#/metadata/metadata-types";
16
16
  import type { BindingRegistry } from "#/registry";
17
+ import { ActivationNeedCache } from "#/resolution/activation-need";
18
+ import type { DefaultLookupEntry } from "#/resolution/binding-lookup-cache";
19
+ import { BindingLookupCache } from "#/resolution/binding-lookup-cache";
17
20
  import { selectAllBindings, selectBinding } from "#/resolution/binding-select";
21
+ import { ClassIntrospector } from "#/resolution/class-introspector";
22
+ import type { ResolutionDiagnostics } from "#/resolution/diagnostics";
18
23
  import type { ResolverCallbacks } from "#/resolution/environment";
19
- import { buildResolutionFrame, DefaultResolutionContext, runWithContainer } from "#/resolution/environment";
24
+ import { buildResolutionFrame, DefaultResolutionContext } from "#/resolution/environment";
20
25
  import { InstantiationPlanCompiler, PLAN_RETRY } from "#/resolution/instantiation-plan";
21
26
  import type { LifecycleManager } from "#/resolution/lifecycle";
22
27
  import { enterResolutionPath, exitResolutionPath } from "#/resolution/resolution-path";
23
28
  import { injectionSlotToResolveOptions } from "#/resolution/resolve-options";
24
29
  import type { ScopeManager } from "#/resolution/scope";
25
- import { SINGLETON_MISS } from "#/resolution/scope";
26
30
  import type { Token } from "#/token";
27
31
  import { tokenName } from "#/token";
28
32
  import type {
@@ -36,16 +40,6 @@ import type {
36
40
  ResolveOptions,
37
41
  } from "#/types";
38
42
 
39
- // Terminal result of the options-less lookup fast lane — alias hops already folded.
40
- interface DefaultLookupEntry {
41
- readonly binding: Binding;
42
- readonly owner: DependencyResolver;
43
- }
44
-
45
- // Fast-lane alias folding gives up past this many hops and defers to the resolve()
46
- // loop, whose Set-based traversal detects genuine cycles exactly (no arbitrary cap).
47
- const ALIAS_HOP_LIMIT = 32;
48
-
49
43
  type BindingWithScope = Binding & { scope: BindingScope };
50
44
  const EMPTY_STRING_LIST: ReadonlyArray<string> = [];
51
45
  const EMPTY_FRAME_LIST: ReadonlyArray<ResolutionFrame> = [];
@@ -60,53 +54,12 @@ const ROOT_CONSTRAINT_CONTEXT = {
60
54
  /**
61
55
  * @since 0.3.16-canary.0
62
56
  */
63
- export class DependencyResolver {
57
+ export class DependencyResolver implements ResolverCallbacks {
64
58
  readonly #syncResolutionContextPool: Array<DefaultResolutionContext> = [];
65
- // Cycle detection for the sync transient-dynamic lane lives on `binding.inFlight` — see the
66
- // field's doc comment in binding.ts. It is an O(1) field read with no hashing, no path scan and
67
- // no side table to allocate or grow, so the lane needs no depth split.
68
- // Shared-context state for the async transient-dynamic lane.
69
- //
70
- // Every level of a SEQUENTIAL async chain shares the same resolutionPath and resolutionStack
71
- // arrays (passed by reference through ctx.resolveAsync), and the context stores references
72
- // rather than snapshots, so one DefaultResolutionContext can serve the whole chain — the arrays
73
- // reflect the current state automatically as levels push and pop.
74
- //
75
- // #asyncChainCtx: the shared context, created on first use and reset at each new root call.
76
- // Inner levels of the same chain reuse it with zero setup.
77
- // #asyncChainCtxPath: identity of the resolutionPath array owning the shared context — same
78
- // reference means an inner level of that chain, a different one means a concurrent chain
79
- // (e.g. Promise.all) which gets its own context instead.
80
- // #asyncChainActiveLevels: active levels of the OWNING chain, so the path pointer is released
81
- // when the last one settles. Concurrent fallback calls are not counted.
82
- //
83
- // The method is NOT declared async: that would allocate an async state machine and an implicit
84
- // promise per level, where a `.then(settle, settle)` side listener costs neither.
85
- #asyncChainCtx: DefaultResolutionContext | undefined;
86
- #asyncChainCtxPath: Array<string> | undefined;
87
- #asyncChainActiveLevels = 0;
88
- // Settle callback shared by every level of the owning async chain: all of them pop the same
89
- // resolutionPath and decrement the same counter, so one closure serves the whole chain instead
90
- // of allocating one per level (the async lane's dominant per-level allocation).
91
- #asyncChainSettle: (() => void) | undefined;
92
- readonly #classHasPostConstruct = new WeakMap<Constructor, boolean>();
93
- readonly #classNeedsActiveContainer = new WeakMap<Constructor, boolean>();
94
- readonly #classConstructorMetadata = new WeakMap<Constructor, ConstructorMetadata | null>();
95
- readonly #activationNeedByBindingId = new Map<BindingIdentifier, boolean>();
96
- #activationCacheVersion = -1;
97
- // Options-less lookup memo across the parent chain: token → terminal {binding, owner}
98
- // (alias hops folded). `null` = token must take the slow lookup path. Invalidated when
99
- // any registry in the chain mutates (monotonic version sum).
100
- readonly #defaultLookupByToken = new Map<Token<unknown> | Constructor, DefaultLookupEntry | null>();
101
- #defaultLookupVersion = -1;
102
- // Name-only lookup memo (token → name → entry) with the same chain-version
103
- // invalidation. `null` = shape needs the full selection path.
104
- readonly #namedLookupByToken = new Map<Token<unknown> | Constructor, Map<string, DefaultLookupEntry | null>>();
105
- #namedLookupVersion = -1;
106
- // Compiled transient-class plans (Dagger-style): a pure-static subgraph (class/constant/
107
- // cached-singleton deps only) compiles once into a nested-constructor closure — cycle
108
- // checking happens at compile time, so execution skips all per-resolve bookkeeping.
109
- // `null` = binding is not plannable under the current versions.
59
+ // Pure allocation pool: a chain's own context is threaded through the call, so nothing here
60
+ // identifies a chain.
61
+ readonly #asyncChainContextPool: Array<DefaultResolutionContext> = [];
62
+ // Compiled plans; `null` marks a binding as unplannable under the current cache versions.
110
63
  readonly #classPlanByBindingId = new Map<BindingIdentifier, (() => unknown) | null>();
111
64
  #classPlanRegistryVersion = -1;
112
65
  #classPlanActivationVersion = -1;
@@ -115,8 +68,10 @@ export class DependencyResolver {
115
68
  readonly #scope: ScopeManager;
116
69
  readonly #lifecycle: LifecycleManager;
117
70
  readonly #metadataReader: MetadataReader;
118
- readonly #container: Container;
119
71
  readonly #parent: DependencyResolver | undefined;
72
+ readonly #lookup: BindingLookupCache<DependencyResolver>;
73
+ readonly #classes: ClassIntrospector;
74
+ readonly #activation: ActivationNeedCache;
120
75
 
121
76
  constructor(
122
77
  registry: BindingRegistry,
@@ -130,8 +85,29 @@ export class DependencyResolver {
130
85
  this.#scope = scope;
131
86
  this.#lifecycle = lifecycle;
132
87
  this.#metadataReader = metadataReader;
133
- this.#container = container;
134
88
  this.#parent = parent;
89
+ this.#lookup = new BindingLookupCache<DependencyResolver>(
90
+ registry,
91
+ this,
92
+ parent === undefined ? undefined : parent.#lookup,
93
+ );
94
+ this.#classes = new ClassIntrospector(metadataReader, container);
95
+ this.#activation = new ActivationNeedCache(lifecycle, this.#classes);
96
+ }
97
+
98
+ /** Structural counts for {@link RESOLUTION_DIAGNOSTICS}; see `resolution/diagnostics.ts`. */
99
+ describeCaches(): Pick<ResolutionDiagnostics, "asyncContextPoolSize" | "compiledPlanCount" | "syncContextPoolSize"> {
100
+ let compiledPlanCount = 0;
101
+ for (const plan of this.#classPlanByBindingId.values()) {
102
+ if (plan !== null) {
103
+ compiledPlanCount += 1;
104
+ }
105
+ }
106
+ return {
107
+ asyncContextPoolSize: this.#asyncChainContextPool.length,
108
+ compiledPlanCount,
109
+ syncContextPoolSize: this.#syncResolutionContextPool.length,
110
+ };
135
111
  }
136
112
 
137
113
  // ── Binding lookup ─────────────────────────────────────────────────────────
@@ -237,7 +213,7 @@ export class DependencyResolver {
237
213
  if (fastBinding !== undefined && fastBinding.kind !== "alias") {
238
214
  return this.#resolveDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack);
239
215
  }
240
- const entry = this.#lookupDefaultEntry(token);
216
+ const entry = this.#lookup.defaultEntry(token);
241
217
  if (entry === null) {
242
218
  return this.resolve(token, undefined, resolutionPath, resolutionStack);
243
219
  }
@@ -284,8 +260,8 @@ export class DependencyResolver {
284
260
  }
285
261
  }
286
262
  } else if (scope === "singleton") {
287
- const cachedSingleton = owner.#scope.peekSingleton(binding.id);
288
- if (cachedSingleton !== SINGLETON_MISS) {
263
+ const cachedSingleton = binding.instance;
264
+ if (cachedSingleton !== NO_INSTANCE) {
289
265
  return cachedSingleton as Value;
290
266
  }
291
267
  if (owner !== this) {
@@ -346,89 +322,8 @@ export class DependencyResolver {
346
322
  }
347
323
  }
348
324
 
349
- #chainRegistryVersion(): number {
350
- let version = this.#registry.version;
351
- for (let resolver = this.#parent; resolver !== undefined; resolver = resolver.#parent) {
352
- version += resolver.#registry.version;
353
- }
354
- return version;
355
- }
356
-
357
- #lookupDefaultEntry(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
358
- const version = this.#chainRegistryVersion();
359
- if (version !== this.#defaultLookupVersion) {
360
- this.#defaultLookupByToken.clear();
361
- this.#defaultLookupVersion = version;
362
- }
363
- let entry = this.#defaultLookupByToken.get(token);
364
- if (entry === undefined) {
365
- entry = this.#computeDefaultEntry(token);
366
- this.#defaultLookupByToken.set(token, entry);
367
- }
368
- return entry;
369
- }
370
-
371
- #computeDefaultEntry(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
372
- let current = token;
373
- for (let hop = 0; hop < ALIAS_HOP_LIMIT; hop += 1) {
374
- const entry = this.#findDefaultEntryInChain(current);
375
- if (entry === null) {
376
- return null;
377
- }
378
- if (entry.binding.kind === "alias") {
379
- current = entry.binding.target;
380
- continue;
381
- }
382
- return entry;
383
- }
384
- return null;
385
- }
386
-
387
- #lookupNamedEntry(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry | null {
388
- const version = this.#chainRegistryVersion();
389
- if (version !== this.#namedLookupVersion) {
390
- this.#namedLookupByToken.clear();
391
- this.#namedLookupVersion = version;
392
- }
393
- // ✓ TS6.0: Map.getOrInsert (ES2025)
394
- const entriesByName = this.#namedLookupByToken.getOrInsert(token, new Map<string, DefaultLookupEntry | null>());
395
- let entry = entriesByName.get(name);
396
- if (entry === undefined) {
397
- entry = this.#findNamedEntryInChain(token, name);
398
- entriesByName.set(name, entry);
399
- }
400
- return entry;
401
- }
402
-
403
- #findNamedEntryInChain(token: Token<unknown> | Constructor, name: string): DefaultLookupEntry | null {
404
- const named = this.#registry.getSimpleNamed(token, name);
405
- if (named !== undefined) {
406
- // Predicates need a live context; aliases carry options through the full path.
407
- if (named.predicate !== undefined || named.kind === "alias") {
408
- return null;
409
- }
410
- return { binding: named, owner: this };
411
- }
412
- if (this.#registry.has(token)) {
413
- return null;
414
- }
415
- return this.#parent === undefined ? null : this.#parent.#findNamedEntryInChain(token, name);
416
- }
417
-
418
- #findDefaultEntryInChain(token: Token<unknown> | Constructor): DefaultLookupEntry | null {
419
- const fast = this.#registry.getFastDefault(token);
420
- if (fast !== undefined) {
421
- return { binding: fast, owner: this };
422
- }
423
- // A level with non-fast bindings (multi-slot / predicate) needs full selection — bail.
424
- if (this.#registry.has(token)) {
425
- return null;
426
- }
427
- return this.#parent === undefined ? null : this.#parent.#findDefaultEntryInChain(token);
428
- }
429
-
430
325
  #getInstantiationPlan(binding: Binding & { kind: "class" | "resolved" }): (() => unknown) | null {
431
- const registryVersion = this.#chainRegistryVersion();
326
+ const registryVersion = this.#lookup.chainVersion();
432
327
  const activationVersion = this.#lifecycle.activationVersion;
433
328
  if (registryVersion !== this.#classPlanRegistryVersion || activationVersion !== this.#classPlanActivationVersion) {
434
329
  this.#classPlanByBindingId.clear();
@@ -451,22 +346,28 @@ export class DependencyResolver {
451
346
  // Compiler behind #getClassPlan — cold path, so the host indirection costs nothing hot.
452
347
  readonly #planCompiler = new InstantiationPlanCompiler({
453
348
  hasActivationHandlers: (token) => this.#lifecycle.hasActivationHandlers(token),
454
- knownPostConstruct: (target) => this.#classHasPostConstruct.get(target),
455
- needsActiveContainer: (target) => {
456
- let needsActiveContainer = this.#classNeedsActiveContainer.get(target);
457
- if (needsActiveContainer === undefined) {
458
- const accessorMetadata = this.#metadataReader.getAccessorMetadata?.(target);
459
- needsActiveContainer = (accessorMetadata?.length ?? 0) > 0;
460
- this.#classNeedsActiveContainer.set(target, needsActiveContainer);
461
- }
462
- return needsActiveContainer;
463
- },
464
- getConstructorMetadata: (target) => this.#getConstructorMetadata(target),
349
+ knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
350
+ needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
351
+ getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
465
352
  lookupDependencyEntry: (token) => {
466
- const entry = this.#lookupDefaultEntry(token);
467
- return entry === null ? null : { binding: entry.binding, ownerScope: entry.owner.#scope };
353
+ const entry = this.#lookup.defaultEntry(token);
354
+ return entry === null ? null : { binding: entry.binding };
355
+ },
356
+ getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
357
+ // Dispatches exactly as #resolveClassDeps does, so an escaped dep is indistinguishable
358
+ // from the same dep on a fully interpreted resolve.
359
+ resolveEscaped: (token, options, arity, resolutionPath, resolutionStack) => {
360
+ if (arity === "all") {
361
+ return this.resolveAll(token, options, resolutionPath, resolutionStack);
362
+ }
363
+ if (arity === "optional") {
364
+ return this.resolveOptional(token, options, resolutionPath, resolutionStack);
365
+ }
366
+ if (options === undefined) {
367
+ return this.resolveFromContext(token, resolutionPath, resolutionStack);
368
+ }
369
+ return this.resolve(token, options, resolutionPath, resolutionStack);
468
370
  },
469
- resolveFallback: (token) => this.resolve(token, undefined, [], []),
470
371
  });
471
372
 
472
373
  resolve<const Value>(
@@ -483,7 +384,7 @@ export class DependencyResolver {
483
384
  options.tag === undefined &&
484
385
  (options.tags === undefined || options.tags.length === 0)
485
386
  ) {
486
- const namedEntry = this.#lookupNamedEntry(token, options.name);
387
+ const namedEntry = this.#lookup.namedEntry(token, options.name);
487
388
  if (namedEntry !== null) {
488
389
  const namedBinding = namedEntry.binding;
489
390
  if (
@@ -495,9 +396,8 @@ export class DependencyResolver {
495
396
  }
496
397
  const namedScope = (namedBinding as BindingWithScope).scope ?? "transient";
497
398
  if (namedScope === "singleton") {
498
- const cachedSingleton = namedEntry.owner.#scope.peekSingleton(namedBinding.id);
499
- if (cachedSingleton !== SINGLETON_MISS) {
500
- return cachedSingleton as Value;
399
+ if (namedBinding.instance !== NO_INSTANCE) {
400
+ return namedBinding.instance as Value;
501
401
  }
502
402
  }
503
403
  // Everything else keeps the full path (context, activation, guards).
@@ -563,10 +463,8 @@ export class DependencyResolver {
563
463
  const scope = (binding as BindingWithScope).scope ?? "transient";
564
464
 
565
465
  // Singleton cache check
566
- if (scope === "singleton") {
567
- if (this.#scope.hasSingleton(binding.id)) {
568
- return this.#scope.getSingleton<Value>(binding.id);
569
- }
466
+ if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
467
+ return binding.instance as Value;
570
468
  }
571
469
 
572
470
  // Scoped cache check
@@ -583,7 +481,7 @@ export class DependencyResolver {
583
481
  const tokenDisplayName = frame.tokenName;
584
482
  const resolutionSet = enterResolutionPath(resolutionPath, tokenDisplayName, false);
585
483
  resolutionStack.push(frame);
586
- const needsActivation = this.#needsActivation(binding);
484
+ const needsActivation = this.#activation.needsActivation(binding);
587
485
  if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
588
486
  const resolutionCtx = this.#acquireSyncResolutionContext(resolutionPath, resolutionStack, options);
589
487
  try {
@@ -611,7 +509,7 @@ export class DependencyResolver {
611
509
 
612
510
  const instance = this.#instantiateSync(binding, resolutionCtx, resolutionPath, resolutionStack);
613
511
 
614
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
512
+ const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
615
513
  const activated = shouldActivate
616
514
  ? this.#lifecycle.runActivationSync(
617
515
  resolutionCtx as DefaultResolutionContext,
@@ -623,7 +521,7 @@ export class DependencyResolver {
623
521
 
624
522
  // Cache by scope
625
523
  if (scope === "singleton") {
626
- this.#scope.setSingleton(binding.id, activated);
524
+ this.#scope.setSingleton(binding, activated);
627
525
  } else if (scope === "scoped") {
628
526
  this.#scope.setScoped(binding.id, activated);
629
527
  }
@@ -662,7 +560,7 @@ export class DependencyResolver {
662
560
 
663
561
  case "class": {
664
562
  const deps = this.#resolveClassDeps(binding.target, resolutionPath, resolutionStack);
665
- const instance = this.#instantiateClass(binding.target, deps);
563
+ const instance = this.#classes.instantiate(binding.target, deps);
666
564
  return instance as Value;
667
565
  }
668
566
 
@@ -688,7 +586,7 @@ export class DependencyResolver {
688
586
  resolutionPath: Array<string>,
689
587
  resolutionStack: Array<ResolutionFrame>,
690
588
  ): Array<unknown> {
691
- const meta = this.#getConstructorMetadata(target);
589
+ const meta = this.#classes.constructorMetadata(target);
692
590
  if (meta === undefined) {
693
591
  if (target.length === 0) {
694
592
  return [];
@@ -828,6 +726,7 @@ export class DependencyResolver {
828
726
  token: Token<Value> | Constructor<Value>,
829
727
  resolutionPath: Array<string>,
830
728
  resolutionStack: Array<ResolutionFrame>,
729
+ callerContext?: DefaultResolutionContext,
831
730
  ): Promise<Value> {
832
731
  // Hot lane: own-registry fast default (async chains resolve sibling dynamic bindings).
833
732
  // Fall back to the chain-versioned memo only on miss or alias.
@@ -844,15 +743,22 @@ export class DependencyResolver {
844
743
  fastBinding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
845
744
  resolutionPath,
846
745
  resolutionStack,
746
+ callerContext,
847
747
  );
848
748
  }
849
- return this.#resolveAsyncDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack);
749
+ return this.#resolveAsyncDefaultEntry<Value>(fastBinding, this, resolutionPath, resolutionStack, callerContext);
850
750
  }
851
- const entry = this.#lookupDefaultEntry(token);
751
+ const entry = this.#lookup.defaultEntry(token);
852
752
  if (entry === null) {
853
753
  return this.resolveAsync(token, undefined, resolutionPath, resolutionStack);
854
754
  }
855
- return this.#resolveAsyncDefaultEntry<Value>(entry.binding, entry.owner, resolutionPath, resolutionStack);
755
+ return this.#resolveAsyncDefaultEntry<Value>(
756
+ entry.binding,
757
+ entry.owner,
758
+ resolutionPath,
759
+ resolutionStack,
760
+ callerContext,
761
+ );
856
762
  }
857
763
 
858
764
  #resolveAsyncDefaultEntry<const Value>(
@@ -860,6 +766,7 @@ export class DependencyResolver {
860
766
  owner: DependencyResolver,
861
767
  resolutionPath: Array<string>,
862
768
  resolutionStack: Array<ResolutionFrame>,
769
+ callerContext?: DefaultResolutionContext,
863
770
  ): Promise<Value> {
864
771
  if (
865
772
  binding.kind === "constant" &&
@@ -879,12 +786,12 @@ export class DependencyResolver {
879
786
  binding as Binding<Value> & { kind: "dynamic" | "dynamic-async" },
880
787
  resolutionPath,
881
788
  resolutionStack,
789
+ callerContext,
882
790
  );
883
791
  }
884
792
  } else if (scope === "singleton") {
885
- const cachedSingleton = owner.#scope.peekSingleton(binding.id);
886
- if (cachedSingleton !== SINGLETON_MISS) {
887
- return Promise.resolve(cachedSingleton as Value);
793
+ if (binding.instance !== NO_INSTANCE) {
794
+ return Promise.resolve(binding.instance as Value);
888
795
  }
889
796
  if (owner !== this) {
890
797
  return owner.#resolveBindingAsync(binding as Binding<Value>, undefined, resolutionPath, resolutionStack);
@@ -918,7 +825,7 @@ export class DependencyResolver {
918
825
 
919
826
  let currentToken: Token<unknown> | Constructor = token;
920
827
  let visitedAliasTokens: Set<Token<unknown> | Constructor> | undefined;
921
- let aliasFollowed: DefaultLookupEntry | undefined = found;
828
+ let aliasFollowed: DefaultLookupEntry<DependencyResolver> | undefined = found;
922
829
  while (aliasFollowed !== undefined && aliasFollowed.binding.kind === "alias") {
923
830
  const target = aliasFollowed.binding.target;
924
831
  visitedAliasTokens ??= new Set([currentToken]);
@@ -969,8 +876,8 @@ export class DependencyResolver {
969
876
 
970
877
  // Singleton cache
971
878
  if (scope === "singleton") {
972
- if (this.#scope.hasSingleton(binding.id)) {
973
- return this.#scope.getSingleton<Value>(binding.id);
879
+ if (binding.instance !== NO_INSTANCE) {
880
+ return binding.instance as Value;
974
881
  }
975
882
  // In-flight dedup
976
883
  const inflight = this.#scope.getInflight(binding.id);
@@ -993,14 +900,9 @@ export class DependencyResolver {
993
900
  const frameName = frame.tokenName;
994
901
  const resolutionSet = enterResolutionPath(resolutionPath, frameName, false);
995
902
  resolutionStack.push(frame);
996
- const needsActivation = this.#needsActivation(binding);
903
+ const needsActivation = this.#activation.needsActivation(binding);
997
904
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
998
- const resolutionCtx = new DefaultResolutionContext(
999
- this as unknown as ResolverCallbacks,
1000
- resolutionPath,
1001
- resolutionStack,
1002
- options,
1003
- );
905
+ const resolutionCtx = new DefaultResolutionContext(this, resolutionPath, resolutionStack, options);
1004
906
  try {
1005
907
  if (binding.kind === "dynamic-async") {
1006
908
  return await binding.factory(resolutionCtx);
@@ -1016,7 +918,7 @@ export class DependencyResolver {
1016
918
 
1017
919
  const needsResolutionContext = needsActivation || this.#requiresResolutionContext(binding);
1018
920
  const resolutionCtx = needsResolutionContext
1019
- ? new DefaultResolutionContext(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, options)
921
+ ? new DefaultResolutionContext(this, resolutionPath, resolutionStack, options)
1020
922
  : undefined;
1021
923
 
1022
924
  try {
@@ -1024,7 +926,7 @@ export class DependencyResolver {
1024
926
  const createSingletonPromise = async (): Promise<Value> => {
1025
927
  const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
1026
928
 
1027
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
929
+ const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
1028
930
  const activated = shouldActivate
1029
931
  ? await this.#lifecycle.runActivation(
1030
932
  resolutionCtx as DefaultResolutionContext,
@@ -1034,7 +936,8 @@ export class DependencyResolver {
1034
936
  )
1035
937
  : instance;
1036
938
 
1037
- this.#scope.setSingleton(binding.id, activated);
939
+ this.#scope.setSingleton(binding, activated);
940
+ binding.instance = activated;
1038
941
  this.#scope.clearInflight(binding.id);
1039
942
  return activated;
1040
943
  };
@@ -1049,7 +952,7 @@ export class DependencyResolver {
1049
952
 
1050
953
  const instance = await this.#instantiateAsync(binding, resolutionCtx, resolutionPath, resolutionStack);
1051
954
 
1052
- const shouldActivate = this.#refreshActivationCacheIfNeeded(binding, needsActivation);
955
+ const shouldActivate = this.#activation.refreshAfterFirstInstantiation(binding, needsActivation);
1053
956
  const activated = shouldActivate
1054
957
  ? await this.#lifecycle.runActivation(
1055
958
  resolutionCtx as DefaultResolutionContext,
@@ -1097,7 +1000,7 @@ export class DependencyResolver {
1097
1000
 
1098
1001
  case "class": {
1099
1002
  const deps = await this.#resolveClassDepsAsync(binding.target, resolutionPath, resolutionStack);
1100
- const instance = this.#instantiateClass(binding.target, deps);
1003
+ const instance = this.#classes.instantiate(binding.target, deps);
1101
1004
  return instance as Value;
1102
1005
  }
1103
1006
 
@@ -1125,7 +1028,7 @@ export class DependencyResolver {
1125
1028
  resolutionPath: Array<string>,
1126
1029
  resolutionStack: Array<ResolutionFrame>,
1127
1030
  ): Promise<Array<unknown>> {
1128
- const meta = this.#getConstructorMetadata(target);
1031
+ const meta = this.#classes.constructorMetadata(target);
1129
1032
  if (meta === undefined) {
1130
1033
  if (target.length === 0) {
1131
1034
  return [];
@@ -1397,30 +1300,6 @@ export class DependencyResolver {
1397
1300
  return tokenName(token);
1398
1301
  }
1399
1302
 
1400
- #getConstructorMetadata(target: Constructor): ConstructorMetadata | undefined {
1401
- const cached = this.#classConstructorMetadata.get(target);
1402
- if (cached !== undefined) {
1403
- return cached === null ? undefined : cached;
1404
- }
1405
- const metadata = this.#metadataReader.getConstructorMetadata(target);
1406
- this.#classConstructorMetadata.set(target, metadata ?? null);
1407
- return metadata;
1408
- }
1409
-
1410
- #instantiateClass(target: Constructor, deps: Array<unknown>): unknown {
1411
- let needsActiveContainer = this.#classNeedsActiveContainer.get(target);
1412
- if (needsActiveContainer === undefined) {
1413
- const accessorMetadata = this.#metadataReader.getAccessorMetadata?.(target);
1414
- needsActiveContainer = (accessorMetadata?.length ?? 0) > 0;
1415
- this.#classNeedsActiveContainer.set(target, needsActiveContainer);
1416
- }
1417
- const invokable = target as ConstructorInvocation;
1418
- if (!needsActiveContainer) {
1419
- return new invokable(...deps);
1420
- }
1421
- return runWithContainer(this.#container, () => new invokable(...deps));
1422
- }
1423
-
1424
1303
  #matchesRequestedTag(
1425
1304
  tagKey: string,
1426
1305
  tagValue: unknown,
@@ -1451,10 +1330,7 @@ export class DependencyResolver {
1451
1330
  resolutionPath: Array<string>,
1452
1331
  resolutionStack: Array<ResolutionFrame>,
1453
1332
  ): Value {
1454
- // One lane at every depth. The separate deep lane existed because cycle detection used to be
1455
- // an O(depth) `resolutionPath.includes()` scan, which had to be escaped past ~32 levels; with
1456
- // the O(1) `binding.inFlight` mark there is nothing to escape, so the depth split — and the
1457
- // divergent behaviour it caused — is gone.
1333
+ // One lane at every depth: `binding.inFlight` is O(1), so there is nothing to escape.
1458
1334
  const frame = this.#getResolutionFrame(binding);
1459
1335
  const tokenDisplayName = frame.tokenName;
1460
1336
  if (binding.inFlight) {
@@ -1477,23 +1353,15 @@ export class DependencyResolver {
1477
1353
  }
1478
1354
  }
1479
1355
 
1480
- // NOT declared `async` — avoids creating a JSAsyncGeneratorObject + implicit Promise wrapper on
1481
- // every invocation. Cleanup is handled via .then(onFulfilled, onRejected) so the behaviour is
1482
- // identical to a try/finally but without the async machinery overhead.
1356
+ // Deliberately not `async`: that would allocate a state machine and a promise per level.
1483
1357
  #resolveTransientDynamicAsyncFromContext<const Value>(
1484
1358
  binding: Binding<Value> & { kind: "dynamic" | "dynamic-async" },
1485
1359
  resolutionPath: Array<string>,
1486
1360
  resolutionStack: Array<ResolutionFrame>,
1361
+ callerContext?: DefaultResolutionContext,
1487
1362
  ): Promise<Value> {
1488
- // One lane at every depth. Cycle detection goes through `enterResolutionPath`, which is
1489
- // path-scoped — the only mechanism that stays correct when chains interleave (Promise.all) —
1490
- // and adapts on its own: a linear scan while the path is short, an attached Set past
1491
- // RESOLUTION_SET_THRESHOLD. That removes the depth split, and with it a silent change of
1492
- // behaviour (context identity, stack frames, promise shape) at the old threshold.
1493
- //
1494
- // For a sequential chain every level shares one resolutionPath/resolutionStack, so a single
1495
- // DefaultResolutionContext serves the whole chain: inner levels of the owning chain allocate
1496
- // nothing. A concurrent chain is detected by path identity and gets its own context.
1363
+ // Path-scoped cycle detection, because async chains interleave — see ARCHITECTURE.md.
1364
+ const pool = this.#asyncChainContextPool;
1497
1365
  const frame = this.#getResolutionFrame(binding);
1498
1366
  const tokenDisplayName = frame.tokenName;
1499
1367
  try {
@@ -1503,40 +1371,13 @@ export class DependencyResolver {
1503
1371
  return Promise.reject(cycleError);
1504
1372
  }
1505
1373
 
1506
- let ctx: DefaultResolutionContext;
1507
- let isOwnerLevel: boolean;
1508
- if (this.#asyncChainCtxPath === resolutionPath) {
1509
- ctx = this.#asyncChainCtx!;
1510
- isOwnerLevel = true;
1511
- } else if (this.#asyncChainCtxPath === undefined) {
1512
- const existing = this.#asyncChainCtx;
1513
- if (existing === undefined) {
1514
- ctx = new DefaultResolutionContext(
1515
- this as unknown as ResolverCallbacks,
1516
- resolutionPath,
1517
- resolutionStack,
1518
- undefined,
1519
- );
1520
- this.#asyncChainCtx = ctx;
1521
- } else {
1522
- existing.reset(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, undefined);
1523
- ctx = existing;
1524
- }
1525
- this.#asyncChainCtxPath = resolutionPath;
1526
- isOwnerLevel = true;
1527
- } else {
1528
- ctx = new DefaultResolutionContext(
1529
- this as unknown as ResolverCallbacks,
1530
- resolutionPath,
1531
- resolutionStack,
1532
- undefined,
1533
- );
1534
- isOwnerLevel = false;
1535
- }
1536
-
1537
- if (isOwnerLevel) {
1538
- this.#asyncChainActiveLevels++;
1539
- }
1374
+ // An inner level reuses the context its caller passed down; only a chain's first level
1375
+ // borrows from the pool. Pooling is load-bearing here — see ARCHITECTURE.md.
1376
+ const ctx =
1377
+ callerContext !== undefined && callerContext.owner === this
1378
+ ? callerContext
1379
+ : this.#acquireAsyncChainContext(resolutionPath, resolutionStack);
1380
+ ctx.chainLevels += 1;
1540
1381
 
1541
1382
  // Invoke the factory synchronously to get its Promise (or a resolved value for "dynamic").
1542
1383
  let factoryPromise: Promise<Value>;
@@ -1551,41 +1392,40 @@ export class DependencyResolver {
1551
1392
  } catch (factoryError) {
1552
1393
  // Synchronous throw from the factory (rare) — clean up immediately.
1553
1394
  exitResolutionPath(resolutionPath);
1554
- if (isOwnerLevel && --this.#asyncChainActiveLevels === 0) {
1555
- this.#asyncChainCtxPath = undefined;
1556
- this.#asyncChainSettle = undefined;
1395
+ ctx.chainLevels -= 1;
1396
+ if (ctx.chainLevels === 0) {
1397
+ pool.push(ctx);
1557
1398
  }
1558
1399
  return Promise.reject(factoryError);
1559
1400
  }
1560
1401
 
1561
- // Cleanup runs as a SIDE listener on the factory promise instead of a derived-promise chain:
1562
- // registered synchronously here, it is FIFO-guaranteed to run before the awaiting caller
1563
- // resumes, so ordering is identical while saving one intermediate promise and one microtask
1564
- // hop per level. Trade-off: the settle handler marks a rejection as handled, so an unawaited
1565
- // failing resolveAsync no longer surfaces as an unhandledRejection — callers are expected to
1566
- // await (or .catch) the returned promise.
1567
- //
1568
- // Every level of the owning chain unwinds identically, so one closure serves them all.
1569
- let settle: () => void;
1570
- if (isOwnerLevel) {
1571
- settle =
1572
- this.#asyncChainSettle ??
1573
- (this.#asyncChainSettle = (): void => {
1574
- exitResolutionPath(resolutionPath);
1575
- if (--this.#asyncChainActiveLevels === 0) {
1576
- this.#asyncChainCtxPath = undefined;
1577
- this.#asyncChainSettle = undefined;
1578
- }
1579
- });
1580
- } else {
1581
- settle = (): void => {
1402
+ // A side listener, not a derived chain: FIFO puts it before the awaiting caller resumes.
1403
+ // It marks a rejection as handled, so callers must await or `.catch` the returned promise.
1404
+ const settle =
1405
+ ctx.chainSettle ??
1406
+ (ctx.chainSettle = (): void => {
1582
1407
  exitResolutionPath(resolutionPath);
1583
- };
1584
- }
1408
+ ctx.chainLevels -= 1;
1409
+ if (ctx.chainLevels === 0) {
1410
+ pool.push(ctx);
1411
+ }
1412
+ });
1585
1413
  factoryPromise.then(settle, settle);
1586
1414
  return factoryPromise;
1587
1415
  }
1588
1416
 
1417
+ #acquireAsyncChainContext(
1418
+ resolutionPath: Array<string>,
1419
+ resolutionStack: Array<ResolutionFrame>,
1420
+ ): DefaultResolutionContext {
1421
+ const pooled = this.#asyncChainContextPool.pop();
1422
+ if (pooled === undefined) {
1423
+ return new DefaultResolutionContext(this, resolutionPath, resolutionStack, undefined);
1424
+ }
1425
+ pooled.reset(this, resolutionPath, resolutionStack, undefined);
1426
+ return pooled;
1427
+ }
1428
+
1589
1429
  #resolveCandidateSync<const Value>(
1590
1430
  binding: Binding<Value>,
1591
1431
  options: ResolveOptions | undefined,
@@ -1603,8 +1443,8 @@ export class DependencyResolver {
1603
1443
  return this.resolve(binding.target, options, resolutionPath, resolutionStack);
1604
1444
  }
1605
1445
  const scope = (binding as BindingWithScope).scope ?? "transient";
1606
- if (scope === "singleton" && this.#scope.hasSingleton(binding.id)) {
1607
- return this.#scope.getSingleton<Value>(binding.id);
1446
+ if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
1447
+ return binding.instance as Value;
1608
1448
  }
1609
1449
  if (scope === "scoped") {
1610
1450
  if (!this.#scope.isChild) {
@@ -1636,8 +1476,8 @@ export class DependencyResolver {
1636
1476
  return this.resolveAsync(binding.target, options, isolatedPath, isolatedStack);
1637
1477
  }
1638
1478
  const scope = (binding as BindingWithScope).scope ?? "transient";
1639
- if (scope === "singleton" && this.#scope.hasSingleton(binding.id)) {
1640
- return Promise.resolve(this.#scope.getSingleton<Value>(binding.id));
1479
+ if (scope === "singleton" && binding.instance !== NO_INSTANCE) {
1480
+ return Promise.resolve(binding.instance as Value);
1641
1481
  }
1642
1482
  if (scope === "scoped") {
1643
1483
  if (!this.#scope.isChild) {
@@ -1664,70 +1504,6 @@ export class DependencyResolver {
1664
1504
  return frame;
1665
1505
  }
1666
1506
 
1667
- #needsActivation<const Value>(binding: Binding<Value>): boolean {
1668
- const lifecycleVersion = this.#lifecycle.activationVersion;
1669
- if (
1670
- lifecycleVersion === 0 &&
1671
- binding.kind !== "class" &&
1672
- binding.kind !== "alias" &&
1673
- binding.onActivation === undefined
1674
- ) {
1675
- return false;
1676
- }
1677
- if (this.#activationCacheVersion !== lifecycleVersion) {
1678
- this.#activationNeedByBindingId.clear();
1679
- this.#activationCacheVersion = lifecycleVersion;
1680
- }
1681
-
1682
- const cached = this.#activationNeedByBindingId.get(binding.id);
1683
- if (cached !== undefined) {
1684
- return cached;
1685
- }
1686
-
1687
- if (binding.kind === "class") {
1688
- let hasActivation = this.#lifecycle.hasActivationHandlers(binding.token) || binding.onActivation !== undefined;
1689
- const cachedPostConstruct = this.#classHasPostConstruct.get(binding.target);
1690
- // Unknown class lifecycle metadata: activate once, then cache after first instantiation.
1691
- if (cachedPostConstruct === undefined) {
1692
- hasActivation = true;
1693
- } else if (cachedPostConstruct) {
1694
- hasActivation = true;
1695
- }
1696
- this.#activationNeedByBindingId.set(binding.id, hasActivation);
1697
- return hasActivation;
1698
- }
1699
-
1700
- let hasActivation = false;
1701
- if (binding.kind !== "alias" && binding.onActivation !== undefined) {
1702
- hasActivation = true;
1703
- } else if (this.#lifecycle.hasActivationHandlers(binding.token)) {
1704
- hasActivation = true;
1705
- }
1706
-
1707
- this.#activationNeedByBindingId.set(binding.id, hasActivation);
1708
- return hasActivation;
1709
- }
1710
-
1711
- #refreshClassPostConstructCache(target: Constructor): void {
1712
- const lifecycle = this.#metadataReader.getLifecycleMetadata(target);
1713
- const hasPostConstruct =
1714
- lifecycle !== undefined && lifecycle.postConstruct !== undefined && lifecycle.postConstruct.length > 0;
1715
- this.#classHasPostConstruct.set(target, hasPostConstruct);
1716
- }
1717
-
1718
- /**
1719
- * Refreshes the post-construct cache for class bindings on first instantiation and
1720
- * returns the (possibly updated) shouldActivate flag.
1721
- */
1722
- #refreshActivationCacheIfNeeded<Value>(binding: Binding<Value>, needsActivation: boolean): boolean {
1723
- if (binding.kind === "class" && this.#classHasPostConstruct.get(binding.target) === undefined) {
1724
- this.#refreshClassPostConstructCache(binding.target);
1725
- this.#activationNeedByBindingId.delete(binding.id);
1726
- return this.#needsActivation(binding);
1727
- }
1728
- return needsActivation;
1729
- }
1730
-
1731
1507
  #requiresResolutionContext<const Value>(binding: Binding<Value>): boolean {
1732
1508
  return binding.kind === "dynamic" || binding.kind === "dynamic-async";
1733
1509
  }
@@ -1740,15 +1516,10 @@ export class DependencyResolver {
1740
1516
  const depth = resolutionStack.length;
1741
1517
  const existing = this.#syncResolutionContextPool[depth];
1742
1518
  if (existing !== undefined) {
1743
- existing.reset(this as unknown as ResolverCallbacks, resolutionPath, resolutionStack, options);
1519
+ existing.reset(this, resolutionPath, resolutionStack, options);
1744
1520
  return existing;
1745
1521
  }
1746
- const created = new DefaultResolutionContext(
1747
- this as unknown as ResolverCallbacks,
1748
- resolutionPath,
1749
- resolutionStack,
1750
- options,
1751
- );
1522
+ const created = new DefaultResolutionContext(this, resolutionPath, resolutionStack, options);
1752
1523
  this.#syncResolutionContextPool[depth] = created;
1753
1524
  return created;
1754
1525
  }