Skip to content
codefastlabs

Command Palette

Search for a command to run...

v0.14.0· Changelog

@codefast/di

0.14.0

Minor Changes

  • #1101 Breaking: an auto-register registry is now a SyncModule, and Container#loadAutoRegistered is removed. createAutoRegisterRegistry(name) takes the module's display name, and the registry loads like any other module — deduplicated by identity, ref-counted, and torn down by unload() / unloadAsync(), which deactivates every singleton it bound. loadAutoRegistered was a bare bind loop: it could not be unloaded, and a second call rebound the whole set over the first.

    Migrate:

    // before
    const registry = createAutoRegisterRegistry();
    const count = container.loadAutoRegistered(registry);
    
    // after
    const registry = createAutoRegisterRegistry("app:Services");
    container.load(registry); // or Container.fromModules(registry)
    const count = registry.entries().length; // if you used the returned count

    A load binds the classes registered by then; a second load() of the same registry only adds a reference, so a class decorated after the first load is bound by unloading the registry to zero and loading it again.

  • #1119 Breaking (types only): three places the types let through what the library then did wrong or rejected late.

    • @injectable() with no deps now compiles only on a class whose constructor needs nothing. It used to accept a class with required parameters — a subclass inheriting its base's constructor included — and build it with those parameters left undefined, silently. List the deps, as @injectable([...]).
    • A ModuleBuilder and an AsyncModuleBuilder now narrow slot names as a container does: whenNamed inside Module.create took any string. A container and both builders bind through one new Binder interface, so the three can no longer drift apart.
    • toSelf() exists only on a chain whose key is a class (ClassBindToBuilder); on a token it was accepted by the compiler and threw SelfBindingRequiresClassError at runtime. An untyped caller still gets that error.

    Binder and ClassBindToBuilder are exported from the package root. Runtime behaviour is unchanged.

  • #1101 Breaking: Container#dispose() is renamed disposeAsync(). It returns a Promise, so the bare name broke the package's own rule — unqualified is sync, the Async suffix is async — and the spec carried an exception for it that is now gone. disposeAsync is the name TC39's AsyncDisposableStack gives the same method, and [Symbol.asyncDispose]() still delegates to it, so await using is unchanged. Replace await container.dispose() with await container.disposeAsync(); an AggregateError from a teardown with several throwing hooks now reads "disposeAsync() completed, but deactivation hooks threw".

  • #1094 Breaking: the ContainerInterface type export is removed. It aliased the Container interface, which Container already exports under its own name beside the value, so replace ContainerInterface with Container.

  • #1063 Breaking: validate, inspect, lookupBindings, explain and generateDependencyGraph are functions that take the container instead of Container methods, so a bundle that only binds and resolves leaves them out: importing Container and token alone drops from 23.2 KB to about 20.6 KB gzipped. has and hasOwn stay methods. Replace container.validate() with validate(container), container.explain(token, options) with explain(container, token, options), and likewise for the other three, importing each from @codefast/di. They refuse a disposed container with DisposedContainerError as the methods did, and an object Container did not create with a TypeError.

  • #1120 Breaking: two spellings of the same API collapse into one.

    • Module is the one module factory: Module.create, Module.createAsync and Module.fromBindings. The SyncModule.create, SyncModule.fromBindings and AsyncModule.create companions are removed; SyncModule and AsyncModule remain as the types those factories return. Replace SyncModule.create( with Module.create(, SyncModule.fromBindings( with Module.fromBindings( and AsyncModule.create( with Module.createAsync(.
    • optional() is renamed injectOptional(), so the descriptor family reads as the resolve family does — inject/injectOptional/injectAll beside resolve/resolveOptional/resolveAll. Replace optional( with injectOptional(.
  • #1111 Breaking: a root-level resolveAll / resolveAllAsync with no options now hands every caller the same memoized list while the chain and its activation hooks are unchanged, instead of a fresh copy per read. The result was already typed ReadonlyArray; it is now read-only in fact, and a caller that needs a list of its own copies it ([...list]). Dropping the copy reads resolve-all-strategies-10 +22%, resolve-all-strategies-100 +67% and production-event-bus-dispatch +32% in a paired run, with the cold collection rows and the canaries level.

  • #1068 Lower the Node.js floor from 24 to the lowest release the package, its dependencies and its full test suite run on: 22.12 for @codefast/cli and @codefast/tailwind-variants, 22.18 for @codefast/di and @codefast/di-testing, and 22.22.2 for @codefast/theme, @codefast/tracking and @codefast/ui. Each floor is type-checked against what Node 22 ships and proven by running the package's tests on that release in CI.

Patch Changes

  • #1110 An @inject accessor now compiles into its class's instantiation plan, like a constructor parameter: the plan resolves it before new and the accessor's init takes the value, instead of each accessor resolving again through the container as the instance initializes. An accessor-injected class may now sit anywhere in a plan, not only at its root. @inject returns an init rather than adding an initializer that writes through context.access.set, so the value is the field's initial value. A standalone probe built with tsc puts one accessor at 46 ns → 31 ns per resolve and two at 59 ns → 41 ns; a paired run reads accessor-injection-construct +26%, with the canaries level. A custom MetadataReader's accessor records are not prepared, so such a class keeps resolving its accessors itself.

  • #1113 An @inject-accessor class now compiles into an async plan too, so resolveAsync stops interpreting it on every call. The plan keeps the interpreted async lane's order: the constructor params settle first, then the accessors resolve on the sync lane and the class constructs. A standalone probe puts resolveAsync of a one-accessor class at 120 ns → 78 ns, against 66 ns for the same class taking its dependency as a constructor param.

  • #1091 The ResolutionContext a factory or activation hook receives narrows name to the slot names its token declares, as Container#resolve already did: inside toDynamic((ctx) => ctx.resolve(LoggerToken, { name: "fiel" })), a name the token does not declare is now a compile error instead of a NoMatchingBindingError at resolve time. A token that declares no names, and a class key, still take any string.

  • #1100 The README writes validate, inspect and the other introspection functions as the free functions they are — validate(container) rather than a method-style validate() — and notes that guarding validate(container) with a dev-only check keeps the introspection code out of a production bundle.

  • #1121 Three code samples still imported a name the previous release removed or renamed: the README's decorator sample and the decorators spec imported optional while calling injectOptional, and the modules spec imported SyncModule while calling Module.create. They import what they call now.

  • #1116 The foundation-types spec now documents the helpers a custom when() constraint reads a frame's slot.keyMask with — tagKeyMaskOf, coversTagKeys and NO_TAG_KEYS, the ones the built-in tag constraints use — with an example that rules a parent out by its tag keys before comparing any value.

  • #1115 DiError.code is now typed as DiErrorCode, a union of every code the library throws, exported from the package root, so a switch (error.code) is checked by the compiler and a misspelled code no longer compiles. An AsyncActivationError raised by a @postConstruct hook now names the token it was building — @postConstruct method 'init' of 'app:Cache' returned a Promise — where it named only the method.

  • #1065 inspect and lookupBindings read the container's inspector directly instead of building an internals object and the ancestor-registry list on every call, so they cost what the methods did before they became functions. A paired run against the method-shaped build reads lookup-bindings at −2% and inspect-snapshot at −4%, from −13% and −9%.

  • #1117 The README's toolchain table lists Bun 1.4.3 or later as needing nothing: its transpiler lowers standard decorators and supplies their metadata without Symbol.metadata. Bun 1.4.2 and earlier still inject the wrong values into a module holding two decorated classes; 1.4.3 fixes that, checked with two accessor-injected and two constructor-injected classes in one module, run directly and after bun build.

  • Move three cold paths out of the resolver to the modules they belong to: a class's constructor-parameter settle (and its MissingMetadataError) to the class introspector, a root collection's stable-value check beside its memo in resolution/cache/lookup, and validate's miss check to introspection/validation, which reads the chain through the new DependencyResolver.bindingsForValidate() in place of missForValidate(). ARCHITECTURE.md replaces the resolver's line budget with the rule for what resolution/resolver.ts holds.

  • #1067 Every dependency slot the engine resolves now has one shape: a class's parameters are copied once when they are settled, and a factory's dependencies when it is bound or declared, each with its resolve options and criterion already filled. A process that has resolved many kinds of slot no longer meets the cold class lane megamorphic, so booting an application gets faster where it was slowest — app-boot-40 +13%, realistic-graph-class-cold-resolve +16% and the interpreted slot-injected-* rows +31% in a paired run — with the warm rows level.

  • #1093 The six resolve methods are declared once, on a new exported Resolver interface that both Container and ResolutionContext extend, so a call through a factory's ctx can never again narrow a token's slot names differently from the same call on the container. Types only: the emitted JavaScript is unchanged.

  • #1101 A sync unbind(), unbindAll(), rebind() or unload() that meets an async deactivation hook now runs every deactivation stage of that instance — container onDeactivation hooks, the binding's own onDeactivation, then each @preDestroy() — before it throws AsyncDeactivationError. It used to stop at the first stage that returned a promise, and since the binding was already removed, the stages after it could never run. The error is still thrown, and each returned promise is still left unawaited with its rejection swallowed, so use unbindAsync() / unloadAsync() wherever a deactivation hook may be async.

  • #1109 validate, explain and generateDependencyGraph read a container's internals from an object built once per container instead of rebuilding it, with the ancestor-registry list, on every call. validate also walks the container's bindings once rather than twice and allocates nothing for a container with no container-level hooks, so it costs less than the container.validate() method did in 0.13.0.

0.13.0

Minor Changes

  • #1022 Breaking: Container no longer declares [Symbol.dispose], and SyncDisposalNotSupportedError is removed. A deactivation hook may be async, so a container disposes through await using or dispose() only, and a synchronous using on one is now a compile error instead of a SyncDisposalNotSupportedError at runtime. Replace using with await using, and drop any instanceof SyncDisposalNotSupportedError check.

  • #1024 Breaking: @postConstruct() and @preDestroy() now reject a private (#name) method, as they already rejected a static or symbol-keyed one. The lifecycle manager calls a hook by name on the instance, so a private hook passed validate() and then failed at resolve with an InvalidMetadataError that blamed the metadata reader, or at dispose() for @preDestroy(). Decorating one now throws the new PrivateLifecycleMethodError when the class is defined. The decorators' types also accept only a public, instance, string-named method, and @inject only an instance accessor, so TypeScript reports all of these at the decoration site (TS1240, TS1241). To run private code from a hook, call it from a public one.

  • #1022 TokenNotBoundError and NoMatchingBindingError now carry path: the token names from the outermost request down to the one that missed, alias hops included. A miss below the token asked for ends its message with that path — Path: UserService → UserRepository → Database → app:Logger — so a deep graph says which consumer needed the binding; a miss on the token asked for keeps its message as it was. Every lane reports the same path: sync and async, generated plans, factory bodies, and explain() for an ancestor that selects nothing.

  • #1022 Breaking: a scoped instance is now deactivated with the child container that cached it. Disposing that child runs the binding's onDeactivation, the container-level onDeactivation hooks of the container that owns the binding, and @preDestroy() for every scoped instance it cached, latest first and before its own singletons; unbinding a scoped binding on the child that cached it deactivates that instance too. .scoped() now offers onDeactivation, and Module.fromBindings accepts onDeactivation with scope: "scoped", so per-request resources — a transaction, a connection — can be released where they are declared. validate() no longer reports a deactivation hook on a scoped-only token as unreachable. Code that relied on a scoped instance never being torn down, or that closed one by hand after dispose(), should drop the manual close.

  • #1022 Breaking: validate() now throws the error resolve() would for a singleton graph that cannot resolve — a required dependency nothing binds (TokenNotBoundError), a slot nothing matches (NoMatchingBindingError), or a cycle (CircularDependencyError) — with the path from that singleton down to the problem. A parent-owned singleton reached from a child is now read from the parent's chain, as resolve reads it. It still leaves alone what it cannot decide: a transient or scoped consumer, whose dependencies a child container may bind, and a request only a when() candidate matches. A container that passed validate() while holding such a graph now fails it; bind the missing dependency, or mark it optional() when it may be absent.

0.12.0

Minor Changes

  • #1010 Breaking: resolveAsync, resolveOptionalAsync and resolveAllAsync on a disposed container — or on a child of one — now return a promise rejected with DisposedContainerError instead of throwing it, and resolveAllAsync(token) likewise rejects when a when() predicate throws or an alias cycle is followed, so every async method reports its failure through the promise it returns. Code that awaits these calls, or chains .catch(), behaves as before or better; only a caller that wrapped the call itself in try/catch without awaiting it no longer sees the error there, and should await it instead.

  • #995 Add container.explain(token, options), which answers why a request selects the binding it does without instantiating anything. It reads the container chain the way resolve does and decides by the same rules, so its answer is the engine's own. It reports each registry it read with every candidate and its verdict (eligible, slot-mismatch, predicate-refused, collection-member), the rule that decided (sole-candidate, sole-predicate, most-criteria, default-alias, ambiguous), the binding the request ends on after every alias, and an outcome naming the error resolve would throw when it ends on none. when() predicates run as they would in resolve, and options.ancestors explains a request made from inside other resolutions, so a predicate that reads the parent sees the one it would.

  • #999 Breaking: a binding's slot is declared before its strategy — bind(Logger).whenNamed("file").to(FileLogger).singleton(). when(), whenNamed(), whenTagged(), whenDefault() and many() now come before to*() and are refused after it with ChainAlreadyRegisteredError, so a binding registers once, in its final slot, and no later step moves it, displaces something else or brings back what it displaced. Scope and lifecycle hooks stay after to*(). To migrate, move each chain's slot steps in front of its to*() call.

    The registry no longer parks the bindings a re-slot displaced or restores them, so a bind with a name or a tag costs a quarter to two fifths less. BindingBuilder, ConstantBindingBuilder and AliasBindingBuilder lose their Names type parameter, since the slot is settled before they exist.

  • #1005 Breaking for deep subpath imports only — the root entry @codefast/di is unchanged. src/ now follows three rules, and the published subpaths follow src/ with no exception: a directory names a family of two or more modules and a lone module sits flat at its layer; a directory carries the topic and a file its role, so no file repeats its directory's name; and the exports map is the source tree, so the strip that kept the introspection modules at flat specifiers is gone.

    Moves: the MetadataReader verification leaves resolution/cache/class-introspector for metadata/verify — it was the one value import pointing up from metadata/ into resolution/; the RESOLUTION_DIAGNOSTICS channel leaves errors/ for introspection/diagnostics; the fluent chain's contract leaves core/binding for core/binding-builders, and the class implementing it is container/binding-chain; effectiveBindingScope folds into core/binding.

    Renamed specifiers: ./errors/errors → ./errors; ./errors/diagnostics → ./introspection/diagnostics; ./ambient/active-container → ./ambient-container; ./container/binding-builders → ./container/binding-chain; ./core/binding-scope → gone (effectiveBindingScope is in ./core/binding); ./injection/resolve-options → ./injection/dependency-slot; ./metadata/{metadata-keys,metadata-types,metadata-reader-token,symbol-metadata-reader,verifying-metadata-reader} → ./metadata/{keys,types,reader-token,symbol-reader,verifying-reader}; ./lifecycle/{lifecycle-manager,scope-manager} → ./lifecycle/{hooks,scopes}; ./decorators/{lifecycle-decorators,decorator-metadata} → ./decorators/{lifecycle,metadata-record}; ./resolution/path/resolution-path → ./resolution/path; ./resolution/plan/{instantiation-plan,plan-codegen} → ./resolution/plan/{compiler,codegen}; ./resolution/select/binding-select → ./resolution/select/candidates; ./resolution/cache/binding-lookup-cache → ./resolution/cache/lookup; ./{inspector,explanation,dependency-graph,graph-adapters/*} → ./introspection/{inspector,explanation,dependency-graph,graph-adapters/*}. New: ./core/binding-builders, ./metadata/verify.

    @codefast/di-testing follows the one specifier it imports, metadata/verifying-reader.

Patch Changes

  • #996 A request with criteria that matches no slot is fast again when the container holds no alias: resolveOptional() of a missing tagged slot no longer probes for a default-slot alias to forward to in a registry that never held one.

  • #1009 resolveAsync no longer rejects with AsyncResolutionError when a later sibling reads a singleton or scoped binding synchronously — a toDynamic factory's ctx.resolve, an @inject accessor, a top-level resolve — while the async lane is still materialising it from an earlier sibling. A level of the async lane now yields only where a dependency or hook really awaits, so an instance built without yielding is cached before the next sibling starts, and only a materialisation that is genuinely pending is published in flight.

  • #994 A scope refinement costs less at bind time. singleton(), transient() and scoped() now do nothing when the scope does not change, and release only what exists when it does, so bind(T).to(X).singleton() straight after registration no longer calls into the scope manager.

  • #997 Every read on a child container is cheaper. The child's check that no ancestor was disposed is now one epoch compare that V8 inlines into each entry point, the walk up the chain runs only after some container was disposed, and a new child starts confirmed live, so a per-request child skips the walk on its first read.

  • #994 Resolving a class binding for the first time is cheaper. The metadata cache now keeps one record per class, which a cold resolve looks up once instead of reading a separate map for each fact, and a class with no constructor parameters is built without allocating an empty argument list. This mostly speeds up containers that bind and resolve many class singletons at startup.

  • #1001 The first resolve of a class binding is cheaper. The class's parameter list, and the check that a subclass is not dropping its base's declared dependencies, are settled once per class instead of once per instantiation, and the class asked about last is answered without a map lookup, so a hundred singletons of one class bind and materialize about a fifth faster.

  • #994 A root-level resolveAll() read in a loop over the same token, as an event bus does for every event, is cheaper: the lookup cache keeps the last collection in front of its map, so a repeat read skips the map lookup.

  • #997 A container that reads a collection once no longer builds a value list it never reads again. A root-level resolveAll keeps its stable value list from the second read on, the way an instantiation plan compiles on repeat, so a fresh container per request pays only for the members it resolves.

  • #996 A resolve that carries options on a root container gets back much of the speed 0.11.0 took from it. The check that refuses a disposed chain grew in that release enough that V8 stopped inlining the lookup behind that entry point; a live root now pays two field reads for it, and only a child reads the dispose epoch.

  • #998 A root container's resolve(token, options) no longer pays for the check a child makes that no ancestor was disposed. Each container is created with the resolve of its role, so the root's never carries the child's path and the rule that a disposed ancestor refuses its descendants stays as it was.

  • #994 A scoped binding whose scope changes stops handing children the instance they cached before the change. A binding registered on a parent, resolved from a child, then refined scoped() → transient() → scoped() gave that child its old scoped instance back, because only the registering container's cache was cleared. Every container now files a scoped instance under a key the binding replaces when it leaves scoped, so the cached instance is never found again.

  • #1003 A teardown reads a class's @preDestroy methods once for a run of bindings of that class, instead of once per binding, and names the token only when a hook fails.

  • #1002 A rebind that owes no teardown allocates nothing for the binding it displaces, and a collection read skips its scan for dangling aliases in a chain that never held one.

0.11.0

Minor Changes

  • #933 A default-slot alias is a transparent pointer: a request whose criteria no slot of the alias's own token matches is forwarded, criteria and all, to its target — resolve(AbstractLogger, { name: "file" }) reaches Logger's "file" binding. An exact slot on the alias's token still wins, the nearest container still answers first, and has agrees; resolveAll keeps filtering by the alias token's own slots.

  • #896 Retire the async cascade lane: every resolveAsync now runs on the branch lane, so a factory's context answers from its own ancestors before and after an await. A whenParentIs binding requested after an await is now selected as the sync lane selects it, ctx.graph.resolutionPath names the level's own ancestors, and a cycle formed entirely from post-await edges is named from the true root. A transient class or factory root with a statically visible graph is answered by the compiled async plan. AsyncCascadeContext and resolveAsyncFromCascade are removed from resolution/context; ResolutionDiagnostics.builtSubsystems reports resolver.asyncRootLevel once the async lane has been entered.

  • #937 Add declared modules: Module.fromBindings(name, [binding(key, definition), …]) (also SyncModule.fromBindings) builds a SyncModule from a list, each binding() stating one binding with the fluent chain's own vocabulary — to, toSelf: true, toConstantValue, toDynamic, toDynamicAsync, toResolved / toResolvedAsync with deps, toAlias, then whenNamed, whenTagged (one criterion or several), when, many: true, scope and the lifecycle hooks. A definition is typed exactly as the chain is — the key alone decides the value type and the slot names, and the compiler rejects a second strategy, a scope on a constant or an alias, a hook on an alias, onDeactivation off a singleton, and a slot on a collection member. binding() checks the same rules at runtime for plain JavaScript and throws the new InvalidBindingDeclarationError (INVALID_BINDING_DECLARATION, with tokenName and reason), or ManyBindingSlotError / SelfBindingRequiresClassError as the chain does.

    A declared module loads, unloads, ref-counts and imports exactly as the equivalent Module.create setup would, and registers each binding already in its final shape, so a binding with a slot, a scope or a hook skips the chain steps a fluent setup pays on every load. Nothing a resolve reads changes. SyncModule's MODULE_SETUP member now holds either the setup callback or the declarations.

  • #925 A constant that a plain last-wins bind() displaces now still runs its onDeactivation hook at dispose(). A displaced constant left the registry — so the dispose sweep, which walks the registry, never reached it — and, carrying no cached instance, it was absent from the singleton cache too, falling between the two. The container now records a displaced constant that still owes a deactivation and drains it at dispose; a refinement that restores the binding (a when*() that moves the winner off the slot) takes it back out of that set, so nothing deactivates twice. The park-and-restore behaviour of bind() is otherwise unchanged.

  • #931 Disposing a container now reaches its descendants. A child whose parent (or any ancestor) has been disposed is itself disposed: isDisposed reads true, and every resolve, has and mutation is refused with DisposedContainerError, including the child's own bindings — previously a child kept building transients and constants from a torn-down chain, and child.has and child.resolve disagreed. The guard keeps the resolve path cheap: a root still reads only its own #disposed field, and a child adds one call-free compare of a dedicated dispose-epoch cell against the epoch at which its chain was last confirmed live — the ancestor walk runs only after some container in the process is disposed. The dispose epoch is separate from the state epoch, so a per-request child's dispose does not invalidate the chain-version memo that keeps deep resolves cheap, and createChild() is untouched. resolveAsync refuses a disposed chain with a synchronous throw at the entry, matching how it already guarded a self-disposed container, and dispose() stays ungated so an await using child of a disposed ancestor still tears down cleanly.

  • #933 generateDependencyGraph({ includeParent: true }) follows resolution's own walk up the chain: every ancestor's bindings join the graph, a single dependency connects to the nearest container holding a binding its slot matches — a child binding the request cannot select no longer hides the parent's — and an injectAll fans out across the whole chain. buildDependencyGraph takes the ancestor registries, nearest first, in place of the parent registry.

  • #921 A subclass that inherits declared constructor dependencies but declares none of its own — the natural class Derived extends Base {} with an implicit constructor — is now rejected with MissingMetadataError instead of being built with undefined arguments. Constructor metadata is opt-in per class, so an implicit constructor previously slipped through the target.length === 0 check and produced a silently-broken instance. The error names the base and how many dependencies it declares. A subclass whose own @injectable([]) declares zero deps is unaffected — it is built with zero arguments, as declared.

  • #933 Decorators report a runtime without Symbol.metadata as MissingDecoratorMetadataError at the declaration, naming the decorator and the fix ((Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata") in a module imported first), instead of failing with a bare TypeError on the first metadata write. TypeScript compiles context.metadata to undefined on such a runtime; the library does not install the symbol itself, since the package declares no side effects.

  • #926 Module unload is now symmetric with import, and unbindAll resets module bookkeeping. An import inside a module's setup incremented the imported module's ref-count but nothing ever decremented it, so unload(A) left a module A imported behind bound forever — the container now records each module's imports and, when a module's own ref-count reaches 0, releases the imports it took, unbinding an imported module only when its ref-count reaches 0. And unbindAll() / unbindAllAsync() now clear the module ref-count, binding-id and import tables after deactivation, so a module loaded before unbindAll can be load()ed again instead of being silently skipped as already-loaded.

  • #933 A sync rebind() whose old binding owes an async deactivation now fails exactly like a sync unbind() whatever shape the token has: rebind() itself throws AsyncDeactivationError, the old bindings are removed, and nothing is committed. A lone default binding used to let rebind() pass and then throw from .to*() after the replacement was already registered.

  • #929 runWithContainer is now typed as synchronous-only. The ambient container lives in a module-level variable restored in a finally, so it lasts only the callback's synchronous run — it does not survive an await. A callback returning a Promise was silently accepted and lost the context; its return type is now Result extends Promise<unknown> ? never : Result, so an async callback resolves to never. MissingContainerContextError now says the context does not survive an await, and the TSDoc, README and SPEC state the sync-only constraint. The engine internals are unchanged — they only ever ran synchronous callbacks.

  • #894 Four size thresholds are gone, each replaced by the algorithm it approximated: alias chains fold exactly to any length (ALIAS_HOP_LIMIT is removed from resolution/cache/binding-lookup-cache); a generated plan is one statement per node and inlines to any depth; every synchronous lane checks a cycle by the binding's in-flight flag at any depth (RESOLUTION_SET_THRESHOLD and enterResolutionPath are removed from resolution/path/resolution-path); a multi-criterion request is selected by one scan at any binding count. PLAN_CODEGEN_THRESHOLD is 1024, the measured break-even of generating a plan against running its closure.

  • #933 UnreachableLifecycleHookError carries a reason — "unbound" or "no-deactivatable-binding" — and an onDeactivation hook on a token whose every binding is transient or scoped is reported as that, rather than as a token nothing is bound to.

  • #933 validate() checks an optional() dependency whenever it is bound, so a singleton capturing a bound transient or scoped dependency through optional() is a ScopeViolationError; an optional dependency that is not bound still imposes nothing.

  • #976 engines.node is now >=24.0.0, up from >=22.12.0, and Node 22 is no longer supported. Node 24.0.0 is the first release with explicit resource management built in (using, await using, DisposableStack, AsyncDisposableStack, SuppressedError) and all of ES2025, so the packages use both as the platform ships them instead of shimming them for an older line, and the CI matrix runs the unit suite on 24.0.0 itself. Move to Node 24, or stay on the current minor while a deployment still runs Node 22.

Patch Changes

  • #977 The README states the browser floor: Chrome and Edge 136, Firefox 136, and Safari 18.4 or later, the first releases that ship every ES2025 builtin. @codefast/di's README also says what a browser program without explicit resource management does: it keeps skipLibCheck on and calls dispose() instead of await using.

  • #922 A lifecycle hook that returns a rejecting Promise when reached from a synchronous lane no longer crashes the process. runActivationSync and runDeactivationSync call each hook before they can tell it is async, so on discovering a returned Promise they now adopt its rejection (a no-op .catch) before throwing AsyncActivationError / AsyncDeactivationError — a rejecting @postConstruct, onActivation, onDeactivation or @preDestroy can no longer become an unhandled rejection that ends the process. The hook has still run; retry on resolveAsync / unbindAsync to await it properly.

  • #896 An async level with several dependencies, and a resolveAllAsync collection, report the first failing dependency in declaration order — the order the sync lanes report — instead of whichever rejection happened to settle first. Every dependency still starts before anything is reported. A node with one dependency awaits it directly, with no fan-out to settle.

  • #924 child.has(token) no longer throws when a parent container is disposed but the child is not. has recursed through the parent's public has, which asserts the container is live, so a live child that could still resolve a parent-owned token threw DisposedContainerError from has alone — breaking the if (c.has(token)) c.resolve(token) guard on a healthy container. It now recurses through an internal #hasInChain that reads the parent's registry directly, keeping the disposed-guard on the public entry point of the container being called.

  • #894 The cold path allocates only what it uses: a container builds its lookup memo, class introspector, context pools and lone map on first use; a root's plan is compiled on the request that repeats it, so a container that resolves a root once never compiles; a binding's activation need is stamped on the binding instead of memoized in a per-resolver map (ResolutionDiagnostics.builtSubsystems no longer lists resolver.activationNeedMemo); teardown clears instances without splicing the singleton list or pairing each binding; a rebind of a lone token is one registration whose displaced binding is deactivated on the spot; a fresh registration is one probe and one write; a chain's own .many() re-slots without probing the registry; each error class names itself with a literal.

  • #933 A reader passed through ContainerOptions.metadataReader now outranks a MetadataReaderToken binding for that container's children too, at every depth, as it already did for the container itself; a token binding still reaches the children of a container given no reader.

  • #920 resolveOptional, resolveOptionalAsync, resolveAll and resolveAllAsync no longer throw when they reach an alias whose chain ends at a token nothing matches. An alias is a transparent pointer, so a dangling chain is the same miss the target itself would be: the optional lanes return undefined, and a dangling alias member is skipped from a collection rather than failing the whole fan-out. A required resolve / resolveAsync still throws, and an alias cycle still throws CircularDependencyError — a cycle has no absent reading.

  • #976 The README states what a program needs for the declarations' disposal members: the explicit resource management types, which no numbered lib declares before ES2027. @types/node 24 or later loads them, and so does ESNext.Disposable in lib. Without either, TypeScript 7 fails inside container.d.ts with TS2550 under skipLibCheck: false, and at await using with TS2318. @codefast/di-testing drops a /// <reference lib="esnext.disposable" /> that TypeScript 7 stripped from its emitted declarations anyway, and takes the lib from its tsconfig.json instead.

  • #933 A dependency slot declaring tags: [] states no criterion, as a binding slot already did: injectionSlotToResolveOptions answers undefined for it, and such a dependency no longer escapes a compiled plan.

  • #933 A MetadataReader passed through ContainerOptions is asked about a class once for the life of the reader: its answers are memoized per class, and every container handed the same reader shares one verifying wrapper. Lifecycle metadata used to be read on every activation, and each root container asked again.

  • #894 The React Flow graph export lays its nodes out on a square grid derived from the node count rather than a fixed five columns.

  • #934 MissingMetadataError for a subclass that inherits declared constructor dependencies no longer advises giving it an explicit constructor, which is rejected the same way. It points at @injectable([...deps]) — @injectable([]) for a class that takes none — or a toDynamic()/toResolved() binding.

  • #894 Every resolution lane now answers a graph identically. A per-request child that misses a token its parent's bindings all declined reports NoMatchingBindingError as the parent does, not TokenNotBoundError; a sibling on the async interpreted lane is selected against its own path rather than the earlier sibling's frame; a dynamic factory that resolves its own token from its synchronous prefix is reported as a cycle before it runs a second time.

  • #905 The chain-versioned lookup memo answers a repeated token from a method small enough for its hot callers to inline: the hit is the whole of defaultEntry, and everything that fills the memo is the miss. An alias resolve, a parent-owned resolve from a child and every other lookup that reaches the memo pays one inlined compare where it paid a call.

  • #938 A constant with an onDeactivation hook that a bind inside a module load displaces now runs that hook at dispose(), as it already did when a plain container.bind() displaced it. A module's chains registered without the container's displacement bookkeeping, so the displaced constant left the registry untracked and nothing ever tore it down; this held for Module.create setups and declared modules (Module.fromBindings) alike. Unloading the displacing module still does not restore the displaced binding — its deactivation stays owed to dispose().

  • #918 A { name, tag } request now selects the same binding as its { tags: [slotName.of(n), tag] } spelling, closing two selection bugs. A name that has been interned nowhere in the process no longer makes the request a miss: the request's criteria can still be a superset of a slot's, so an index miss falls through to the scan and the parent walk instead of returning early — the answer no longer depends on whether unrelated code has ever called whenNamed with that string. And the name-plus-tag fast lane now declines to full selection whenever the token carries any when() predicate candidate, so the more-specific rule's predicate step is honoured on this lane as on every other.

  • #896 Every binding predicate now reads one ConstraintContext shape: the shared root context, the context a selection builds over a live path, an async level's prefix and the inspector's probe are all DefaultConstraintContext, exported from resolution/context. A predicate's call site stays monomorphic, and the per-selection object literal with its eager ancestors slice is gone — ancestors is now sliced on first read, as ctx.graph already did.

  • #916 LEARNING.md is removed, and with it the Learning page the docs site rendered for this package. What a shape is and what it guarantees stays in ARCHITECTURE.md, the behavioural contract in SPEC.md, and usage in README.md, whose document list no longer points at the removed file.

  • #933 resolveAll returns a token's bindings in registration order however a chain refines its own binding. Binding a default and then a named, tagged, member or predicate binding used to list the later one first, because the refinement re-added what it had displaced behind it; each binding now keeps the place its first registration gave it.

  • #902 A transient factory root resolved with resolveAsync() is handed one resolution context per binding, built on the first resolve and reused by every later one — its path is its own frame alone and the request carries no options, so the context is a function of the binding. The root allocated an array and a context per resolve; it allocates nothing now, and a concurrent root reads the same, correct, path after an await.

  • #919 A NoMatchingBindingError's "Available slots" diagnostic no longer throws when a bound slot carries a tag value that cannot be stringified — a bigint, a null-prototype object, or one whose toString throws. bindingSlotToString (and the dependency-graph edge label) now render such a value as <unprintable> through a shared stringifyTagValue guard, so the real NoMatchingBindingError surfaces instead of a masking TypeError, matching the guard the request-side diagnostic already had.

  • #932 SPEC.md carries the behavioural contract and nothing else. The source-tree listing, the copied export barrel, the build configuration and the roadmap of already-shipped work are gone — a reader can now rename any file under src/ without contradicting the spec. Public API states the surface as a rule instead of a second copy of index.ts, which had already drifted from it, and Scope and requirements states the runtime, module format and TypeScript floor a consumer must bring. New DECISIONS.md takes the background and the whole InversifyJS v8 comparison, README.md takes the testing patterns with the MetadataReader example corrected to Container.create({ metadataReader }), and CONTRIBUTING.md takes the packaging and decorator-toolchain notes.

    Checking the spec against the source turned up four public names it never described: BindingSnapshot.isMany, the DependencySlot that both InjectionDescriptor and ParamMetadata extend, and bindingSlotToResolveOptions, which turns a snapshot's slot back into the ResolveOptions that selects it. All three are specified now, and the claim that toAlias() returns the one builder with no type parameter is corrected — it has no value type parameter, but it does carry the token's slot names.

  • #927 @postConstruct / @preDestroy on a symbol-keyed method now fail at the declaration with the new SymbolKeyedLifecycleError, instead of a misleading InvalidMetadataError at resolve that blamed a MetadataReader the caller never configured. The lifecycle reader keys methods by their string name, so a symbol-keyed method could never be found again; the decorator now rejects it up front, where the mistake is.

  • #957 MissingDecoratorMetadataError suggests installing Symbol.metadata as (Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata"), which type-checks in a .ts file; the bare Symbol.metadata ??= … it quoted failed with TS2540, since TypeScript declares the symbol readonly.

  • #933 A rejected async onActivation on a transient toDynamic binding — per-binding or container-level — no longer surfaces as an unhandled rejection: the sync resolve still throws AsyncActivationError, and the hook's promise is adopted as it already was on every other lane.

  • #965 The documented TypeScript floor is now 7, up from 5.9: TypeScript 7 is the one compiler that type-checks the package and emits its published declarations, so it is the floor they support. README.md, SPEC.md and DECISIONS.md state it; no code or declaration changed.

  • #940 unbind(id) and unbindAsync(id) now tear down a binding that a later last-wins bind displaced: its cached singleton is deactivated, and a displaced constant runs its onDeactivation, at that call instead of waiting for dispose(). The id lookup asked only the registry, which a displaced binding had already left, so the call did nothing. The unbind also invalidates what the displacing chain holds parked, so a later when*() refinement of that chain can no longer restore the unbound binding.

  • #939 Unloading a module now tears down a binding it registered that a later last-wins bind displaced: a displaced cached singleton is deactivated, and a displaced constant runs its onDeactivation, at that unload()/unloadAsync() instead of waiting for dispose(). Unload looked each of the module's bindings up in the registry, which a displaced binding had already left, so it was skipped. The unload also invalidates what the displacing chain holds parked, so a later when*() refinement of that chain can no longer restore a binding of the unloaded module.

  • #923 validate() now reports a container-level onDeactivation hook that can never run. The builder type blocks onDeactivation on scoped/transient bindings, but container.onDeactivation(token, handler) takes any token with no such gate, so a hook keyed to a token whose every binding is scoped or transient used to pass validation silently. validate() now raises UnreachableLifecycleHookError for it, reusing the mechanism that already catches a hook on an unbound token. A token that also has a singleton or constant binding, and any onActivation hook, stay valid.

0.10.1

Patch Changes

  • #892 4c97c5b Thanks @thevuong! - Lower the monorepo's Node floor from 24 to 22.12, so the packages install and run on the active Node 22 LTS line.

    engines.node becomes >=22.12.0 across every package — the floor the shared toolchain (oxlint, Vite, Vitest, TanStack Start) already requires. Development stays on the latest Node (.node-version) for speed, and a CI matrix exercises the floor and the active LTS directly, so the floor is a contract CI proves rather than one everyone has to run. @types/node is pinned to the floor's major (^22), with a workspace override holding the whole tree there so a dev tool's @types/node: "*" peer can no longer pull a newer major and mask an API the floor lacks. The floor stays mechanical, not advisory: @codefast/di keeps its own Map upsert helpers rather than the ES2025 Map.prototype.getOrInsert (which would raise the floor to 26) and its lib stays ES2024. @codefast/cli's mirror step now calls the node:path functions directly instead of aliasing them, which the floor's types correctly flag as unbound methods.

    The shared @codefast/typescript-config presets pin lib and target to ES2024 (was ESNext) so the compiler's ECMAScript surface matches the Node floor: an ES2025 builtin such as Map.prototype.getOrInsert now fails to type-check rather than compiling and crashing on Node 22.12. @codefast/di and @codefast/di-testing already pinned lib and are unchanged.

    Internal subpath imports move from a #/ prefix to a bare # (#core/token, not #/core/token), and the package.json#imports keys become #*/#tests/*/#examples/* to match. Node's native ESM resolver rejects a #/-prefixed specifier with ERR_INVALID_MODULE_SPECIFIER on the whole Node 22 line (and on Node 24 before 24.14), and each package ships those specifiers verbatim inside its published dist/*.js for a consumer's Node to resolve — so this rename is what actually lets the packages import on the new floor. Purely internal: a consumer's own import paths are unchanged.

0.10.0

Minor Changes

  • #866 3621384 Thanks @thevuong! - BindingIdentifier is a branded number, minted from a process-wide counter, instead of a branded string. The id stays opaque — obtained from .id(), handed back to unbind(id), reported by inspect() and the dependency graph — and a plain bind no longer allocates a string for it. Code that treated the id as a string (interpolating, parsing or storing it as text) must treat it as a number.

  • #866 67c7eb4 Thanks @thevuong! - The fluent chain bind() returns is now the binding it registers: one object is every builder step, the registry's record and what the resolver reads, so a plain bind is one allocation instead of three and a copy of every field. Two names on the Binding shapes change to make room for the builder's methods — id is identifier, and the hook fields are activationHook / deactivationHook — and a chain registers exactly once: a second to*() throws ChainAlreadyRegisteredError instead of minting a second binding.

  • #866 7f4efc2 Thanks @thevuong! - resolveAll, resolveAllAsync and the value an injectAll() dependency delivers are ReadonlyArrays, and a root-level, options-less resolveAll hands out the engine's own list — the same array on every call while no registry in the chain has changed — instead of a copy per read. That list is kept while every member is a hook-free constant or a hook-free singleton whose instance is cached, so a collection of singleton handlers is one lookup per read. Callers that mutated the returned array spread it first; a constructor parameter typed Array<T> for an injectAll dependency becomes ReadonlyArray<T>.

  • #866 6980f8e Thanks @thevuong! - many() marks a binding as a collection member: several members of one token coexist on the default slot, resolveAll returns every member, a single resolve never selects one, and membership replaces slot last-wins. A member keeps the default slot (ManyBindingSlotError otherwise) and may carry when() predicates. It is the intended form of a strategy set, where a predicate that always passes used to stand in — and it is what lets a root-level collection of constants be served from its memo without evaluating anything. BindingSnapshot gains isMany.

  • #866 a56390f Thanks @thevuong! - A root-level resolveAll with no options memoizes its candidate list until any registry in the chain changes, and its value list while every member is a hook-free constant with no activation hook anywhere in the chain. The contract already required when() predicates to be pure; the engine now relies on it there, evaluating a predicate once per container state for that read instead of on every call. Reads carrying options, or made from inside a factory, are unchanged.

Patch Changes

  • #863 ccc5ed1 Thanks @thevuong! - Rewrite benchmarks/di/RESULTS.md as a single machine-derived snapshot on @codefast/di 0.9.0 (full profile, GC-exposed, interleaved) instead of an accreted dated ledger, weighting wins and losses equally so the page shows where the engine is slower. The snapshot records that @codefast/di loses the aggregate to ditox (0.79× median) and the geomean to injection-js (0.56×), and breaks down each loss as a real deficit or a by-design work difference — the resolve-all-strategies collapse (0.03× at N=100) is the rivals returning a memoized collection where @codefast/di re-gathers per op.

    Assert the A/B method in BENCH_GUIDE.md: because run.ts rebuilds packages/di/dist from src unconditionally before spawning, swapping the source is the one method, and swapping dist directly is a demoted escape hatch that must use the child entries. Repoint the packages/di/ARCHITECTURE.md tag-chain-walk-memo reference from RESULTS.md to the package CHANGELOG.md, where that A/B now lives.

  • #866 85adfd0 Thanks @thevuong! - Construct an accessor-injected class without the two per-instantiation allocations it used to pay: the ambient resolution handed to its accessors is built once per resolver for the lent root stack, and the construction itself runs inside the ambient scope directly instead of through a wrapping closure.

  • #866 d541dee Thanks @thevuong! - Compile an accessor-injected class as a plan root instead of declining the plan: its constructor parameters compile as usual and construction runs through the host, which puts the class's frame on the resolution path and the container ambient before constructing, so an accessor that cycles back is still reported as a CircularDependencyError. Below a plan's root such a class stays an escape.

  • #866 793f5d0 Thanks @thevuong! - Route every member of a resolveAllAsync collection that is a transient factory with no activation and no request options through the same non-async lane a single resolveAsync already takes, so a fan-out costs one factory promise per member instead of an async state machine on top of each.

  • #866 7a00e30 Thanks @thevuong! - Build collections in linear time. A token's binding list now appends in place and is replaced only on removal or displacement, with every selection walk reading its starting length first, so a predicate that binds mid-walk is still invisible to that walk; and a bare when() rewrites the binding's predicate in place — moving a lone binding into a record — instead of re-registering it, whenever the chain owns the registry's last write and has nothing parked. A hundred when() bindings on one token no longer copy the list a hundred times.

  • #866 3613886 Thanks @thevuong! - Defer every per-container allocation a resolve can happen without. The resolver builds its plan compiler and plan maps on the first plan request, the lookup cache allocates its memo maps only once a second distinct token or tag appears in one cache generation, the activation-need memo is allocated by the first answer its early returns cannot give, and the registry allocates its record map on the first bind. A per-request child that is created, asked one parent-owned token and disposed now allocates none of them; the registry's fast-default map stays eager so the first read of every synchronous resolve is still a bare Map.get.

  • #866 b823fc8 Thanks @thevuong! - Price the two lanes the full pass showed paying for the rewrite: a request for an unbound or record-less token reads the record map alone once its lone-map probe has missed, instead of probing the lone map again, and a root container's chain-summed versions are read as its own version instead of through the epoch memo, so a resolveAll over a root no longer pays a compare and a stamp per candidate.

  • #866 5fbc3de Thanks @thevuong! - A request carrying a name and one tag is answered from a memoized lookup of the exact two-criterion slot, in either declaration order, instead of a scan of the token's bindings; a predicate on that binding still runs, and a name no binding has declared is a miss at once.

  • #866 b46c0cd Thanks @thevuong! - resolveOptional with no options answers a lone default binding in its own registry on the lane a plain resolve takes, and a root that keeps no records answers a miss without the selection walk; a generated toResolved plan checks its factory's result for a promise inline instead of through a call.

  • #866 0506f04 Thanks @thevuong! - A compiled instantiation plan that keeps running — sync or async — is generated as a function of its own through the Function constructor and takes the closure's place, so its call sites carry feedback for one plan only instead of for every plan the process has compiled; below the threshold a plan stays a closure, so a cold container or a per-request child never compiles one. A runtime whose Content Security Policy refuses the constructor keeps every plan a closure and behaves identically. RESOLUTION_DIAGNOSTICS reports generatedPlanCount.

  • #866 e3e9257 Thanks @thevuong! - Keep the common token — one default-slot binding, no predicate — in the registry's fast-default map alone, and give a record (binding list plus tagged indexes) only to a token that carries a second binding, a tagged slot or a predicate, moving it back when the record shrinks to the default slot. A plain bind is now one map write and one binding object; getFastDefault() is unchanged, a bare Map.get on that map, and has()/hasOwn() without criteria answer from a registry presence probe instead of materialising the token's list. RESOLUTION_DIAGNOSTICS reports the record map under registry.records.

  • #866 414d495 Thanks @thevuong! - The binding registry keeps one record per token — its list and its two tagged indexes — in a single map, and builds the binding-id index only on the first id-keyed operation. A bind into a fresh token is one record and one map write where it was three map writes; getFastDefault() keeps its own map so the warm resolve lane still answers from one bare Map.get. Measured paired and alternating against the previous layout: the bind path runs at roughly 1.6× on the 128-binding registration row and the warm resolve rows hold at parity.

  • #866 3db3447 Thanks @thevuong! - Register a binding into a token's record in constant time. Last-wins displacement asked the record's list who occupied the incoming binding's slot, walking every binding already on the token; the default slot now has its own index entry beside the two tagged ones, so an add finds and displaces exactly its occupant without the walk. A token that grows a large collection member by member — the fan-out cold path — was quadratic to build and is now linear: per-bind cost stays flat instead of climbing with the collection size, and the resolve-all-cold rows move from a loss to a win against the decorator-free rivals. The slot index is dropped in the same step a binding leaves its slot — many() turning it into a member, a predicate making it predicate-only — so a later add never displaces a binding that has already moved on.

  • #866 8bb19e3 Thanks @thevuong! - Answer a request the slot indexes decline with an allocation-free first pass over the token's candidates: one slot match with no predicate is returned outright and no match is a clean miss, without building a constraint context, a display name or a candidate array. A second match, or a predicate on a match, still goes through full selection, so specificity and ambiguity are decided exactly as before.

  • #866 5e57752 Thanks @thevuong! - A container's class-metadata caches are keyed by the metadata reader and shared by every container that reads through it, so a child inheriting its parent's reader resolves a class the parent already met without reading its metadata again; the activation-need cache is built by the first interpreted resolve that asks for it instead of with the container.

  • #866 bee69af Thanks @thevuong! - Memoize the lookup cache's chain-summed registry version against a process-wide state epoch that every registry mutation and activation-hook registration advances, and let a root container read both chain sums as its own version. A resolve from a deep child no longer walks its whole ancestor chain on every lookup while nothing has changed; the first read after any change re-sums exactly as before. The activation sum stays a walk: the memo that would serve it costs fields on the resolver that the warm transient class lane pays for.

  • #866 f78726e Thanks @thevuong! - A request carrying one criterion that the token's tagged index does not hold is answered as a miss for that container without scanning its bindings, and walks to the parent directly.

  • #866 5536e62 Thanks @thevuong! - Allocate the deactivation-pair list only when an unbind, rebind or teardown actually owes a deactivation; the hot-swap shape, a binding nothing ever cached, hands back a shared empty list instead.

0.9.0

Minor Changes

  • #847 d0b794c Thanks @thevuong! - Token gains a second type parameter, Names extends string = string, declaring the slot names its bindings may use: token<Logger, "console" | "file">("Logger"). whenNamed, and name in ResolveOptions and InjectOptions, narrow to it, so a misspelt name is a compile error and the IDE completes the declared names at every bind and request site. Names is a covariant phantom that defaults to string, so existing tokens, class keys and internal Token<unknown> lanes are unchanged; a new SlotNamesOf<Key> type reads the set back.

    Breaking: whenParentNamed and whenAnyAncestorNamed now take the parent token first — whenParentNamed(Database, "primary") — and match only when that frame resolves that token at that slot. A slot name is a label on one token's bindings, so the token is part of the question and is what types the name; a label shared across tokens is what a tag key is for. validate() checks the name on that token's bindings and UnreachableConstraintError carries the new requiredTokenName. The reserved criterion handed to a …Tagged helper (whenParentTagged(slotName.of("x"))) is now validated too, as it is the same bare string.

    Display names follow one rule everywhere the package speaks — spelled like the TS symbol they stand for, under the owner's namespace: token<Logger>("app:Logger"), Module.create("app:Infra", …), tag("app:cacheTier"). The package's own MetadataReaderToken now prints as di:MetadataReader, beside the reserved di:name key. SPEC gains a normative "Display names" section stating the rule and its enforcement.

Patch Changes

  • #827 0984174 Thanks @thevuong! - package.json now carries homepage and bugs, so npm links the package README and the issue tracker the way the other @codefast/* packages already do.

0.8.1

Patch Changes

  • #791 37a212b Thanks @thevuong! - Point the README's license badge and "License" section at the package's own LICENSE file instead of the monorepo root.

  • #796 ba04d27 Thanks @thevuong! - Rewrite the README around one structure — what the package is, installation and requirements, a complete quick start, the concepts in learning order, then the documents and the license — with every API claim checked against the current code and no figures that go stale.

  • #784 ad2f93a Thanks @thevuong! - Ship the MIT LICENSE file in the published package. files already listed it, but the file was missing from the package directory, so the tarball had none.

0.8.0

Minor Changes

  • #774 96af502 Thanks @thevuong! - Fold the name lane into the tag lane: a slot name is now a criterion of the reserved tag key slotName, exported from the package root.

    whenNamed(n) ≡ whenTagged(slotName.of(n)) and { name: n } ≡ { tag: slotName.of(n) } — one selection model, one matcher, one criterion index. Names take part in key masks, criterion counting (specificity) and the multi-criterion union lane like any tag, and whenParentTagged(slotName.of(n)) is now expressible directly.

    Breaking (behavioral): outcomes change only for a request carrying both a name and at least one tag. The old name rule — equality, absence included — excluded every slot that declared no name; under the one-rule model those slots match whenever their declared criteria are covered by the request, and the most-specific candidate (highest criterion count, the name counting as one) wins. Requests carrying only a name, only tags, or nothing resolve exactly as before. Selection stays container-local: a child's matching subset slot answers before a parent's more specific one, exactly as it always has for tag-only requests. initializeAsync instantiates each singleton binding directly instead of re-selecting by its slot's criteria, and a request's name folds through the new TagKey.peek() — an intern read that never mints — so dynamic names no binding declares are never retained.

    Breaking (internal API): the registry's string-keyed named index, the lookup cache's named lane, and the isNameOnlyOptions/singleTagOnlyOf helpers are gone; singleCriterionOnlyOf is the one admission test. BindingSlot.tags now carries the reserved criterion and BindingSlot.name is its derived view.

    Benchmarked paired and alternating (three adjacent source-swap passes): the hottest named rows gained 9–12%, the tag shorthand row 4%; details and the accepted trade on two codefast-only rows are in benchmarks/di-inversify/RESULTS.md.

0.7.1

Patch Changes

  • #748 bde6d1b Thanks @thevuong! - Add a summary to every exported declaration whose doc block carried only an @since tag.

0.7.0

Minor Changes

  • #737 cafd7ad Thanks @thevuong! - Lower the Node floor to 24, matching every other package in the monorepo. The container no longer calls the ES2025 Map.prototype.getOrInsert / getOrInsertComputed; both forms now live in core/map-upsert and are still chosen by hit rate — eager where a bind is usually a token's first, computed where a lookup usually hits. A Node 24 serverless runtime therefore needs no polyfill: examples/tanstack-start dropped the Map.prototype patch it carried to deploy on nodejs24.x, along with the deferred import and the await that patch forced on every container boot.

    Measured, not assumed. Paired alternating A/B against the previous build (source swap, isolate runner, four passes, benchmarks/di-inversify) puts every scenario at parity or better beside a four-pass A/A floor on the same rows, with named-constant-get clearing its floor at 1.14× — consistent with a small local helper inlining where a newly shipped builtin does not. The table and the row that must not be cited are in the suite's RESULTS.md.

    Also closes a latent typing hole the method form hid. TypeScript does not contextually type the right-hand side of ??=, so a bare new Map() there is Map<any, any> and every read through the field goes unchecked; every lazily allocated index in the package now spells its type arguments. Both helpers additionally constrain their value type to exclude undefined, since they read absence with one get — a map that stores undefined needs a has and is a compile error here rather than a silent overwrite. packages/di also pins lib to ["ES2024", "ESNext.Decorators"], so the Node floor is now a compile error to break rather than a note in CONTRIBUTING.

0.6.2

Patch Changes

  • #732 83704d9 Thanks @thevuong! - fix(di): reject a deps array longer than the constructor at compile time

    The deps-checking changeset closed three of the four @injectable mismatches and documented the fourth as the one TypeScript's arity rules let through: a class taking fewer parameters than deps declares satisfies a constructor type taking more, so the surplus dependency compiled, resolved, and was discarded — a latent wiring bug nothing reported. The deps overload now infers the class and requires Deps["length"] to be an arity the constructor declares, so the surplus is a compile error at the decorator.

    Exactly the arities the class admits stay legal: an optional trailing parameter contributes every length it declares (the check is against the union), and a rest parameter admits any list. @injectable() and @injectable([]) keep their looser overload for classes that inject through accessors. The rejection and both admissions are pinned in tests/types/injection-contract.test.ts beside the three mismatches already there.

    The check only binds where the compiler knows the length: a deps array — built at runtime, length number — skips it, which is also the deliberate spelling for declaring more dependencies than the constructor takes (the dependency graph's edge declarations do exactly this). The first draft enforced arity on arrays too and was corrected by its own first run: it rejected a legitimate mapped-token deps list and the intentional declare-more-than-you-take shape in the benchmark suite.

    A literal deps tuple that compiled against the old signature only did so by carrying a dependency the class could never receive — fixing the declaration is deleting the surplus entry.

0.6.1

Patch Changes

  • #729 a8fff29 Thanks @thevuong! - perf(di): settle a single-tag dependency at compile time, the named settlement's rule on the tagged lane

    A compiled plan settled a name-only dependency ahead of time and still escaped a tag-only one, though the tagged lane now has everything the named rule needs: criteria are interned so an index hit is exact, and the chain-walk memo gives the compiler a path-independent lookup to ask. A dependency carrying one tag and nothing else, whose candidate carries no predicate and whose slot the request satisfies, now compiles to a plain dep thunk; a predicate, a second tag, a name, a miss — anything whose selection could read the resolution path — escapes exactly as before, on both the sync and async plan lanes.

    InstantiationPlanHost gains lookupPathIndependentTaggedEntry, deliberately optional where the named twin's arrival was a breaking change: a host that does not provide it stays a valid host, and a compiler given none simply escapes the dependency, which is the pre-settlement behavior.

    Measured paired against the previous build, six alternating passes: the new slot-injected-tag-compiled row — four tagged constants injected into one class, mirroring the named pair — reads 4.33× (every pass 4.13–4.61), going 5.76M → 24.96M hz/op and landing exactly where slot-injected-name-compiled and the criteria-free plan-deps-inlined sit, which is what "the criterion was the only reason it escaped" predicts. The interpreted twin and three controls hold parity. Eight tests mirror the named settlement's pins — the baked answer tracks rebinds, a predicate keeps runtime selection, an opaque factory's escape replays the tag — plus one holding a two-tag dependency on the runtime path.

  • #729 a60bcf8 Thanks @thevuong! - fix(di): a when() chain no longer hides a helper's requirement from validate()

    .when(whenParentNamed("x")).when(other) composes one closure out of two predicates, and the composition dropped the unreachability requirement the name helper had recorded — the constraint stayed impossible to satisfy, validate() just could not see it, a limit the previous changeset stated outright. SPEC's rule carries no such carve-out: validate() throws UnreachableConstraintError when no binding declares the slot name a constraint waits for, composed or not.

    The composition site now merges both sides' requirements onto the composite predicate, and validate() reads the full list, so a requirement survives any number of when() narrowings and either side of the chain can contribute one. constraintRequirementOf keeps its shape and answers the first recorded requirement; the plural constraintRequirementsOf is the reader validate() uses. A container that previously validated clean can now throw — that is the documented rule holding where it silently did not.

  • #729 0ee3290 Thanks @thevuong! - perf(di): lend a sync escape's seed stack instead of copying it per call

    A compiled plan's escape re-enters the runtime resolver seeded with its ancestor frames, and minted a fresh [...frames] on every call because the resolver pushes and pops on the array it is given. Every sync lane pops what it pushes, so the owned array still holds exactly the seed when a call returns — the thunk now lends one array, the resolver's own root-stack rule one level down. A dirty return (length not restored) drops the array and the next call mints; re-entering the same thunk without a genuine cycle is impossible — every route back to the same plan node crosses a binding that is still in flight — so the claimed branch is a one-compare defence rather than a hot case. The async escape lane keeps copying: it lives across awaits, where "the call returned" and "the stack is free" are different moments.

    The win is bigger than removing one small allocation, and the mechanism is the context pool: a pooled resolution context is reused only for the array pair it already holds, so a fresh array per escape forced a fresh context per escape — the lent array keeps its identity across calls and the pool starts hitting. Measured paired against the previous build, six alternating passes, all five escape rows positive in every pass: plan-escape-factory-dep 1.41× (1.331–1.460), plan-escape-scoped-dep 1.23×, plan-escape-optional-dep 1.14×, plan-escape-hooked-dep 1.12×, plan-escape-multi-dep 1.11×; the no-escape plan-deps-inlined control holds parity and realistic-graph-resolve-root, whose root plan escapes once for a singleton materialization, reads 1.03×. A new test pins that a throwing escape leaves the thunk reusable.

  • #729 12186d6 Thanks @thevuong! - perf(di): a one-entry cache in front of TagKey.of(), narrowing the inline-criterion gap

    The tag-interning changeset recorded that an inline .of(v) stayed behind a hoisted criterion because every call reads the intern map, and that the gap had widened rather than closed. An inline call site usually repeats one value, so of() now keeps its last (value, pair) and answers a repeat before touching internKeyFor or the map. The check is Object.is — the comparison the slot contract already defines — so ±0 stay distinct and NaN hits itself, with no special-casing.

    Measured paired against the previous build, six alternating passes on rows whose A/A floor is the suite's widest: slot-tag-shorthand-inline 1.07× (six of six passes positive, 1.040–1.124) and slot-tag-array-inline 1.07× (five of six), while both hoisted rows — which never call .of() in the loop — sit at parity, doubling as the proof the source swap was live. Hoisted stays the fast spelling, as it must: it pays zero calls.

  • #729 aa76f4d Thanks @thevuong! - perf(di): memoize the single-tag chain walk, with a deferred map so a per-request child does not pay for it

    A single-tag resolve was the one criteria lane that consulted each container's tag index on the way up on every call — defaultEntry and namedEntry already memoized their walks, and ARCHITECTURE carried the question as open, waiting on a fresh-vs-warm measurement. That measurement (RESULTS.md, 2026-08-16): the memoized name lane answered the same parent-owned shape at ~2.1× the unmemoized tag lane, warm; the memo's per-container state cost ~5–8% at duty cycle 1 and amortized before N=4.

    BindingLookupCache gains taggedEntry(token, tag) — chain-versioned like namedEntry, null meaning "this shape needs full selection", predicate- and alias-carrying hits declined so a when() is still evaluated on every resolve — and resolve() gains the tagged twin of its name-only fast lane, dispatching just the shapes whose semantics involve no resolution context (plain constants, cached singletons). The memo key is the criterion object itself: interning makes identity the slot contract's own Object.is, so ±0 stay split and no indexed hit needs a value re-check.

    The first cut mirrored namedEntry exactly and failed its own gate: fresh-child-tag-n1 — one tagged resolve inside a child that then dies — read 0.861 across six negative passes, and the whole cost was the inner-map allocation on a container that never asks twice. So the first (token, tag) shape a cache generation sees is answered from the walk and parked in a one-entry front; the map is not written until a second distinct shape appears, and an alternating pair converges after one extra walk per key.

    Measured paired against the previous build, six alternating passes, medians: slot-tag-parent-owned 2.78× (every pass 2.73–2.95), slot-tag-shorthand-hoisted 2.14×, tagged-binding-resolve 1.98× (a head-to-head row), fresh-child-tag-n4 1.24×, with the must-hold fresh-child-tag-n1 at 0.98 (0.933–1.072, inside that row's floor) and the name-lane and multi-tag controls at parity. Seven new tests pin the memo's invalidation and its refusals: rebind/unbind in both request spellings, a parent rebind observed from a child, a predicate beside the tag, a late container-level activation hook, and a warmed +0 criterion refusing -0.

0.6.0

Minor Changes

  • #727 9182664 Thanks @thevuong! - perf(di): compile statically-visible graphs entering resolveAsync into async plans

    A transient class/resolved/resolved-async binding resolved by resolveAsync at a true root (no open cascade) now compiles once into a plan, like the sync lane has always done — the graph is declared up front, so nothing about it needs per-level bookkeeping. A fully synchronous subtree executes without touching a promise; nodes that may yield one await their dependencies together, exactly as the interpreted path does (promise-valued constants unwrap, siblings start before the first rejection propagates). dynamic-async factories stay opaque and keep the cascade lane. Escapes replay the async dispatch seeded with the plan's ancestors, so cycles, criteria and hooks behave as if nothing had compiled. Diagnostics gain compiledAsyncPlanCount.

  • #690 ed1387c Thanks @thevuong! - Fix toResolved bindings on the async path, and tighten the type surface.

    bind(T).toResolved(factory, deps) threw InternalError: resolved binding requires resolution context from every async entry point — resolveAsync, resolveAllAsync, resolveOptionalAsync, and any toDynamicAsync factory awaiting one. requiresResolutionContext() answers only for the two factory kinds that are handed a context, so a resolved binding legitimately arrives with none; the guard that rejected it never read the context it demanded. The sync lane never had it.

    Type-surface changes, all verified type-identical or strictly wider:

    • Optional properties on the public option bags — ResolveOptions, InjectOptions, InjectableOptions, GraphOptions — are now ?: T | undefined. Under exactOptionalPropertyTypes the old ?: T rejected a caller holding T | undefined, which is the shape a real call site has.
    • BindingConstraint is exported: the (ctx: ConstraintContext) => boolean that when() takes and every when* helper returns now has a name instead of fourteen inline spellings.
    • ParamMetadata and InjectionDescriptor extend DependencySlot, and the dependency-graph builder uses it directly, so the one shape both dependency sources normalise to is enforced by the compiler rather than by four declarations that happened to match.
    • PartialBinding is derived from Binding by a distributive Omit, so a new binding kind cannot join one union and miss the other.
    • The inert const modifier is gone from type parameters inferred from a token rather than a literal; the four on toResolved/toResolvedAsync, where deps is an array literal, stay.
  • #703 4ceccf2 Thanks @thevuong! - A chain's when() calls now narrow rather than replace. SPEC defines a candidate as a binding that passes all of a chain's when(ctx) predicates, and §5.4 describes a binding as carrying "one or several constraints combined"; the chain's type says the same by returning this. Only the implementation disagreed, and it did so silently — #reslot overwrote the predicate field, so the first condition was discarded and never called.

    The consequence was worse than a binding being more permissive than written. Specificity prefers a binding that carries a predicate, so a discarded first condition made the constrained binding beat the default one:

    container
      .bind(Logger)
      .to(ConsoleLogger)
      .when((ctx) => ctx.parent !== undefined) // never consulted
      .when((ctx) => ctx.parent?.scope === "singleton");

    Nothing reported it — not at bind time, not at resolve time, not from validate(). A service that should have received the default logger received the constrained one instead.

    whenTagged() already accumulated, which is what made this a trap rather than a quirk: two adjacent methods with the same chaining syntax and the same this return type, one combining and one replacing.

    Binding-level onActivation() and onDeactivation() keep replacing. That reading is pinned by a test and is reasonable for reconfiguring a chain held in a variable, even though the chained spelling reads as combination and the container-level hooks accumulate. Changing it is a separate decision from this one.

  • #708 957f438 Thanks @thevuong! - A compiled plan now settles a name-only dependency at compile time instead of escaping to the runtime for it. A dependency escaped as soon as it carried any criterion, before anything tried to look it up — yet whenNamed writes the binding's slot name rather than a predicate, so a name-only request is usually a plain hit in the registry's named index, and that index is already memoized on the same registry version the plan cache is keyed on. Four named constants injected into one class stop escaping and become four () => value thunks.

    The row that measures exactly that shape, slot-injected-name-compiled, reads 4.06× the previous build, paired and alternating over twelve passes with every pass above 3.87×, while its interpreted twin and eleven control rows hold parity. Allocation on the same shape falls from 1260 to 366 scavenges per 2M resolves — level with the criteria-free plan of the same arity, so the compiled lane no longer allocates more than the interpreted path it exists to beat.

    Selection is only baked in when it cannot depend on the resolution path: the candidate must carry no predicate and its slot must match the request. A predicate reads the path, so it stays the runtime's to evaluate, and anything else — a tag, a miss, an ambiguous name — escapes exactly as before.

    InstantiationPlanHost gains lookupPathIndependentNamedEntry. Breaking for anything implementing that interface directly, which the package exposes on the ./resolution/plan/instantiation-plan subpath.

  • #704 44dd4a3 Thanks @thevuong! - toConstantValue(...).onDeactivation(...) now runs, and validate() reports a hook that can never run.

    SPEC §3.4 names toConstantValue as one of the two things deactivation applies to ("treat as singleton"), and ConstantBindingBuilder offers onDeactivation in the type. The hook was never called.

    What made it hard to spot is that it worked whenever an activation hook happened to sit beside it:

    container
      .bind(Pool)
      .toConstantValue(pool)
      .onDeactivation((p) => p.end()); // silent
    container
      .bind(Pool)
      .toConstantValue(pool)
      .onActivation((_ctx, p) => p) // unrelated
      .onDeactivation((p) => p.end()); // now it fires

    The resolver's plain-constant fast path returns binding.value with no pipeline, and it decides that on activation alone — but the step it skips is what records the binding as a cached singleton, and that recording is what teardown iterates. An activation hook disables the fast path, so the deactivation starts working for a reason that has nothing to do with it. dispose(), unbind(), unbindAll() and module unload() were all affected.

    Teardown now finds a constant through the registry instead of relying on that recording. A container that has never held a constant skips the sweep, so teardown cost is unchanged where there is nothing to find.

    A constant deactivates whether or not anything resolved it. A singleton only exists after its first resolve, so an unresolved one has nothing to deactivate; a constant is the opposite — the value is handed in at bind time and exists from then on. Making the hook depend on whether someone happened to resolve it would make teardown depend on resolution order. If the value was activated, the hook receives the activated value rather than the bound one.

    validate() reports an unreachable hook

    Container-level hooks are keyed by token identity, so a class used only as an implementation target matches nothing:

    container.bind(LoggerToken).to(ConsoleLogger);
    container.onDeactivation(ConsoleLogger, (l) => l.flush()); // keyed by the class — never runs

    validate() now throws UnreachableLifecycleHookError when a container-level hook's token is bound in neither this container nor any ancestor. Registering a hook before its binding stays valid — the check runs at validate(), not at registration — and a hook for a parent-owned token is accepted, since the child resolves through it.

    Not changed

    transient, scoped and toAlias bindings still have no deactivation, and this is not a gap: TransientBindingBuilder, ScopedBindingBuilder and AliasBindingBuilder do not expose onDeactivation at all, so the compiler rejects it. They appear to accept one only under a runtime that skips type-checking.

  • #706 8dfac73 Thanks @thevuong! - Two constraints that could never hold are now reported instead of quietly resolving to the default binding.

    whenParentTaggedAll([]) reads as a requirement but matches every parent — "carries all of no criteria" is vacuously true, so the constraint silently weakens to "has a parent at all", and specificity still ranks it above an unconstrained binding. Both …TaggedAll helpers now throw EmptyTagCriteriaError at the call site. An empty list is what a filtered array or an absent config produces, which is exactly when nobody is watching.

    whenParentNamed("typo") waits on a bare string, so a misspelling produces a constraint nothing can satisfy, with no error at bind time, at resolve time, or from validate(). The name helpers now record what they wait for on the predicate itself, and validate() throws UnreachableConstraintError when no binding in the container or its ancestors declares that slot name.

    Neither touches resolution: the criteria check runs where the helper is called, and the name check runs inside validate(). The requirement rides on the predicate under a symbol that resolution never reads.

    One limit, stated because it is easy to assume otherwise: chaining .when(whenParentNamed("x")).when(other) composes a new closure, and the requirement does not survive that. The constraint is still unreachable; validate() just cannot see it.

  • #681 4a29f20 Thanks @thevuong! - A custom MetadataReader can now actually reach resolution, and the ambient container is public API:

    • Container.create({ metadataReader }) — new ContainerOptions. A container hands its reader to the resolver it builds in its constructor, so a MetadataReaderToken binding on that same container was always too late: resolution kept the decorator reader and any undecorated class threw MissingMetadataError, while validate()/inspect()/generateDependencyGraph() re-read the token and honoured it — the two halves disagreed. The option is in place before the resolver exists and is inherited by children; the binding path still works in its one working shape (bound on a parent, used from a child).
    • One container, one reader. That asymmetry is gone rather than documented: a container now answers every question — resolve, validate(), inspect(), generateDependencyGraph(), unbind* — with the reader its resolver was built with, so introspection cannot describe a class differently from how it is instantiated. As a side effect those paths no longer re-scan the registry for MetadataReaderToken on every call.
    • runWithContainer / getActiveContainer are exported from the root entry, alongside the metadata pieces needed to write a reader without reaching for subpaths: defaultMetadataReader, SymbolMetadataReader, and the ConstructorMetadata / LifecycleMetadata / ParamMetadata types. toMermaidGraph joins the other graph adapters on the barrel.
    • A MetadataReader's answer is verified, not trusted. The seam returned ConstructorMetadata by cast, so a hand-written reader that forgot params produced a bare TypeError from the plan compiler — no code, no class name — while validate() passed the same container because its cold path defended with ?? []. New InvalidMetadataError names the class and the defect. A supplied reader is wrapped once at container construction so resolve, validate() and generateDependencyGraph() all see verified answers; the decorator reader writes the metadata it later reads, so a container that supplies none is left on the path it always took.
    • Fix: MissingContainerContextError named the accessor where it meant the class. Constructing a class with @inject accessors outside a container context reported Class 'clock' … container.resolve(clock) instead of the class it was told to name (SPEC §7.5 already specified the class). The error now carries className: string | undefined and accessorName: string | symbol instead of a single flattened targetName, and phrases itself accordingly — the word "Class" leaves the sentence when there is no class to name. Breaking: targetName is gone from this error.
    • New examples 18-ambient-container and 19-custom-metadata-reader; SPEC §6.1, §6.11, §7.4 and §7.5 updated.
  • #727 4d472a6 Thanks @thevuong! - fix(di)!: keep a factory's ctx on its own resolution path across a nested top-level resolve

    The depth-indexed sync context pool reused a pooled context by resetting it onto whatever array pair the next acquisition carried. A nested container.resolve() inside a factory mints its own path arrays and reaches the same depth as the frame still holding that pooled context, so the outer factory's ctx was silently re-pointed at the nested resolve's arrays — ctx.resolve then evaluated when() predicates against an empty ancestor chain and selected the wrong binding, and ctx.graph reported an empty path. A pooled context is now reused only for the array pair it already holds; a mismatch mints a fresh context, and only the resolver's two stable pairs may claim a pool slot.

    Breaking (type/API surface, no behavioral change for correct programs):

    • ConstraintContext.currentResolveOptions is now Readonly<ResolveOptions> — the object was already frozen at runtime, so a write through it always threw; it is now a compile error.
    • ScopeManager.hasScoped/getScoped are removed — dead since readScoped replaced the two-read shape.
    • Diagnostics getters renamed for role: BindingRegistry.isBuilt → isNamedIndexBuilt/isTaggedIndexBuilt (the tagged index was previously unobservable), ScopeManager.isBuilt → isScopedCacheBuilt, LifecycleManager.isBuilt → isActivationTableBuilt. builtSubsystems now also reports registry.taggedIndex.
    • TagKeyMask is exported from the package root.
  • #680 c415c6b Thanks @thevuong! - generateDependencyGraph now tells the whole wiring story instead of an approximation of it:

    • Optional dependencies are visible. A bound optional dependency's edge carries an optional label; an unbound one now points at an unbound:<token> placeholder node (kind/scope: "unbound") instead of silently disappearing — "optional and absent" is no longer indistinguishable from "not a dependency".
    • Multi-bindings fan out. An injectAll(...) dependency draws an edge to every binding of the token, not just the first.
    • Class-constructor edges use slot labels. A named or tagged constructor dependency is labeled name:.../tag:... like resolved-factory deps always were, and edge targets are filtered with the same slot-matching rules resolution uses (matchesSlot, SPEC §6.9) — an unnamed request no longer draws an edge to a named binding it could never resolve.
    • includeParent connects across the chain. A child binding whose dependency is satisfied by the parent now gets its edge (own bindings still shadow the parent, mirroring resolution's upward walk).

    GraphNode["scope"] widens from BindingScope to BindingScope | "unbound" for the placeholder nodes.

  • #727 a4377ff Thanks @thevuong! - Breaking: removed isToken(). The guard tested for an object carrying a string name, which every Token has but so does anything else — an InjectionDescriptor that named its slot passed it, as did any plain { name } object. A Token is a branded structural type with nothing to check at runtime, so the predicate could not be made sound; it narrowed to Token<unknown> on evidence that did not support the claim. Nothing in the package used it.

    Discriminating a declared dependency is what isInjectionDescriptor() is for, and it stays. Code that called isToken(x) to tell a token from a class wants typeof x === "function" instead.

  • #690 f4b1aa6 Thanks @thevuong! - Report the failures that were being swallowed or mislabelled, and derive the types the build emits.

    @codefast/di:

    • @inject, @postConstruct and @preDestroy on a static member now throw StaticMemberDecoratorError instead of InternalError. All three act on one instance, so this is caller misuse — and InternalError means the library broke, which sent anyone catching it to file a bug against their own mistake. SPEC §10 already recorded that mistake for predicate ambiguity.
    • AsyncResolutionError names the token the caller asked for and the token whose factory is async, which is what SPEC has always specified. Every throw site passed the same token twice, so the message read "Token 'X' requires async resolution because 'X' in its dependency chain has an async factory"; a resolve(App) that fails on an async Database now says so. asyncSourceToken defaults to tokenName for the case where the requested binding is itself the source.
    • A MetadataReader that names a @postConstruct/@preDestroy method the instance does not have raises InvalidMetadataError instead of skipping the hook — a hook that silently never runs is the failure a caller cannot see. InvalidMetadataError's message no longer says "constructor", since it now covers both answers; the specifics moved into reason.
    • MissingScopeContextError from ScopeManager names its token instead of "(unknown)", and the scoped read takes one map lookup where it took two.
    • Token, Constructor and InjectionDescriptor declare out Value, so the compiler checks the covariance the engine already relied on.

    Repo-wide: isolatedDeclarations is on for every package that emits declarations, so a public type can always be written down from the source file alone. allowJs is gone from the shared base config — no package has JavaScript sources. @codefast/theme and @codefast/tracking gained explicit annotations on four exported constants to satisfy it; the emitted types are unchanged. @codefast/ui and @codefast/benchmark-viewer opt out for reasons recorded in their configs.

  • #727 50448de Thanks @thevuong! - perf(di)!: freeze slot tags where they are built so snapshots alias instead of copy

    BindingSnapshot.slot.tags is now the binding's own frozen array rather than a fresh copy per snapshot per binding — lookupBindings()/inspect() skip an allocation per binding, reclaiming the cost the defensive copy had added. The array is frozen at its two construction sites (the default slot and the builder's re-tag), so the registry stays uncorruptible.

    Breaking: mutating a snapshot's tags array — already a type error against ReadonlyArray — now throws TypeError at runtime instead of silently editing a private copy.

  • 08a5f2d Thanks @thevuong! - Publish every module as an entry point again — the sole-consumer repo prefers full access over encapsulation. The 0.5.0 surface reduction (13 subpaths) is reverted: resolution/*, registry, container/*, binding, constructor-type, and the metadata internals are entry points once more. Introspection modules keep the flat specifiers they have always shipped under (./inspector, ./dependency-graph, ./graph-adapters/*).

  • #680 c415c6b Thanks @thevuong! - The dependency graph states its facts as fields instead of hiding them in a display string. GraphEdge gains optional: boolean and slotName?: string, so a consumer reads what an edge means rather than parsing label (which stays, as the string the adapters render). GraphNode gains tokenKey: two tokens that share a display name are now distinguishable, and the same token keeps its key across graphs from the same process — enough to key a view by, which tokenName never was.

    GraphNode["kind"] is now BindingKind | "unbound" instead of a bare string, alongside the already-widened scope, and the React Flow and Cytoscape adapters carry those same unions (plus tokenKey, optional, slotName) instead of flattening them to string — a consumer can narrow on them now. SPEC.md now documents what the graph does and does not represent: unbound optional placeholders, omitted required-but-unbound deps, injectAll fan-out, slot-filtered targets, unevaluated predicates, and parent shadowing under includeParent.

  • #688 3112841 Thanks @thevuong! - inject(), optional() and injectAll() accept the single-tag shorthand. container.resolve(Token, { tag: pair }) has always been valid while inject(Token, { tag: pair }) was a compile error, so the same request had two vocabularies depending on whether you were asking a container or declaring a dependency — and the one a constructor dependency had to use was the longer one.

    InjectOptions gains tag, and that is the whole surface change. Nothing downstream learns a second spelling: the shorthand is folded into tags where the descriptor is built, so InjectionDescriptor, ParamMetadata, the plan compiler and the dependency graph keep seeing exactly one tag list. Passing both is a request for every pair across the two, which is what the matcher already did with them.

    While that path was open: an @inject accessor was rebuilding its resolve options on every constructed instance, from the raw options rather than from the descriptor. It now derives them once, from the descriptor — so the accessor honours the shorthand for free, and stops allocating per instance.

    Measured against the previous build on the bind and boot rows, paired and alternating: parity everywhere, controls clean.

  • #702 22a02b8 Thanks @thevuong! - What a constructor and a factory are handed is now checked against what they declare.

    @injectable([...]) had no relation to the class it decorated. Deps in the wrong order compiled and injected the wrong dependency; a deps array one short compiled and handed a parameter undefined; injectAll() handed an array to a parameter declaring one value, and optional() handed undefined to one that did not admit it. Every case failed silently — resolution succeeded, the object was built, and the wrong value was already inside it.

    The decorator now infers its deps and requires the class to match:

    @injectable([ConfigToken, LoggerToken]) // Property 'log' is missing in type 'Config'
    class Service {
      constructor(
        readonly logger: Logger,
        readonly config: Config,
      ) {}
    }

    @injectable() and @injectable([]) are unchanged — a separate overload keeps the no-dependency form as loose as it was, for classes that inject through properties.

    One mismatch still compiles: a deps array longer than the constructor, because a class taking fewer parameters satisfies a constructor type taking more. The surplus dependency is resolved and discarded rather than misplaced, which makes it the least harmful of the four.

    Alongside it, a hand-written InjectionDescriptor no longer lies to a toResolved factory. { token: Plugin, multi: true } said Plugin and delivered Array<Plugin>; optional: true said Plugin and could deliver undefined. ResolvedDependencyValue reads the flags before the descriptor's own type parameter, so both now say what they do. injectAll() and optional() were already correct and are untouched.

    The tightening found real looseness in examples/17-extended-constraints immediately: thirteen tokens were declared by structural shape — token<{ score(): string }> — while the constructors receiving them declared the concrete class, which has private members and is therefore a different type. Every one of those tokens is bound to exactly that class, so they now say so.

  • #680 c415c6b Thanks @thevuong! - New toMermaidGraph adapter (@codefast/di/graph-adapters/mermaid): renders a container graph as Mermaid flowchart TD source — viewable anywhere Mermaid renders (GitHub markdown, docs tooling, mermaid.live) with no extra library. Parent-chain nodes and unbound-optional placeholders carry dashed classDefs; toDotGraph now also dashes unbound placeholders, not just parent nodes.

  • #727 def51b4 Thanks @thevuong! - perf(di)!: derive resolution-path names from frames instead of carrying a second array

    Every hop used to push and pop two lockstep arrays — token names for error messages and frames for cycle detection. The name array is gone: cycle guards, branch extension, escapes, contexts and the cascade carry only the frame stack, and the names an error or ctx.graph.resolutionPath reports are derived from the frames at the moment they are asked for. Hot lanes pay one push/pop per hop instead of two; only error paths pay the name materialization.

    Breaking (internal-module surface; the root export is unchanged):

    • ResolverCallbacks and the resolution-path helpers take only the frame stack — enterResolutionPath(stack, frame), extendResolutionBranch(stack, depth, frame); OwnedBranchPath and extendResolutionStackBranch are gone (OwnedBranchStack is the one brand).
    • DependencyResolver.rootPath is gone; the lending protocol reads rootStack alone.
    • ConstraintContext.resolutionPath is now derived per read from resolutionStack — contents are identical, but it is no longer the same array object across reads.
  • #708 801c749 Thanks @thevuong! - A dependency slot that carries a name or a tag no longer rebuilds its ResolveOptions on every hop. The criteria are fixed when the slot is declared, so the derived options are too; they are now built once and memoized on the slot itself, which is sound to share across containers because they derive from the slot alone. A slot carrying no criterion answers from its two fields without calling the builder at all, so the common shape never reaches the memo. A frozen slot — which a custom MetadataReader may hand out — keeps rebuilding rather than throwing.

    The memoized object is frozen, because sharing it has a consequence: a constraint predicate is handed it as currentResolveOptions, and ResolveOptions declares mutable fields, so a write through that reference would rewrite what the dependency asks for on every later resolve. Frozen, the attempt throws where it is made. Paired A/B over six rows and twelve passes puts the freeze inside noise — the control that cannot be affected by it moved as much as the row that can.

    resolveOptionsForSlot and the DependencySlot type are exported, so the memoizing form is reachable and the slot it takes has a name a consumer can write down.

    This is an allocation change, not a throughput one, and the distinction is worth stating because only one lane ever paid. A compiled plan already derives a criteria-carrying param's options at compile time and captures them in its escape thunk; the interpreted path had no such moment, so it minted an options object per hop, per resolve. Counted as scavenges per 2M resolves under a 1 MB young generation (pnpm --filter @codefast/benchmark-di-inversify instrument:alloc), a four-named-dependency class whose plan is declined went 870 → 442, landing exactly on the criteria-free control's 443, while the compiled lane sat at 1260 on both builds. A paired benchmark A/B across all 65 rows reads flat — correctly, since none of them injected a criteria-carrying dependency until two rows were added for the lane.

  • #690 3bcb204 Thanks @thevuong! - src/ is reorganised by dependency direction, temperature and lane. Breaking for deep subpath imports only — the root entry @codefast/di and the four @codefast/di/graph-adapters/* specifiers are unchanged, and nothing else in this repo imported a deep specifier.

    • core/ now holds the model — token, types, constructor-type, binding, binding-scope, registry, module — instead of sitting loose beside index.ts, so the layering the architecture test enforces is visible in the tree rather than only in prose.
    • errors/ separates the taxonomy from its diagnostics. The hot path imports error constructors and nothing else; message building belongs behind the throw, which is what the measured cost of a deeper throw site already said.
    • injection/ is new, and it closes a real inversion: core/binding.ts and metadata/metadata-types.ts imported InjectionDescriptor from decorators/inject.ts — the model depending on a decorator module, which passed the layering test only because the imports are type-only. The descriptor, its normalisers and the two pure builders (optional, injectAll) now live at the model layer; decorators/inject.ts keeps the one symbol that is actually a decorator. resolve-options moves alongside, so DependencySlot and the descriptor it derives from are in one place.
    • ambient/ takes the module-global active container out of resolution/environment.ts, which had been carrying three unrelated jobs. The remainder is renamed resolution/context.ts, which is what it is.
    • lifecycle/ promotes LifecycleManager and ScopeManager out of resolution/, and resolution/{cache,path,plan,select}/ groups the engine's collaborators by the lane each one serves.

    Renamed specifiers: ./binding, ./constructor-type, ./module, ./registry, ./token, ./types → ./core/*; ./errors → ./errors/errors; ./resolution/binding-scope → ./core/binding-scope; ./resolution/diagnostics → ./errors/diagnostics; ./resolution/lifecycle → ./lifecycle/lifecycle-manager; ./resolution/scope → ./lifecycle/scope-manager; ./resolution/resolve-options → ./injection/resolve-options; ./resolution/{activation-need,binding-lookup-cache,class-introspector} → ./resolution/cache/*; ./resolution/resolution-path → ./resolution/path/resolution-path; ./resolution/instantiation-plan → ./resolution/plan/instantiation-plan; ./resolution/{binding-select,constraints} → ./resolution/select/*; ./resolution/environment → ./resolution/context.

    No behaviour changes. Measured as a paired, alternating, per-scenario A/B against the pre-move build over thirteen scenarios: every median inside the A/A control's own spread, and the one row that looked down at three passes (fan-out-tree-depth-3-breadth-4, 0.966) came back at 0.993 over seven passes against an A/A median of 0.991 on the same row.

    tests/unit/architecture.test.ts gains a check that ARCHITECTURE.md's backticked tests/… citations point at files that exist — the existing link check only saw ](src/…) links, so a moved test file could invalidate a citation silently.

    Three re-declared types are now derived, which is structurally identical and breaks nothing: buildResolutionFrame's slot parameter is ResolutionFrame["slot"] rather than a hand-written shape that had lost both readonly modifiers, inlined BindingTag's definition and dropped its tuple labels; injectionSlotToResolveOptions takes Pick<DependencySlot, "name" | "tags">; and the descriptor's tags no longer carries any from PropertyDescriptor.value into a cast that looked checked.

  • #688 c3403a0 Thanks @thevuong! - A resolve whose tags match several bindings now takes the one declaring the most tags, instead of throwing AmbiguousBindingError.

    Tags on a binding are its own conditions, not a filter the request must match exactly, so naming more tags satisfies more bindings rather than fewer. Given whenTagged(Fuel.of("petrol")) and a specialisation whenTagged(Fuel.of("petrol")).whenTagged(Size.of("v8")), a request for {fuel} skipped the specialisation — it also requires size — and a request for {fuel, size} satisfied both and was ambiguous. No request reached the specialisation at all, so declaring one was pointless.

    That is the dispatch model, the same one routing, media queries and overload resolution use, and every one of those pairs it with a most-specific-wins rule for exactly this reason. This adds the rule that was missing: a candidate declaring more tags than every other is the more specific match. {fuel} now resolves the general binding and {fuel, size} the specialisation, which is what both the filter reading and the dispatch reading of tags predict.

    Selection order is predicate first, then tag count, then throw. Predicate keeps its precedence because it is the older rule and re-ordering would re-decide resolutions that already succeed; with this order, every call that resolved before resolves to the same binding, and only calls that previously threw can now return. An equal tag count is still genuinely ambiguous — {fuel:petrol} against {size:v8} with both tags requested has no more specific side — and resolveAll is untouched, since specificity only applies where one binding must be chosen.

    The new comparison sits on the branch that used to throw, so no successful resolve reaches it. Paired A/B over three passes, alternating order, per-scenario isolation: the four rows whose requests reach candidate selection and two controls all land in parity, 0.986×–1.040× against a control spread of 0.993×–1.018×.

  • #691 02ea054 Thanks @thevuong! - Replace the [string, unknown] tag tuple with a tag() factory whose criteria are interned.

    A tag key is declared once and mints its own criteria:

    const Region = tag<"eu" | "us">("region");
    container.bind(Storage).to(S3).whenTagged(Region.of("eu"));
    container.resolve(Storage, { tag: Region.of("eu") });

    The value type is now checked at both ends, so a bind site and a resolve site cannot drift apart — previously the key was a bare string and the value was unknown, and a typo was a runtime NoMatchingBindingError rather than a compile error. whenTagged takes the criterion instead of (key, value), which makes it the same shape a request carries.

    This is not a compatible change: BindingTag is an interned, branded object rather than a tuple, and nothing outside TagKey.of() can construct one. whenTagged, whenParentTagged, whenAnyAncestorTagged, whenParentTaggedAll, whenAnyAncestorTaggedAll, ResolveOptions.tag/tags and InjectOptions.tag/tags all take criteria now.

    Interning is what pays for it, and it pays twice:

    • The registry indexes tagged bindings by the criterion, not by key-then-value, which removes a hash level and — because equal criteria are one object — makes the index exact. The value re-check that existed only to correct a Map's SameValueZero treatment of ±0 is gone; the intern cache splits those two under a private symbol instead, so Object.is (SPEC §3.5) still holds.
    • The multi-tag lane prefilters on a key mask. Each key carries a bit, each slot and request the OR of theirs, so a slot whose keys the request does not cover is rejected by one AND and one compare before any criterion is read. Bits wrap every 32 keys; a shared bit is a false positive identity then rejects, never a false negative.

    Measured with di:bench:isolate against the previous build, @codefast/di hz/op:

    Row Before After
    slot-tag-miss-optional 13.3M 19.8M (+48%)
    slot-tag-shorthand-hoisted 38.9M 49.8M (+28%)
    tagged-binding-resolve 38.1M 48.4M (+27%)
    multi-tag-slot-resolve 10.9M 13.8M (+27%)
    slot-tag-resolve-all 38.2M 46.5M (+22%)
    multi-tag-constraint-resolve 6.9M 8.1M (+18%)

    Five control rows the change does not touch moved between −3.3% and +3.0%, and the head-to-head aggregate held at 44 wins / 0 parity / 0 losses against inversify 8.2.3. Three new mask-* rows price the prefilter directly: reject-heavy, admit-then-decide, and the shared-bit collision.

    One thing interning did not buy: an inline Region.of(v) is still slower than a hoisted criterion (+2–3% against +25–28%), because .of() reads the intern map on every call. The gap between the inline and hoisted rows widened rather than closed.

Patch Changes

  • #727 09e85b8 Thanks @thevuong! - Fix a batch of correctness bugs found by a full engine audit:

    • Subclass decorators no longer pollute the base class's metadata: defining a decorated subclass used to make the base unresolvable and silently dropped the subclass's own hooks and accessors. Lifecycle and accessor metadata now aggregate over the base chain (postConstruct base-first, preDestroy derived-first); constructor metadata stays opt-in per class.
    • A dependency cycle through an @inject accessor now throws CircularDependencyError instead of overflowing the stack.
    • A held fluent chain refined after later registry writes can no longer undo an unbind or destroy a newer binding; a scope refinement evicts the instance cached under the old scope, and a re-slot no longer double-deactivates or re-runs the factory of a cached undefined singleton.
    • resolveAll/resolveAllAsync materialize a parent-owned singleton at the parent, so a child override no longer leaks into the shared instance and child.dispose() no longer destroys it.
    • Concurrent async resolves of one scoped binding construct one instance; a sync resolve during an in-flight async singleton materialization refuses with AsyncResolutionError instead of silently double-constructing.
    • Container-level activation hooks fire per the binding's owner, matching the SPEC, including through compiled plans.
    • Teardown survives throwing hooks (every remaining hook still runs, failures are reported), drains in-flight async materializations, deactivates dependents before dependencies, and a disposed parent no longer serves or re-materializes singletons through live children. A module whose setup throws rolls back, so a retry load works.
    • DOT/Mermaid graph adapters escape token names; has()/hasOwn() answer ambiguity with true; binding snapshots no longer alias live registry state; NoMatchingBindingError survives bigint and circular tag values; the verifying metadata reader checks lifecycle and accessor answers; resolveOptional evaluates when() predicates once (measurably faster on the hit path).
  • #727 7a103f0 Thanks @thevuong! - Let a child container compile an instantiation plan for a transient class binding its parent owns. The plan compiler refuses to decide until a runtime resolve has read the class's lifecycle metadata, but that discovery was recorded on the owner's introspector while the compiler consulted the introspector of whichever resolver was doing the resolving. Where those differ, the child's answer stayed unknown forever: it recompiled the plan on every single resolve and threw the result away each time, falling back to the interpreted path. The resolving side now settles its own answer after instantiating a class binding another container owns.

    Measured against benchmarks/di-inversify (fast profile, isolated, best-of-3 per side): median 1.003×, geomean 1.005× over 103 scenarios, with no reproducible regression on a path the change touches.

  • #727 27020d1 Thanks @thevuong! - perf(di): make token binding lists copy-on-write so selection drops its snapshot

    The registry now replaces a token's binding list on add/removeById instead of splicing it in place, so a selection walking the list while a when() predicate rebinds the token keeps its own pre-mutation array by construction. The defensive per-selection copy in binding selection is gone — predicate-bearing resolveAll fan-outs no longer pay an allocation per call. Observable behavior is unchanged: the mid-selection-rebind pin test passes as written.

  • #727 7feb085 Thanks @thevuong! - Key the resolution-path cycle guard on binding identity instead of token display names. Two distinct tokens created with the same name (say, two token("Config") from different modules) on one dependency chain used to throw a false CircularDependencyError for a legitimately acyclic graph — on the sync lane, on the deep-path membership set, and on the async branch lane alike. The guard now compares binding ids read off the resolution frame stack, which moves in lockstep with the path; the display-name array is kept solely for the error message, so a real cycle still reports the same readable chain.

  • #727 e337290 Thanks @thevuong! - Warm constants whose only activation handler is container-level during initializeAsync(). A toConstantValue binding carrying a per-binding onActivation was already resolved and cached by the warm-up, but one whose hook was registered through container.onActivation(token, …) was skipped — so the hook first ran on whichever request happened to resolve the token, exactly the lazy-init latency initializeAsync() exists to remove. The skip now tests both hook channels, matching how the resolver's own plain-constant fast path decides the same question.

    Also tightens isSyncModule(), which read a brand field directly and so returned undefined rather than false for an async module despite declaring a boolean type predicate, and corrects the Promise type assertion on the parent-owned singleton lane of resolveAsync.

  • #727 839efbb Thanks @thevuong! - perf(di): index multi-tag slots by their first criterion for subset selection

    A name-less multi-tag resolve over a wide variant set no longer scans the token's whole binding list: multi-tag slots are bucketed under their first criterion, and since a matching slot's every tag is in the request, walking the request's buckets (plus the single-tag index) finds each candidate exactly once — no dedup set, no extra per-resolve allocation. The lane engages only past a size threshold on the token's list (under it the generic scan is cheaper) and serves resolve only, so resolveAll keeps its result order. Selection semantics — subsets, specificity, predicates, ambiguity — are unchanged.

  • #727 5d5fe67 Thanks @thevuong! - Trim the resolveAll selection path so it fits V8's cumulative inlining budget. The chain from resolveAll down to the per-candidate resolve summed to roughly 1040 bytes of bytecode against a 920-byte budget, so TurboFan stopped inlining partway through it. Three changes cut about 100 of those bytes without changing behaviour: filterBindings takes the slot-match decision from its caller instead of deriving it, the per-candidate predicate is read once rather than twice, and building a non-root ConstraintContext moved to its own function so a selection inlines the test and not the object literal.

    Measured against benchmarks/di-inversify (default isolated profile, source-swapped, six alternating paired passes, median per row): production-event-bus-dispatch 1.058×, resolve-all-strategies-10 1.045×, resolve-all-strategies-100 1.034×. constant-resolve and resolve-all-named-64 read slightly under parity, both on rows fast enough that the suite's own guidance says not to read them alone; neither is on the path this touches.

  • #688 6dcb736 Thanks @thevuong! - resolve(token, { tag: pair }) now reaches the registry's tagged index, which its own documentation had always claimed it did. It never had: singleTagOnlyOf treated the presence of tag as a reason to give up on the fast lane, so the shorthand — the form README reaches for and ResolveOptions advertised as the fast one — was the only spelling excluded from it, and fell through to full candidate selection instead. Results were never wrong, only slower. Measured paired against the previous build, alternating order, five passes, medians: 2.42× on a single-tag resolve with the pair hoisted and 2.38× with it written inline, every pass agreeing; { tags: [pair] } and the tagged-binding-resolve row hold at parity, controls clean.

    A request that carries a tag from both sources at once still declines the index, because two tags requested is not something a one-tag index can answer without skipping the ambiguity check the full path would have run.

    SPEC §3.5 now states the rule this fixes as normative — a fast path serving tags: [pair] must serve tag: pair — so the two spellings cannot drift into different lanes again, and tests/unit/resolution/tag-shorthand-parity.test.ts pins both the lane and the result equality across the value kinds Object.is and a Map's SameValueZero disagree on. ResolveOptions.tag and the README also stop implying the two forms differ in speed, and say what actually distinguishes them: only tags expresses more than one tag, and only tags exists on InjectOptions.

  • #688 bbc111b Thanks @thevuong! - resolveAll(token, { tag }) reads the tagged index instead of scanning every binding under the token. resolveAll has had a fast lane for a name-only request since the name index existed; the identical shape for a one-tag request was missing, for a reason that had expired. simpleTagOf kept predicate-bearing bindings out of the tag index, justified by the index being "read without a re-check" — which stopped being true when the ±0 fix gave every indexed hit a re-check. With the premise gone the exclusion was vestigial, and it was the only thing keeping resolveAll off the index.

    Both lanes that read the index now evaluate the predicate on what they find, exactly as the name lane always has, so an indexed hit whose when() refuses still cannot reach a caller.

    Worth 1.72× on a resolveAll over a tagged token, nine passes and every one positive, landing that row at the throughput the equivalent name lane already had. Single-tag resolve, the name lanes, and the sync controls hold.

    One row moves the other way and is recorded rather than explained: resolveAll with no options over a hundred pure-predicate bindings measures 0.95×, nine passes inside a one-percent spread. It has no causal path — that request leaves candidate selection at its first test and never reaches the index or any new code — and the obvious remedy, keeping the caller its original size, did not move it. It reads as a code-layout effect; the experiment that would confirm that has not been run yet.

  • #727 bda71f7 Thanks @thevuong! - Stop a deep synchronous resolution from reporting a circular dependency that is not there. The membership set the resolution path attaches past RESOLUTION_SET_THRESHOLD is seeded from the path, and the frames already on it are handed no set to delete from on unwind — so the set outlived the resolve it was built for, on an array the resolver reuses. A graph resolved on the interpreted path deeper than that threshold answered its first resolve and threw CircularDependencyError on every later one, and a sibling branch below the attach depth threw within a single resolve. The set is now dropped as soon as it stops mirroring the path, and rebuilt by the next frame that needs one.

0.5.0

Minor Changes

  • #677 7fd9ba8 Thanks @thevuong! - Detect async cycles from the synchronous factory cascade instead of a settle-scoped path, and escape to a per-branch path only where the cascade cannot see.

    A factory asks for its dependencies from its synchronous prefix — async ctx => await ctx.resolveAsync(dep) calls resolveAsync before it awaits anything — so an eight-level chain is built inside one synchronous cascade before any of it settles, and the chain of who-is-resolving-whom at the moment of a request is the call stack itself. While that cascade is open the resolver's own arrays are the ancestor chain, pushed on factory-enter and popped when the factory returns its promise rather than when that promise settles. Two cascades cannot interleave, so binding.inFlight is exact path membership for async too, exactly as it already was for sync. Every level shares one context; nothing is allocated per level and no level observes its own settlement.

    This fixes a false CircularDependencyError. A diamond — A awaiting B and C in parallel, both needing D — rejected with Circular dependency detected: a → b → d → c → d, a path in which b → d → c is not a dependency edge at all. D's flag is now clear by the time the second sibling asks for it.

    A request made from a continuation, after an await, has its ancestors on no call stack. It arrives with the cascade empty — an exact test, since a continuation never runs inside one — and escapes to a branch lane whose path is append-only: a level appends while its branch still owns the next slot and copies its own prefix once a sibling has claimed it. Anything the cascade lane does not serve escapes the same way, seeded with a snapshot of the ancestors reached so far, and a subtree that has left the cascade stays off it. A cycle formed entirely from post-await edges is still reported, one level in from the true root, because the ancestors before the first escape were never written down; resolver-async.test.ts pins that message.

    Measured with BENCH_ISOLATE=1 BENCH_FULL=1, libraries interleaved with rotating order, 3 trials: against inversify 8.2.3 the suite goes from 42 / 0 / 1 to 43 / 0 / 0 — the async chain row this library had always lost now reads 1.60× where it read 0.75×, and the async group's geomean goes 1.13× to 1.58×. Per-level overhead against a floor of eight plain awaited async functions falls from 48.3 ns to 19.5 ns with the collector idle and 21.3 ns with a full GC forced every 100 samples — the lane is now within ~7 ns of a build carrying no cycle bookkeeping at all, and it is GC-insensitive again.

    A paired A/B of the two builds, five passes alternating which side ran first, holds all seventeen measured sync rows at parity (0.99–1.08× medians, no row negative across every pass), including circular-dependency-3, which shares the binding.inFlight flag the cascade now uses. That A/B is also what caught a regression the suite reported as a win: a materialized async singleton did not match the cascade lane and escaped, snapshotting both cascade arrays on every resolve, for 0.81× of the previous build across all five passes. The cascade entry now answers a plain constant and a cached singleton itself.

    ARCHITECTURE.md records the two shapes tried before this one, including the one that fixed the same bug and measured worse, and why the sync lane's compiled-plan answer does not port to async.

    ResolutionDiagnostics no longer carries asyncContextPoolSize, since there is no async context pool to report.

  • #646 0093b99 Thanks @thevuong! - Build a container's rarely-used collaborators on first use instead of in its constructor: the inspector, the module ref/binding tables, the scope's in-flight and scoped caches, the registry's named and tagged slot indexes, and the class introspector's metadata caches. A container that only binds and resolves — the common case, and every per-request child container — no longer allocates eleven Maps it never reads.

    A fresh Container.create() retains 2.7 KB instead of 4.8 KB (43% lighter), and parent.createChild() the same, measured by retention against a forced collection. Every deferred collaborator behaves identically whether or not something touched it first, which tests/unit/container/deferred-subsystems.test.ts pins by exercising each one as the first thing a fresh container does.

    It is a throughput win too, on the paths that actually build containers: Container.create() is 1.80× faster (230 ns → 127 ns) and a per-request child container plus a resolve through it — createChild() + resolve, the shape a web app runs once per request — is 1.31× faster. Measured by an interleaved A/B with both builds loaded into one process, 13 trials in alternating order, against a control scenario that resolves from a pre-built container and so cannot benefit; the control sat at 0.997–1.009, and median and best-of agreed on both figures.

    Breaking: ScopeManager.getAllScoped() is removed from the ./resolution/scope subpath. Deferring the scoped cache raised the question of what a bulk reader returns when the cache was never allocated, and this reader had no callers anywhere in the package — so it is gone rather than carrying an empty-map fallback for nobody. getAllSingletons() is unaffected; its cache is still eager.

    What it does not do is close the realistic-graph-cold-resolve loss against tsyringe, and the arithmetic says why: 103 ns off container construction is 2.5% of that row's 4.06 µs iteration, so the row moves ~1.5% — measured, and inside the noise floor. That row's gap is GC-attributable (the two libraries are at mutator parity; di only loses once a forced collection is in the loop), so a 13.7% allocation cut was never going to carry it.

  • #677 7fd9ba8 Thanks @thevuong! - Breaking: effectiveBindingScope is no longer exported from the package root. It read a Binding, which package.json#exports deliberately withholds, and no public API ever handed one out — so it was exported and impossible to call. Read a binding's scope from BindingSnapshot.scope (container.lookupBindings() / container.inspect()) or from GraphNode.scope (container.generateDependencyGraph()), both of which have always carried it.

    bindingSlotToResolveOptions now takes its slot structurally, so the slot on a public BindingSnapshot — where name is an optional property rather than a required one holding undefined — is accepted. Passing a BindingSlot-shaped literal keeps working.

    A type test now asserts each exported function is callable with values a consumer can actually obtain from the package's own exports, which is what neither of these satisfied.

  • #646 de80bad Thanks @thevuong! - Publish an intentional export surface: 13 subpaths instead of 36. The engine's collaborators — resolution/*, registry, container/*, binding, constructor-type, and the metadata internals — are no longer entry points. They carry the invariants documented in ARCHITECTURE.md, and publishing them meant every internal refactor was technically a breaking change. Everything a consumer needs stays reachable from the root export, which already re-exports the builder interfaces, Constructor, MetadataReader, effectiveBindingScope, and the resolve-options helpers.

    This also repairs a silent break. The surface was generated from dist/, so reorganising src/ into container/, resolution/ and introspection/ renamed twelve already-published entry points — ./inspector → ./introspection/inspector, ./dependency-graph → ./introspection/dependency-graph, ./graph-adapters/* → ./introspection/graph-adapters/*, ./container → ./container/container, and the whole flat ./resolver/./scope/./lifecycle/./environment/./constraints/./binding-select/./binding-scope/./resolve-options set — with no changeset saying so. The consumer-facing ones (./inspector, ./dependency-graph, ./graph-adapters/*) are back at the specifiers they shipped under; examples/tanstack-start imports two of them and would have broken on its next upgrade.

    Breaking: the internal subpaths listed above are gone. Import from the package root instead.

  • 4f7a188 Thanks @thevuong! - Rename the hint resolve parameter to options throughout — "hint" implied optional guidance the container may ignore, but the value is a hard selection criterion (resolve throws NoMatchingBindingError when nothing matches), so the name misstated its role. Positional call sites are unaffected; the one breaking surface is NoMatchingBindingError.hint, now NoMatchingBindingError.options.

  • ad11507 Thanks @thevuong! - Reorganize the source tree into subsystem folders — container/ (container + the extracted fluent binding builders), resolution/ (resolver, scope, lifecycle, environment, selection/constraints, and the extracted cycle-guard module), and introspection/ (inspector, dependency graph, and the graph adapters). The root entry keeps exporting everything and now also exports the graph adapters (toDotGraph, toCytoscapeGraph, toReactFlowGraph and their types), so import { toReactFlowGraph } from "@codefast/di" is the preferred path.

    Breaking (0.x minor): the @codefast/di/graph-adapters/* subpaths are removed — import the adapters from the root entry or from @codefast/di/introspection/graph-adapters/*. Deep subpaths of other moved modules follow the new folders (e.g. @codefast/di/resolver → @codefast/di/resolution/resolver).

  • 6a25788 Thanks @thevuong! - Resolver performance overhaul — the head-to-head benchmark vs InversifyJS 8 now shows 38/38 comparable scenarios won (median 1.82×, isolated mode), up from 7 losing rows. Four techniques, no public-API changes:

    • Chain-versioned lookup memo — BindingRegistry gains a monotonic mutation version; resolvers memoize options-less token → {binding, owner} lookups across the parent chain with alias hops folded to the terminal binding. Resolving a root binding from a depth-2 child (or through toAlias) is now as fast as resolving it locally.
    • Compiled resolution plans — a transient class binding whose dependency subgraph is pure static (class/constant/cached-singleton deps, no activation hooks or postConstruct) compiles once into a nested-constructor closure, cycle-checked at compile time. Anything dynamic keeps the runtime cycle guard, so error semantics are unchanged.
    • Uniform binding hidden class — Registry.add rebuilds every binding with one fixed field superset so mixed binding kinds no longer turn the resolver's hot property reads megamorphic (~30% throughput loss in processes exercising several kinds).
    • Leaner async transient path — cleanup runs as a FIFO side listener on the factory promise instead of a derived-promise chain (one less promise and one less microtask hop per level), and activated transient dynamic bindings get a dedicated lane that fetches container hooks once. Behavior note: an unawaited failing resolveAsync no longer surfaces as an unhandledRejection; await (or .catch) the returned promise.
  • #646 4ba70d1 Thanks @thevuong! - Keep a singleton's instance on its binding instead of in a per-container table. A binding belongs to exactly one container, so its singleton slot is per-binding — which turns every cached-singleton read from a keyed Map lookup into a field read, on the most common resolve shape there is. The scope manager keeps only a lazily-created list of the bindings that have materialized, so disposal and inspect() can still enumerate them, and the singleton Map is gone entirely.

    In the suite, realistic-graph-resolve-root — a transient controller over eight cached singletons — went from 10.66M to 12.19M hz/op, and the realistic group geomean from 2.24× to 2.54× of InversifyJS. That row carries a 2.9% IQR, so it is one of the numbers here worth reading precisely. singleton-class-1-dep and cold container build both moved up as well, on rows whose IQR is too wide to attribute confidently.

    An interleaved A/B against the previous build, both in one process with a control that cannot benefit, put the same row between 1.09× and 1.40× across four runs — never slower, median and best-of agreeing inside each run, but with a spread that depends on what else the process had run. The suite's figure is the one to cite; the A/B established the direction. A trap worth recording: the control first read 0.88×, which was an artifact of timing an 11 ns call one at a time — batched the way the harness does it, the same control reads 1.02× with a 4% spread.

    Breaking: ScopeManager's singleton API takes a Binding rather than a BindingIdentifier, and hasSingleton/getSingleton/peekSingleton/setSingleton(id, …)/getAllSingletons are replaced by setSingleton(binding, …), deleteSingleton(binding) and cachedSingletons(). The SINGLETON_MISS sentinel is gone; NO_INSTANCE on the binding replaces it. None of this is a published entry point any more, so it is internal — but a fork reaching into ./resolution/scope would notice. InstantiationPlanDependencyEntry also drops its ownerScope field, which a compiled thunk no longer needs.

  • #643 14c3a98 Thanks @thevuong! - container.validate() now reports a captive dependency when a singleton depends on a transient or scoped toDynamic / toDynamicAsync binding. Previously any dynamic terminal was classified opaque and its declared scope went unchecked, so the most common form of the bug — a singleton capturing one instance of something bound transient — passed validation silently.

    A factory's body remains opaque: validate() still does not descend into it, so whatever the factory resolves internally is not reported. Only the declared scope of the dependency edge is judged, which is the part the container actually knows.

    Breaking: a container that wires a singleton to a transient or scoped dynamic binding now throws ScopeViolationError from validate() where it previously passed. Either widen the dependency's scope, or inject a factory instead of the value if a fresh instance per use is intended.

Patch Changes

  • #677 7fd9ba8 Thanks @thevuong! - Make resolving through a container-level onActivation hook as cheap as resolving without one.

    A transient factory binding that carries activation hooks now takes the same O(1) binding.inFlight cycle guard as the unhooked lane — the argument for that guard never mentioned hooks, since a hook runs on the call stack the factory did — and LifecycleManager keeps a one-entry token→hooks cache in front of its map, because a resolve loop asks about the same token every iteration. Together they halve what the hook lane costs over the plain one. A hook that re-resolves its own token still reports CircularDependencyError, and the flag is still released on every exit path.

  • #677 7fd9ba8 Thanks @thevuong! - Fix three defects found by an audit of the resolution engine's memoization:

    • A .onActivation() hook added to a chain after its binding's first resolve was silently skipped on every lane that consults the activation-need memo (named resolves and nested dependency resolves) while the default-slot dynamic lane honored it. The memo now reads the binding's own hook fresh on every call, so all lanes give one answer.
    • The activation-need memo is keyed by binding id and was only invalidated by the lifecycle version, so a long-running container that rebinds in a loop grew it without bound (~60 B per rebind). The memo is now also stamped with the registry version, evicting entries whose binding ids a rebind has retired.
    • A scoped instance cached in a child container survived unbind/unbindAll/module unload — the drain released singletons only. Scoped entries are now released with their binding (no deactivation, per SPEC §5.2), and resolution diagnostics expose a scopedInstanceCount so the release is pinned structurally.

    A paired A/B against the previous build over six activation- and dispatch-sensitive rows (three passes, alternating order) held every row within noise of parity.

  • 2def688 Thanks @thevuong! - Fix a crash on cyclic aliases: bind(a).toAlias(b) + bind(b).toAlias(a) previously recursed until RangeError: Maximum call stack size exceeded on both resolve and resolveAsync. Alias following is now an iterative loop with exact revisit detection — a genuine cycle throws CircularDependencyError naming the alias chain, and legitimately long alias chains resolve with no arbitrary hop cap.

    Also splits the magic 32 that served two unrelated roles: the transient-dynamic fast lanes keep their own DEEP_LANE_THRESHOLD = 32 (a shared-context/pool design point), while the cycle-scan Set attachment moves to a measured RESOLUTION_SET_THRESHOLD = 128 — benchmarking showed Array.includes beats the Set's has/add/delete churn up to at least depth 96, so mid-depth graphs now skip the Set entirely.

  • #646 d27b76f Thanks @thevuong! - Thread an async chain's resolution context through the call and pool it, instead of parking chain identity on the resolver. ctx.resolveAsync() now hands the callee the context it used, so an inner level reuses it when the owner matches — which removes the resolver's path-identity heuristic, its shared settle callback and its active-level counter, and makes two concurrent chains correct by construction rather than by a fallback branch.

    The contexts are pooled, and that is load-bearing rather than an allocation micro-optimization: a per-chain context survives its chain's microtask hops, so under a collecting profile a freshly allocated one is promoted out of the nursery and then collected the expensive way. An ablation that allocated per chain cost 2.5× on dynamic-async-chain-8 under a forced GC every 100 samples, which is the reason for the shape.

    It does not close that row. An earlier draft of this changeset claimed it went from 0.98× to 1.18× of InversifyJS; that figure came from a probe running both library builds in one process, which this harness's README warns is worth ~30% on async chains, and from a 3-trial suite run on a loaded machine. At 5 trials on a quiet machine the row is 0.87× with a 0.6% / 0.3% IQR — among the tightest numbers in the suite. The mechanism above is real; the win over inversify was not.

    A competing hypothesis was tested and rejected: a forced full GC costs the two libraries the same (1.35 ms / 9.76 MB live for di, 1.41 ms / 9.89 MB for inversify), so the cost was never the collection but what di re-established afterwards.

  • #677 7fd9ba8 Thanks @thevuong! - Stop allocating a throwaway Map on every named resolve. The named-lookup memo upserted with getOrInsert(token, new Map()), whose fallback JavaScript evaluates eagerly — so every call built a Map for the hit that immediately discarded it. It now uses Map.prototype.getOrInsertComputed with a module-level factory, which allocates nothing on a hit and no closure per call: ~1.72× on named-constant-get, measured paired against the previous build with the order alternated.

    The bind-time upserts keep the eager form deliberately — a bind is usually a token's first, so the fallback is usually the value stored, and the computed form measured slower there.

    @codefast/di now calls Map.prototype.getOrInsertComputed as well as getOrInsert; both ship in Node 26+, which the package already required.

  • #677 7fd9ba8 Thanks @thevuong! - Put a one-entry cache in front of BindingLookupCache's options-less token map. Two shapes reach that map and neither can use the registry's direct index: an alias, whose terminal binding the index cannot name, and a token owned by a parent container, whose entry has to carry the owner. Both are then resolved in a loop over the same token, so the map lookup they repeat deserves an inline cache — the rule this package already applies to LifecycleManager.activationHandlersFor().

    Paired A/B against the previous build, seven passes alternating which side ran first, medians: to-alias-redirect 1.16× (every pass 1.15–1.18) and child-depth-2-resolve 1.23× (every pass 1.22–1.27), which were the two thinnest wins in the suite outside the lifecycle rows. rebind-hot-swap — the row that invalidates the cache on every iteration, so the only place a front cache could be pure overhead — reads 1.17×, after a five-pass run had put it at 0.88× on mixed signs; the tighter run is the one to believe.

    In the interleaved isolated suite to-alias-redirect reads 1.53× of inversify 8.2.3, up from 1.33×, which is what the paired ratio predicts. child-depth-2-resolve reads 1.14× there against 1.36× before — that row carries both of the report's instability markers (above 30M ops/s, and a per-trial IQR over 5%), its own throughput went up, and seven paired passes put it at 1.22–1.27×, so the paired number is the one that describes this change. The suite's aggregate moved from 42/0/1 to 42/1/0 at a slightly lower median on rows this change cannot reach, which is run-to-run drift rather than an effect.

    null is a real answer from that map, meaning "this token's shape needs the full selection path", so absence is tracked by the token slot rather than by the entry, and a registry-version change clears the slot along with the map.

    Folding alias hops into registry.getFastDefault() instead was considered and rejected as unsound: that index is a bare own-registry Map.get returning a binding, while an alias's terminal may live in a parent container and its invalidation depends on the whole chain's summed version, neither of which the registry can see.

  • #677 7fd9ba8 Thanks @thevuong! - Remove a type parameter the resolver could never honour. Fifteen private methods took Binding<Value> and returned Value, but every caller supplied Value through an unchecked as Binding<Value> — so the generic documented an intent the compiler never verified. The internal lanes now take the erased Binding and return unknown, and the eight public resolve entry points each cast once, where the caller's token is the claim being made. Seventeen casts fewer in the resolver.

    What made that possible: the binding kinds declare their lifecycle hooks as methods rather than function-typed properties, so their parameters compare bivariantly and Binding<Value> stays assignable to Binding. The public ActivationHandler and DeactivationHandler are unchanged and still checked strictly, so a handler you write is verified exactly as before.

    No behaviour change: the emitted JavaScript is identical apart from one line break.

  • 19199af Thanks @thevuong! - Fix binding-registration order sensitivity: the fluent builder chain commits eagerly, so bind(x).toDynamic(f).when(p) (or .whenNamed(...) / .whenTagged(...)) momentarily registered a default-slot binding whose last-wins commit silently displaced an existing default binding of the same token — and the displaced binding was never restored once the chain narrowed to a predicate or a named/tagged slot. Registering a default binding before a constrained one on the same token therefore lost the default. The commit chain now remembers what an intermediate commit displaced and restores it when the chain settles on a non-conflicting shape; a chain that genuinely ends on the same default slot still replaces the previous default (last-wins unchanged).

    Binding selection also gains a most-specific-wins rule: when both a default binding and exactly one predicate-carrying binding match, the predicate wins (it is a deliberate specialization) instead of throwing AmbiguousBindingError — so "default plus when(...) override" now works as naturally intended. Two matching predicates remain ambiguous and still throw.

  • #643 14c3a98 Thanks @thevuong! - Unify sync transient-dynamic cycle detection on a per-binding inFlight flag, replacing the shallow lane's O(depth) resolutionPath.includes() scan and the deep lane's Map of generation marks. Sync resolution runs on a single call stack, so a binding marked on factory-enter and cleared on factory-exit is exactly path membership — detection becomes an O(1) field read with no hashing, no string scan, and no side table to allocate or grow. The async lane keeps its own per-path check, since async chains can interleave.

    Transient-dynamic chains that previously lost now win across the whole depth range (a 32-deep chain went from ~0.55× to ~1.8× of InversifyJS), deep chains widen their lead, and cold container builds get cheaper because there is no per-resolver cycle-tracking structure to allocate. It also fixes a latent correctness bug: because the deep lane now clears a binding's mark when its factory returns, a deep (past-threshold) transient dependency resolved twice via separate sub-branches (a diamond, not a cycle) no longer throws a false CircularDependencyError.

  • #646 864d213 Thanks @thevuong! - Fold the fluent chain's registry committer into the chain itself. bind() now allocates a BindingEntry that carries only the to*() calls, and to*() a BindingChain that commits to the registry directly — one object per bind less than the entry/chain/committer trio, and one Map lookup less per binding registered by a module.

    The two classes now share a BindingRegistration describing where the chain registers, built once per container rather than once per bind(). Threading that instead of a loose (registry, moduleBindingIds, moduleRef) triple makes the module invariant type-enforced — the id list is present exactly when the chain belongs to a module load — which removes both non-null assertions from the commit path, and drops the constructors from 4 and 5 positional parameters to 2 and 3.

    BindingCommitter is gone and BindingEntry's constructor now takes (token, registration). Neither is a published entry point any more, so this is internal.

    This is a simplification, not a throughput win: removing only the committer measured no change above noise. The chain stays two objects because tests/unit/container/bind-to-builder-order.test.ts requires bind()'s result to lack when*() at runtime — that is the test's own guarantee, stricter than SPEC §2.4, which only claims compiler enforcement. The measured ceiling for removing every builder object is ~19% on realistic-graph-cold-resolve under a forced GC — recorded in ARCHITECTURE so the lead is not re-tried blind.

  • #677 7fd9ba8 Thanks @thevuong! - Collapse the resolver's duplicated logic onto one rule per question, and fix the two places where a second copy had drifted.

    • resolveAll(token, { name }) now evaluates a when() predicate on a named binding, as resolve always did. The name index answers the slot; the predicate is a further constraint, and the fast lane was returning a candidate resolve refuses.
    • Refining a binding's scope after its first resolve (bind(T).toDynamic(f) … later .singleton()) now reports the new scope to when() predicates that read ctx.parent.scope. The resolution frame is memoized on the binding and derives from scope, so the refinement has to drop it.

    Internally: slot matching, name-only requests, and the alias walk each exist once; a class's constructor params and a toResolved factory's descriptors resolve through one routine per lane; scope is declared by every binding kind, so the engine reads it as a plain field. No public API changed. Resolution throughput is unchanged or better across the benchmark suite — resolveOptional ~1.5×, transient class and toResolved construction ~1.35–1.44×, constants and named lookups ~1.3×, with the deep/wide graph rows at parity.

  • #646 d27b76f Thanks @thevuong! - Compile instantiation plans around dependencies the compiler cannot see through, instead of refusing to compile the graph at all. A factory, a scoped binding, an activation hook, a class past the depth limit, or a multi/optional/named parameter now compiles to an escape — a re-entry into the runtime resolver seeded with exactly the ancestors the interpreted path would have pushed at that point, dispatched through exactly the resolve the interpreter would have called. Cycle detection, constraint contexts and error paths are therefore identical to never having compiled, and only the opaque dependency pays the runtime price while its siblings and ancestors stay compiled.

    Previously a single toDynamic dependency anywhere in a class graph dropped the whole graph to the interpreted path — a 13.9× cliff on a shape applications write constantly (a factory-provided config injected into a class tree). That graph is now ~2× faster, and the first-materialization path of a singleton dependency inside a plan gained cycle detection it did not have.

  • #646 d27b76f Thanks @thevuong! - Split the resolver's self-contained caches into named collaborators — BindingLookupCache (the chain-versioned options-less lookup memo), ClassIntrospector (per-class metadata, @postConstruct discovery, accessor injection, instantiation) and ActivationNeedCache (per-binding activation need, versioned on the lifecycle manager). The engine class keeps the sync and async pipelines, which genuinely need the same private state on every hop, and ARCHITECTURE.md now records the layering, the invariants each hot path depends on, and the rule that separates a legitimate threshold (choosing an implementation) from the kind that was removed (choosing a semantics).

    New subpaths @codefast/di/resolution/{activation-need,binding-lookup-cache,class-introspector}; @codefast/di/resolution/class-plan is now @codefast/di/resolution/instantiation-plan, correcting an export map that had been stale since the module was renamed.

  • f9aeeb0 Thanks @thevuong! - Extend the compiled-plan and memoization coverage: toResolved(...) transient bindings with pure-static explicit deps now compile into factory-call plans (same refusal rules and sync-only check as class plans), and name-only resolves gain a chain-versioned memo that fast-paths constants and cached singletons — predicates, aliases, and anything context-dependent keep the full selection path. Measured: named-constant-get ~21M → ~30M hz/op, to-resolved-3-deps ~39M → ~52M hz/op.

  • #677 7fd9ba8 Thanks @thevuong! - Stop minting two arrays per top-level sync resolve, and stop a pooled resolution context re-storing pointers it already holds.

    --prof over the four thinnest rows put the largest di-attributed cost in a place none of this package's notes mention: #acquireSyncResolutionContext and DefaultResolutionContext.reset() together take 22% of ticks on fan-out-tree-depth-3-breadth-4 and 16% on scale-deep-transient-chain-512, and reset() alone takes 10% on container-level-activation-hook. The reason reset() is not free is that a pooled context outlives enough resolves to sit in old space, so each of its five field writes is a pointer store with a write barrier — and three of the five write the same resolver and the same two arrays every time.

    Except they did not, because container.resolve() handed every call a fresh [] pair. So both halves are needed together: a resolver now keeps one sync rootPath/rootStack pair, lent to a top-level resolve when rootStack.length === 0 and otherwise replaced by a fresh pair, and reset() compares before storing. Every sync lane pops what it pushes, so an empty stack is an exact "nobody holds this"; a nested container.resolve() from inside a factory still starts from an empty path, and if a resolve ever left the pair dirty the only consequence is that later resolves mint their own.

    Paired A/B against this commit's parent, six passes alternating which side ran first: constant-resolve 1.70×, container-level-activation-hook 1.67×, realistic-graph-resolve-root 1.34×, fan-out-tree-depth-3-breadth-4 1.28×, scale-deep-transient-chain-512 1.21×, scale-mid-transient-chain-32 1.16×, singleton-class-1-dep 1.13×, to-alias-redirect 1.09×, and dynamic-async-chain-8 0.99× as the untouched control.

    transient-class-1-dep reads 0.91×, negative in all six passes, and the mechanism is the same one that wins the other rows: a fresh array is in new space, so pushing a frame onto it needs no write barrier, while the shared pair is in old space and every push pays one. That row pushes a frame and does nothing else, so it is the one shape where the barrier costs more than the two allocations saved. Kept because it is one row at −9% against five between +21% and +70%.

    tests/unit/resolution/in-flight-invariants.test.ts pins the lending rule in both directions — a nested root resolve gets its own pair, and a throwing resolve hands the pair back — and both were checked by breaking the guard.

  • #677 7fd9ba8 Thanks @thevuong! - toSelf() on a token that is not a class now throws SelfBindingRequiresClassError instead of a bare Error, so it is catchable as a DiError like every other failure this package raises, carries a code and the token name, and is documented in SPEC.

    It was the one throw site outside the error taxonomy, and the architecture test could not see it — that test only read export class …Error declarations. It now also fails on any throw new Error(…) under src/, and on an error class the root barrel forgets to export.

  • #643 14c3a98 Thanks @thevuong! - Collapse the async transient-dynamic resolver into a single lane and retune the cycle-set threshold.

    The async lane used to split at depth 32 into a fast path (linear path scan, shared context, no stack frames) and a slow path (forced Set, fresh context per level, stack frames, extra microtask hop) — so context identity, ctx.graph contents, and promise shape all changed silently at that depth. Both are now one lane whose cycle guard goes through enterResolutionPath, the only mechanism that stays correct when chains interleave (Promise.all) and which adapts on its own: a linear scan while the path is short, an attached Set past RESOLUTION_SET_THRESHOLD. DEEP_LANE_THRESHOLD is gone from the package entirely.

    RESOLUTION_SET_THRESHOLD drops from 128 to 32 on fresh measurements — at 128 an async chain costs 1275 / 3641 / 9645 / 26082 ns at depth 16 / 32 / 64 / 128 versus 1202 / 3285 / 7735 / 16837 at 32, so the old value was the worse choice at every depth measured.

  • #646 a720c62 Thanks @thevuong! - Collapse the fluent binding chain into one object. A single BindingChain is now the BindToBuilder that bind() returns and the kind-specific builder that to*() returns, so a bind() allocates one builder instead of two.

    The to*()-before-when*() ordering stays enforced, as a type-level guarantee — which is what SPEC §2.4 actually claims. bind() is typed BindToBuilder, so a refinement before to*() does not compile; tests/types/container-api.test.ts pins that. For a caller without types, or one who casts past them, every refinement now throws the new ChainNotRegisteredError naming the token and pointing at to*(), rather than silently doing nothing. whenDefault() asserts registration for that reason alone, since it otherwise has nothing to do.

    The previous revision kept two objects because a unit test asserted the refinement methods were absent from the object bind() returns — a stricter reading than the spec, and one that pinned an implementation detail. That test now asserts the contract instead: every refinement throws before to*(), nothing is registered when it does, and the chain still works normally afterwards.

    This is an API simplification, not a throughput win: going from four builder objects per bind to three measured no change above noise, and a fluent API cannot go below one, so the ~19% ceiling recorded in ARCHITECTURE for removing all of them is unreachable rather than pending.

  • #646 1241f82 Thanks @thevuong! - Register a fluent binding chain once instead of once per refinement. bind(T).toDynamic(f).singleton() used to insert a binding, remove it, and insert a replacement — two registry mutations, two version bumps, and a full index churn per binding. The chain now registers on its to*() call and refines that same registered object in place; only when*() re-slots, and it re-registers under the chain's original id, so id() stays valid for the whole chain instead of the intermediate ids being dead. Binding construction also funnels through a single createBinding() literal, which is what guarantees the one V8 hidden class the resolver's hot property reads depend on — so the registry stores what it is handed rather than re-copying it.

    Cold container build (build, bind 10 nodes, resolve the root) went from the suite's only loss to a win against every competitor: 0.76× → 3.0× of InversifyJS, 0.43× → 1.9× of Awilix, 0.22× → 1.07× of tsyringe.

    The builder's CommitFn type is replaced by a BindingCommitter interface (commit plus refine, the latter for in-place refinements the registry indexes do not care about), and createBinding / refinableFields are new exports from @codefast/di/binding.

  • #643 14c3a98 Thanks @thevuong! - Collapse the sync transient-dynamic resolver into a single lane and memoize each binding's resolution frame on the binding itself.

    The separate deep lane existed to escape an O(depth) resolutionPath.includes() cycle scan past ~32 levels. With cycle detection now an O(1) binding.inFlight mark there is nothing to escape, so the depth split, its shared-context bookkeeping, its reentrancy fallback, and the per-resolver frame Map are all gone — the smaller function also inlines better. Frames derive only from immutable binding fields, so caching one per binding replaces a Map lookup per hop and a Map insert per binding per container.

    Faster at every chain depth measured (8 → 512), e.g. a 32-deep transient chain improved ~39% and cold container build ~62%, which turns the cold-build result against Awilix from a loss into a win in the default benchmark profile.

  • #677 7fd9ba8 Thanks @thevuong! - Fix a tag request answering differently depending on how it was spelled. resolve(T, { tags: [["n", -0]] }) matched a binding tagged ["n", 0] while resolve(T, { tag: ["n", -0] }) threw NoMatchingBindingError and resolveAll returned [] — three answers to one question.

    The registry indexes tagged bindings in a Map, so it answers by SameValueZero, while tag values compare by Object.is as SPEC §3.5 requires; the two differ on +0 versus -0. The fast path now re-checks the index's answer, and only where the index can be wrong — a request whose tag value is not zero was already exact. NaN was never affected: both rules treat it as equal to itself.

  • #643 14c3a98 Thanks @thevuong! - toResolved() and toResolvedAsync() now accept injection descriptors — inject(), optional() and injectAll() — in their dependency list, matching what @injectable([...]) already allowed and what the builder already did at runtime (it normalizes every entry through normalizeToDescriptor). Previously the public signature only admitted bare tokens and constructors, so an optional or multi dependency needed a cast even though resolution handled it correctly.

    Factory arguments are typed from the descriptor: a bare token gives Value, optional(token) gives Value | undefined, and injectAll(token) gives Array<Value>. Widening only — existing bare-token call sites are unaffected.

  • #643 14c3a98 Thanks @thevuong! - unbind(token) now drops the token's bindings in a single registry pass instead of removing them one id at a time. The previous path re-scanned and re-indexed the token's binding list once per binding — quadratic in the number of slots bound to that token — and bumped the registry version once per removal, invalidating resolver lookup caches repeatedly. Behaviour is unchanged, including deactivation of cached singletons.

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

0.5.0-canary.9

Minor Changes

  • #677 7fd9ba8 Thanks @thevuong! - Detect async cycles from the synchronous factory cascade instead of a settle-scoped path, and escape to a per-branch path only where the cascade cannot see.

    A factory asks for its dependencies from its synchronous prefix — async ctx => await ctx.resolveAsync(dep) calls resolveAsync before it awaits anything — so an eight-level chain is built inside one synchronous cascade before any of it settles, and the chain of who-is-resolving-whom at the moment of a request is the call stack itself. While that cascade is open the resolver's own arrays are the ancestor chain, pushed on factory-enter and popped when the factory returns its promise rather than when that promise settles. Two cascades cannot interleave, so binding.inFlight is exact path membership for async too, exactly as it already was for sync. Every level shares one context; nothing is allocated per level and no level observes its own settlement.

    This fixes a false CircularDependencyError. A diamond — A awaiting B and C in parallel, both needing D — rejected with Circular dependency detected: a → b → d → c → d, a path in which b → d → c is not a dependency edge at all. D's flag is now clear by the time the second sibling asks for it.

    A request made from a continuation, after an await, has its ancestors on no call stack. It arrives with the cascade empty — an exact test, since a continuation never runs inside one — and escapes to a branch lane whose path is append-only: a level appends while its branch still owns the next slot and copies its own prefix once a sibling has claimed it. Anything the cascade lane does not serve escapes the same way, seeded with a snapshot of the ancestors reached so far, and a subtree that has left the cascade stays off it. A cycle formed entirely from post-await edges is still reported, one level in from the true root, because the ancestors before the first escape were never written down; resolver-async.test.ts pins that message.

    Measured with BENCH_ISOLATE=1 BENCH_FULL=1, libraries interleaved with rotating order, 3 trials: against inversify 8.2.3 the suite goes from 42 / 0 / 1 to 43 / 0 / 0 — the async chain row this library had always lost now reads 1.60× where it read 0.75×, and the async group's geomean goes 1.13× to 1.58×. Per-level overhead against a floor of eight plain awaited async functions falls from 48.3 ns to 19.5 ns with the collector idle and 21.3 ns with a full GC forced every 100 samples — the lane is now within ~7 ns of a build carrying no cycle bookkeeping at all, and it is GC-insensitive again.

    A paired A/B of the two builds, five passes alternating which side ran first, holds all seventeen measured sync rows at parity (0.99–1.08× medians, no row negative across every pass), including circular-dependency-3, which shares the binding.inFlight flag the cascade now uses. That A/B is also what caught a regression the suite reported as a win: a materialized async singleton did not match the cascade lane and escaped, snapshotting both cascade arrays on every resolve, for 0.81× of the previous build across all five passes. The cascade entry now answers a plain constant and a cached singleton itself.

    ARCHITECTURE.md records the two shapes tried before this one, including the one that fixed the same bug and measured worse, and why the sync lane's compiled-plan answer does not port to async.

    ResolutionDiagnostics no longer carries asyncContextPoolSize, since there is no async context pool to report.

  • #677 7fd9ba8 Thanks @thevuong! - Breaking: effectiveBindingScope is no longer exported from the package root. It read a Binding, which package.json#exports deliberately withholds, and no public API ever handed one out — so it was exported and impossible to call. Read a binding's scope from BindingSnapshot.scope (container.lookupBindings() / container.inspect()) or from GraphNode.scope (container.generateDependencyGraph()), both of which have always carried it.

    bindingSlotToResolveOptions now takes its slot structurally, so the slot on a public BindingSnapshot — where name is an optional property rather than a required one holding undefined — is accepted. Passing a BindingSlot-shaped literal keeps working.

    A type test now asserts each exported function is callable with values a consumer can actually obtain from the package's own exports, which is what neither of these satisfied.

Patch Changes

  • #677 7fd9ba8 Thanks @thevuong! - Make resolving through a container-level onActivation hook as cheap as resolving without one.

    A transient factory binding that carries activation hooks now takes the same O(1) binding.inFlight cycle guard as the unhooked lane — the argument for that guard never mentioned hooks, since a hook runs on the call stack the factory did — and LifecycleManager keeps a one-entry token→hooks cache in front of its map, because a resolve loop asks about the same token every iteration. Together they halve what the hook lane costs over the plain one. A hook that re-resolves its own token still reports CircularDependencyError, and the flag is still released on every exit path.

  • #677 7fd9ba8 Thanks @thevuong! - Fix three defects found by an audit of the resolution engine's memoization:

    • A .onActivation() hook added to a chain after its binding's first resolve was silently skipped on every lane that consults the activation-need memo (named resolves and nested dependency resolves) while the default-slot dynamic lane honored it. The memo now reads the binding's own hook fresh on every call, so all lanes give one answer.
    • The activation-need memo is keyed by binding id and was only invalidated by the lifecycle version, so a long-running container that rebinds in a loop grew it without bound (~60 B per rebind). The memo is now also stamped with the registry version, evicting entries whose binding ids a rebind has retired.
    • A scoped instance cached in a child container survived unbind/unbindAll/module unload — the drain released singletons only. Scoped entries are now released with their binding (no deactivation, per SPEC §5.2), and resolution diagnostics expose a scopedInstanceCount so the release is pinned structurally.

    A paired A/B against the previous build over six activation- and dispatch-sensitive rows (three passes, alternating order) held every row within noise of parity.

  • #677 7fd9ba8 Thanks @thevuong! - Stop allocating a throwaway Map on every named resolve. The named-lookup memo upserted with getOrInsert(token, new Map()), whose fallback JavaScript evaluates eagerly — so every call built a Map for the hit that immediately discarded it. It now uses Map.prototype.getOrInsertComputed with a module-level factory, which allocates nothing on a hit and no closure per call: ~1.72× on named-constant-get, measured paired against the previous build with the order alternated.

    The bind-time upserts keep the eager form deliberately — a bind is usually a token's first, so the fallback is usually the value stored, and the computed form measured slower there.

    @codefast/di now calls Map.prototype.getOrInsertComputed as well as getOrInsert; both ship in Node 26+, which the package already required.

  • #677 7fd9ba8 Thanks @thevuong! - Put a one-entry cache in front of BindingLookupCache's options-less token map. Two shapes reach that map and neither can use the registry's direct index: an alias, whose terminal binding the index cannot name, and a token owned by a parent container, whose entry has to carry the owner. Both are then resolved in a loop over the same token, so the map lookup they repeat deserves an inline cache — the rule this package already applies to LifecycleManager.activationHandlersFor().

    Paired A/B against the previous build, seven passes alternating which side ran first, medians: to-alias-redirect 1.16× (every pass 1.15–1.18) and child-depth-2-resolve 1.23× (every pass 1.22–1.27), which were the two thinnest wins in the suite outside the lifecycle rows. rebind-hot-swap — the row that invalidates the cache on every iteration, so the only place a front cache could be pure overhead — reads 1.17×, after a five-pass run had put it at 0.88× on mixed signs; the tighter run is the one to believe.

    In the interleaved isolated suite to-alias-redirect reads 1.53× of inversify 8.2.3, up from 1.33×, which is what the paired ratio predicts. child-depth-2-resolve reads 1.14× there against 1.36× before — that row carries both of the report's instability markers (above 30M ops/s, and a per-trial IQR over 5%), its own throughput went up, and seven paired passes put it at 1.22–1.27×, so the paired number is the one that describes this change. The suite's aggregate moved from 42/0/1 to 42/1/0 at a slightly lower median on rows this change cannot reach, which is run-to-run drift rather than an effect.

    null is a real answer from that map, meaning "this token's shape needs the full selection path", so absence is tracked by the token slot rather than by the entry, and a registry-version change clears the slot along with the map.

    Folding alias hops into registry.getFastDefault() instead was considered and rejected as unsound: that index is a bare own-registry Map.get returning a binding, while an alias's terminal may live in a parent container and its invalidation depends on the whole chain's summed version, neither of which the registry can see.

  • #677 7fd9ba8 Thanks @thevuong! - Remove a type parameter the resolver could never honour. Fifteen private methods took Binding<Value> and returned Value, but every caller supplied Value through an unchecked as Binding<Value> — so the generic documented an intent the compiler never verified. The internal lanes now take the erased Binding and return unknown, and the eight public resolve entry points each cast once, where the caller's token is the claim being made. Seventeen casts fewer in the resolver.

    What made that possible: the binding kinds declare their lifecycle hooks as methods rather than function-typed properties, so their parameters compare bivariantly and Binding<Value> stays assignable to Binding. The public ActivationHandler and DeactivationHandler are unchanged and still checked strictly, so a handler you write is verified exactly as before.

    No behaviour change: the emitted JavaScript is identical apart from one line break.

  • #677 7fd9ba8 Thanks @thevuong! - Collapse the resolver's duplicated logic onto one rule per question, and fix the two places where a second copy had drifted.

    • resolveAll(token, { name }) now evaluates a when() predicate on a named binding, as resolve always did. The name index answers the slot; the predicate is a further constraint, and the fast lane was returning a candidate resolve refuses.
    • Refining a binding's scope after its first resolve (bind(T).toDynamic(f) … later .singleton()) now reports the new scope to when() predicates that read ctx.parent.scope. The resolution frame is memoized on the binding and derives from scope, so the refinement has to drop it.

    Internally: slot matching, name-only requests, and the alias walk each exist once; a class's constructor params and a toResolved factory's descriptors resolve through one routine per lane; scope is declared by every binding kind, so the engine reads it as a plain field. No public API changed. Resolution throughput is unchanged or better across the benchmark suite — resolveOptional ~1.5×, transient class and toResolved construction ~1.35–1.44×, constants and named lookups ~1.3×, with the deep/wide graph rows at parity.

  • #677 7fd9ba8 Thanks @thevuong! - Stop minting two arrays per top-level sync resolve, and stop a pooled resolution context re-storing pointers it already holds.

    --prof over the four thinnest rows put the largest di-attributed cost in a place none of this package's notes mention: #acquireSyncResolutionContext and DefaultResolutionContext.reset() together take 22% of ticks on fan-out-tree-depth-3-breadth-4 and 16% on scale-deep-transient-chain-512, and reset() alone takes 10% on container-level-activation-hook. The reason reset() is not free is that a pooled context outlives enough resolves to sit in old space, so each of its five field writes is a pointer store with a write barrier — and three of the five write the same resolver and the same two arrays every time.

    Except they did not, because container.resolve() handed every call a fresh [] pair. So both halves are needed together: a resolver now keeps one sync rootPath/rootStack pair, lent to a top-level resolve when rootStack.length === 0 and otherwise replaced by a fresh pair, and reset() compares before storing. Every sync lane pops what it pushes, so an empty stack is an exact "nobody holds this"; a nested container.resolve() from inside a factory still starts from an empty path, and if a resolve ever left the pair dirty the only consequence is that later resolves mint their own.

    Paired A/B against this commit's parent, six passes alternating which side ran first: constant-resolve 1.70×, container-level-activation-hook 1.67×, realistic-graph-resolve-root 1.34×, fan-out-tree-depth-3-breadth-4 1.28×, scale-deep-transient-chain-512 1.21×, scale-mid-transient-chain-32 1.16×, singleton-class-1-dep 1.13×, to-alias-redirect 1.09×, and dynamic-async-chain-8 0.99× as the untouched control.

    transient-class-1-dep reads 0.91×, negative in all six passes, and the mechanism is the same one that wins the other rows: a fresh array is in new space, so pushing a frame onto it needs no write barrier, while the shared pair is in old space and every push pays one. That row pushes a frame and does nothing else, so it is the one shape where the barrier costs more than the two allocations saved. Kept because it is one row at −9% against five between +21% and +70%.

    tests/unit/resolution/in-flight-invariants.test.ts pins the lending rule in both directions — a nested root resolve gets its own pair, and a throwing resolve hands the pair back — and both were checked by breaking the guard.

  • #677 7fd9ba8 Thanks @thevuong! - toSelf() on a token that is not a class now throws SelfBindingRequiresClassError instead of a bare Error, so it is catchable as a DiError like every other failure this package raises, carries a code and the token name, and is documented in SPEC.

    It was the one throw site outside the error taxonomy, and the architecture test could not see it — that test only read export class …Error declarations. It now also fails on any throw new Error(…) under src/, and on an error class the root barrel forgets to export.

  • #677 7fd9ba8 Thanks @thevuong! - Fix a tag request answering differently depending on how it was spelled. resolve(T, { tags: [["n", -0]] }) matched a binding tagged ["n", 0] while resolve(T, { tag: ["n", -0] }) threw NoMatchingBindingError and resolveAll returned [] — three answers to one question.

    The registry indexes tagged bindings in a Map, so it answers by SameValueZero, while tag values compare by Object.is as SPEC §3.5 requires; the two differ on +0 versus -0. The fast path now re-checks the index's answer, and only where the index can be wrong — a request whose tag value is not zero was already exact. NaN was never affected: both rules treat it as equal to itself.

  • #676 641e233 Thanks @thevuong! - Collapse the types and default lanes of package.json#imports from fallback arrays to single strings.

    Node resolves an imports array by taking the first candidate it can parse, without checking that the file exists and without falling through — a specifier whose first candidate is missing throws ERR_MODULE_NOT_FOUND rather than trying the second. ./dist/*/index.js and ./dist/*/index.d.ts could therefore never be reached, so they read as a safety net that does not exist. The source lane keeps its extension candidates, which only tsc and Vite read and both probe.

0.5.0-canary.8

Minor Changes

  • #646 0093b99 Thanks @thevuong! - Build a container's rarely-used collaborators on first use instead of in its constructor: the inspector, the module ref/binding tables, the scope's in-flight and scoped caches, the registry's named and tagged slot indexes, and the class introspector's metadata caches. A container that only binds and resolves — the common case, and every per-request child container — no longer allocates eleven Maps it never reads.

    A fresh Container.create() retains 2.7 KB instead of 4.8 KB (43% lighter), and parent.createChild() the same, measured by retention against a forced collection. Every deferred collaborator behaves identically whether or not something touched it first, which tests/unit/container/deferred-subsystems.test.ts pins by exercising each one as the first thing a fresh container does.

    It is a throughput win too, on the paths that actually build containers: Container.create() is 1.80× faster (230 ns → 127 ns) and a per-request child container plus a resolve through it — createChild() + resolve, the shape a web app runs once per request — is 1.31× faster. Measured by an interleaved A/B with both builds loaded into one process, 13 trials in alternating order, against a control scenario that resolves from a pre-built container and so cannot benefit; the control sat at 0.997–1.009, and median and best-of agreed on both figures.

    Breaking: ScopeManager.getAllScoped() is removed from the ./resolution/scope subpath. Deferring the scoped cache raised the question of what a bulk reader returns when the cache was never allocated, and this reader had no callers anywhere in the package — so it is gone rather than carrying an empty-map fallback for nobody. getAllSingletons() is unaffected; its cache is still eager.

    What it does not do is close the realistic-graph-cold-resolve loss against tsyringe, and the arithmetic says why: 103 ns off container construction is 2.5% of that row's 4.06 µs iteration, so the row moves ~1.5% — measured, and inside the noise floor. That row's gap is GC-attributable (the two libraries are at mutator parity; di only loses once a forced collection is in the loop), so a 13.7% allocation cut was never going to carry it.

  • #646 de80bad Thanks @thevuong! - Publish an intentional export surface: 13 subpaths instead of 36. The engine's collaborators — resolution/*, registry, container/*, binding, constructor-type, and the metadata internals — are no longer entry points. They carry the invariants documented in ARCHITECTURE.md, and publishing them meant every internal refactor was technically a breaking change. Everything a consumer needs stays reachable from the root export, which already re-exports the builder interfaces, Constructor, MetadataReader, effectiveBindingScope, and the resolve-options helpers.

    This also repairs a silent break. The surface was generated from dist/, so reorganising src/ into container/, resolution/ and introspection/ renamed twelve already-published entry points — ./inspector → ./introspection/inspector, ./dependency-graph → ./introspection/dependency-graph, ./graph-adapters/* → ./introspection/graph-adapters/*, ./container → ./container/container, and the whole flat ./resolver/./scope/./lifecycle/./environment/./constraints/./binding-select/./binding-scope/./resolve-options set — with no changeset saying so. The consumer-facing ones (./inspector, ./dependency-graph, ./graph-adapters/*) are back at the specifiers they shipped under; examples/tanstack-start imports two of them and would have broken on its next upgrade.

    Breaking: the internal subpaths listed above are gone. Import from the package root instead.

  • #646 4ba70d1 Thanks @thevuong! - Keep a singleton's instance on its binding instead of in a per-container table. A binding belongs to exactly one container, so its singleton slot is per-binding — which turns every cached-singleton read from a keyed Map lookup into a field read, on the most common resolve shape there is. The scope manager keeps only a lazily-created list of the bindings that have materialized, so disposal and inspect() can still enumerate them, and the singleton Map is gone entirely.

    In the suite, realistic-graph-resolve-root — a transient controller over eight cached singletons — went from 10.66M to 12.19M hz/op, and the realistic group geomean from 2.24× to 2.54× of InversifyJS. That row carries a 2.9% IQR, so it is one of the numbers here worth reading precisely. singleton-class-1-dep and cold container build both moved up as well, on rows whose IQR is too wide to attribute confidently.

    An interleaved A/B against the previous build, both in one process with a control that cannot benefit, put the same row between 1.09× and 1.40× across four runs — never slower, median and best-of agreeing inside each run, but with a spread that depends on what else the process had run. The suite's figure is the one to cite; the A/B established the direction. A trap worth recording: the control first read 0.88×, which was an artifact of timing an 11 ns call one at a time — batched the way the harness does it, the same control reads 1.02× with a 4% spread.

    Breaking: ScopeManager's singleton API takes a Binding rather than a BindingIdentifier, and hasSingleton/getSingleton/peekSingleton/setSingleton(id, …)/getAllSingletons are replaced by setSingleton(binding, …), deleteSingleton(binding) and cachedSingletons(). The SINGLETON_MISS sentinel is gone; NO_INSTANCE on the binding replaces it. None of this is a published entry point any more, so it is internal — but a fork reaching into ./resolution/scope would notice. InstantiationPlanDependencyEntry also drops its ownerScope field, which a compiled thunk no longer needs.

Patch Changes

  • #646 d27b76f Thanks @thevuong! - Thread an async chain's resolution context through the call and pool it, instead of parking chain identity on the resolver. ctx.resolveAsync() now hands the callee the context it used, so an inner level reuses it when the owner matches — which removes the resolver's path-identity heuristic, its shared settle callback and its active-level counter, and makes two concurrent chains correct by construction rather than by a fallback branch.

    The contexts are pooled, and that is load-bearing rather than an allocation micro-optimization: a per-chain context survives its chain's microtask hops, so under a collecting profile a freshly allocated one is promoted out of the nursery and then collected the expensive way. An ablation that allocated per chain cost 2.5× on dynamic-async-chain-8 under a forced GC every 100 samples, which is the reason for the shape.

    It does not close that row. An earlier draft of this changeset claimed it went from 0.98× to 1.18× of InversifyJS; that figure came from a probe running both library builds in one process, which this harness's README warns is worth ~30% on async chains, and from a 3-trial suite run on a loaded machine. At 5 trials on a quiet machine the row is 0.87× with a 0.6% / 0.3% IQR — among the tightest numbers in the suite. The mechanism above is real; the win over inversify was not.

    A competing hypothesis was tested and rejected: a forced full GC costs the two libraries the same (1.35 ms / 9.76 MB live for di, 1.41 ms / 9.89 MB for inversify), so the cost was never the collection but what di re-established afterwards.

  • #646 864d213 Thanks @thevuong! - Fold the fluent chain's registry committer into the chain itself. bind() now allocates a BindingEntry that carries only the to*() calls, and to*() a BindingChain that commits to the registry directly — one object per bind less than the entry/chain/committer trio, and one Map lookup less per binding registered by a module.

    The two classes now share a BindingRegistration describing where the chain registers, built once per container rather than once per bind(). Threading that instead of a loose (registry, moduleBindingIds, moduleRef) triple makes the module invariant type-enforced — the id list is present exactly when the chain belongs to a module load — which removes both non-null assertions from the commit path, and drops the constructors from 4 and 5 positional parameters to 2 and 3.

    BindingCommitter is gone and BindingEntry's constructor now takes (token, registration). Neither is a published entry point any more, so this is internal.

    This is a simplification, not a throughput win: removing only the committer measured no change above noise. The chain stays two objects because tests/unit/container/bind-to-builder-order.test.ts requires bind()'s result to lack when*() at runtime — that is the test's own guarantee, stricter than SPEC §2.4, which only claims compiler enforcement. The measured ceiling for removing every builder object is ~19% on realistic-graph-cold-resolve under a forced GC — recorded in ARCHITECTURE so the lead is not re-tried blind.

  • #646 d27b76f Thanks @thevuong! - Compile instantiation plans around dependencies the compiler cannot see through, instead of refusing to compile the graph at all. A factory, a scoped binding, an activation hook, a class past the depth limit, or a multi/optional/named parameter now compiles to an escape — a re-entry into the runtime resolver seeded with exactly the ancestors the interpreted path would have pushed at that point, dispatched through exactly the resolve the interpreter would have called. Cycle detection, constraint contexts and error paths are therefore identical to never having compiled, and only the opaque dependency pays the runtime price while its siblings and ancestors stay compiled.

    Previously a single toDynamic dependency anywhere in a class graph dropped the whole graph to the interpreted path — a 13.9× cliff on a shape applications write constantly (a factory-provided config injected into a class tree). That graph is now ~2× faster, and the first-materialization path of a singleton dependency inside a plan gained cycle detection it did not have.

  • #646 d27b76f Thanks @thevuong! - Split the resolver's self-contained caches into named collaborators — BindingLookupCache (the chain-versioned options-less lookup memo), ClassIntrospector (per-class metadata, @postConstruct discovery, accessor injection, instantiation) and ActivationNeedCache (per-binding activation need, versioned on the lifecycle manager). The engine class keeps the sync and async pipelines, which genuinely need the same private state on every hop, and ARCHITECTURE.md now records the layering, the invariants each hot path depends on, and the rule that separates a legitimate threshold (choosing an implementation) from the kind that was removed (choosing a semantics).

    New subpaths @codefast/di/resolution/{activation-need,binding-lookup-cache,class-introspector}; @codefast/di/resolution/class-plan is now @codefast/di/resolution/instantiation-plan, correcting an export map that had been stale since the module was renamed.

  • #646 a720c62 Thanks @thevuong! - Collapse the fluent binding chain into one object. A single BindingChain is now the BindToBuilder that bind() returns and the kind-specific builder that to*() returns, so a bind() allocates one builder instead of two.

    The to*()-before-when*() ordering stays enforced, as a type-level guarantee — which is what SPEC §2.4 actually claims. bind() is typed BindToBuilder, so a refinement before to*() does not compile; tests/types/container-api.test.ts pins that. For a caller without types, or one who casts past them, every refinement now throws the new ChainNotRegisteredError naming the token and pointing at to*(), rather than silently doing nothing. whenDefault() asserts registration for that reason alone, since it otherwise has nothing to do.

    The previous revision kept two objects because a unit test asserted the refinement methods were absent from the object bind() returns — a stricter reading than the spec, and one that pinned an implementation detail. That test now asserts the contract instead: every refinement throws before to*(), nothing is registered when it does, and the chain still works normally afterwards.

    This is an API simplification, not a throughput win: going from four builder objects per bind to three measured no change above noise, and a fluent API cannot go below one, so the ~19% ceiling recorded in ARCHITECTURE for removing all of them is unreachable rather than pending.

  • #646 1241f82 Thanks @thevuong! - Register a fluent binding chain once instead of once per refinement. bind(T).toDynamic(f).singleton() used to insert a binding, remove it, and insert a replacement — two registry mutations, two version bumps, and a full index churn per binding. The chain now registers on its to*() call and refines that same registered object in place; only when*() re-slots, and it re-registers under the chain's original id, so id() stays valid for the whole chain instead of the intermediate ids being dead. Binding construction also funnels through a single createBinding() literal, which is what guarantees the one V8 hidden class the resolver's hot property reads depend on — so the registry stores what it is handed rather than re-copying it.

    Cold container build (build, bind 10 nodes, resolve the root) went from the suite's only loss to a win against every competitor: 0.76× → 3.0× of InversifyJS, 0.43× → 1.9× of Awilix, 0.22× → 1.07× of tsyringe.

    The builder's CommitFn type is replaced by a BindingCommitter interface (commit plus refine, the latter for in-place refinements the registry indexes do not care about), and createBinding / refinableFields are new exports from @codefast/di/binding.

0.5.0-canary.7

Minor Changes

  • ad11507 Thanks @thevuong! - Reorganize the source tree into subsystem folders — container/ (container + the extracted fluent binding builders), resolution/ (resolver, scope, lifecycle, environment, selection/constraints, and the extracted cycle-guard module), and introspection/ (inspector, dependency graph, and the graph adapters). The root entry keeps exporting everything and now also exports the graph adapters (toDotGraph, toCytoscapeGraph, toReactFlowGraph and their types), so import { toReactFlowGraph } from "@codefast/di" is the preferred path.

    Breaking (0.x minor): the @codefast/di/graph-adapters/* subpaths are removed — import the adapters from the root entry or from @codefast/di/introspection/graph-adapters/*. Deep subpaths of other moved modules follow the new folders (e.g. @codefast/di/resolver → @codefast/di/resolution/resolver).

  • 6a25788 Thanks @thevuong! - Resolver performance overhaul — the head-to-head benchmark vs InversifyJS 8 now shows 38/38 comparable scenarios won (median 1.82×, isolated mode), up from 7 losing rows. Four techniques, no public-API changes:

    • Chain-versioned lookup memo — BindingRegistry gains a monotonic mutation version; resolvers memoize options-less token → {binding, owner} lookups across the parent chain with alias hops folded to the terminal binding. Resolving a root binding from a depth-2 child (or through toAlias) is now as fast as resolving it locally.
    • Compiled resolution plans — a transient class binding whose dependency subgraph is pure static (class/constant/cached-singleton deps, no activation hooks or postConstruct) compiles once into a nested-constructor closure, cycle-checked at compile time. Anything dynamic keeps the runtime cycle guard, so error semantics are unchanged.
    • Uniform binding hidden class — Registry.add rebuilds every binding with one fixed field superset so mixed binding kinds no longer turn the resolver's hot property reads megamorphic (~30% throughput loss in processes exercising several kinds).
    • Leaner async transient path — cleanup runs as a FIFO side listener on the factory promise instead of a derived-promise chain (one less promise and one less microtask hop per level), and activated transient dynamic bindings get a dedicated lane that fetches container hooks once. Behavior note: an unawaited failing resolveAsync no longer surfaces as an unhandledRejection; await (or .catch) the returned promise.
  • #643 14c3a98 Thanks @thevuong! - container.validate() now reports a captive dependency when a singleton depends on a transient or scoped toDynamic / toDynamicAsync binding. Previously any dynamic terminal was classified opaque and its declared scope went unchecked, so the most common form of the bug — a singleton capturing one instance of something bound transient — passed validation silently.

    A factory's body remains opaque: validate() still does not descend into it, so whatever the factory resolves internally is not reported. Only the declared scope of the dependency edge is judged, which is the part the container actually knows.

    Breaking: a container that wires a singleton to a transient or scoped dynamic binding now throws ScopeViolationError from validate() where it previously passed. Either widen the dependency's scope, or inject a factory instead of the value if a fresh instance per use is intended.

Patch Changes

  • 2def688 Thanks @thevuong! - Fix a crash on cyclic aliases: bind(a).toAlias(b) + bind(b).toAlias(a) previously recursed until RangeError: Maximum call stack size exceeded on both resolve and resolveAsync. Alias following is now an iterative loop with exact revisit detection — a genuine cycle throws CircularDependencyError naming the alias chain, and legitimately long alias chains resolve with no arbitrary hop cap.

    Also splits the magic 32 that served two unrelated roles: the transient-dynamic fast lanes keep their own DEEP_LANE_THRESHOLD = 32 (a shared-context/pool design point), while the cycle-scan Set attachment moves to a measured RESOLUTION_SET_THRESHOLD = 128 — benchmarking showed Array.includes beats the Set's has/add/delete churn up to at least depth 96, so mid-depth graphs now skip the Set entirely.

  • 19199af Thanks @thevuong! - Fix binding-registration order sensitivity: the fluent builder chain commits eagerly, so bind(x).toDynamic(f).when(p) (or .whenNamed(...) / .whenTagged(...)) momentarily registered a default-slot binding whose last-wins commit silently displaced an existing default binding of the same token — and the displaced binding was never restored once the chain narrowed to a predicate or a named/tagged slot. Registering a default binding before a constrained one on the same token therefore lost the default. The commit chain now remembers what an intermediate commit displaced and restores it when the chain settles on a non-conflicting shape; a chain that genuinely ends on the same default slot still replaces the previous default (last-wins unchanged).

    Binding selection also gains a most-specific-wins rule: when both a default binding and exactly one predicate-carrying binding match, the predicate wins (it is a deliberate specialization) instead of throwing AmbiguousBindingError — so "default plus when(...) override" now works as naturally intended. Two matching predicates remain ambiguous and still throw.

  • #643 14c3a98 Thanks @thevuong! - Unify sync transient-dynamic cycle detection on a per-binding inFlight flag, replacing the shallow lane's O(depth) resolutionPath.includes() scan and the deep lane's Map of generation marks. Sync resolution runs on a single call stack, so a binding marked on factory-enter and cleared on factory-exit is exactly path membership — detection becomes an O(1) field read with no hashing, no string scan, and no side table to allocate or grow. The async lane keeps its own per-path check, since async chains can interleave.

    Transient-dynamic chains that previously lost now win across the whole depth range (a 32-deep chain went from ~0.55× to ~1.8× of InversifyJS), deep chains widen their lead, and cold container builds get cheaper because there is no per-resolver cycle-tracking structure to allocate. It also fixes a latent correctness bug: because the deep lane now clears a binding's mark when its factory returns, a deep (past-threshold) transient dependency resolved twice via separate sub-branches (a diamond, not a cycle) no longer throws a false CircularDependencyError.

  • f9aeeb0 Thanks @thevuong! - Extend the compiled-plan and memoization coverage: toResolved(...) transient bindings with pure-static explicit deps now compile into factory-call plans (same refusal rules and sync-only check as class plans), and name-only resolves gain a chain-versioned memo that fast-paths constants and cached singletons — predicates, aliases, and anything context-dependent keep the full selection path. Measured: named-constant-get ~21M → ~30M hz/op, to-resolved-3-deps ~39M → ~52M hz/op.

  • #643 14c3a98 Thanks @thevuong! - Collapse the async transient-dynamic resolver into a single lane and retune the cycle-set threshold.

    The async lane used to split at depth 32 into a fast path (linear path scan, shared context, no stack frames) and a slow path (forced Set, fresh context per level, stack frames, extra microtask hop) — so context identity, ctx.graph contents, and promise shape all changed silently at that depth. Both are now one lane whose cycle guard goes through enterResolutionPath, the only mechanism that stays correct when chains interleave (Promise.all) and which adapts on its own: a linear scan while the path is short, an attached Set past RESOLUTION_SET_THRESHOLD. DEEP_LANE_THRESHOLD is gone from the package entirely.

    RESOLUTION_SET_THRESHOLD drops from 128 to 32 on fresh measurements — at 128 an async chain costs 1275 / 3641 / 9645 / 26082 ns at depth 16 / 32 / 64 / 128 versus 1202 / 3285 / 7735 / 16837 at 32, so the old value was the worse choice at every depth measured.

  • #643 14c3a98 Thanks @thevuong! - Collapse the sync transient-dynamic resolver into a single lane and memoize each binding's resolution frame on the binding itself.

    The separate deep lane existed to escape an O(depth) resolutionPath.includes() cycle scan past ~32 levels. With cycle detection now an O(1) binding.inFlight mark there is nothing to escape, so the depth split, its shared-context bookkeeping, its reentrancy fallback, and the per-resolver frame Map are all gone — the smaller function also inlines better. Frames derive only from immutable binding fields, so caching one per binding replaces a Map lookup per hop and a Map insert per binding per container.

    Faster at every chain depth measured (8 → 512), e.g. a 32-deep transient chain improved ~39% and cold container build ~62%, which turns the cold-build result against Awilix from a loss into a win in the default benchmark profile.

  • #643 14c3a98 Thanks @thevuong! - toResolved() and toResolvedAsync() now accept injection descriptors — inject(), optional() and injectAll() — in their dependency list, matching what @injectable([...]) already allowed and what the builder already did at runtime (it normalizes every entry through normalizeToDescriptor). Previously the public signature only admitted bare tokens and constructors, so an optional or multi dependency needed a cast even though resolution handled it correctly.

    Factory arguments are typed from the descriptor: a bare token gives Value, optional(token) gives Value | undefined, and injectAll(token) gives Array<Value>. Widening only — existing bare-token call sites are unaffected.

  • #643 14c3a98 Thanks @thevuong! - unbind(token) now drops the token's bindings in a single registry pass instead of removing them one id at a time. The previous path re-scanned and re-indexed the token's binding list once per binding — quadratic in the number of slots bound to that token — and bumped the registry version once per removal, invalidating resolver lookup caches repeatedly. Behaviour is unchanged, including deactivation of cached singletons.

0.5.0-canary.6

Minor Changes

  • 4f7a188 Thanks @thevuong! - Rename the hint resolve parameter to options throughout — "hint" implied optional guidance the container may ignore, but the value is a hard selection criterion (resolve throws NoMatchingBindingError when nothing matches), so the name misstated its role. Positional call sites are unaffected; the one breaking surface is NoMatchingBindingError.hint, now NoMatchingBindingError.options.

1.0.0-canary.7


Showing the latest 20 releases; the full changelog on GitHub has 22 more releases.