@codefast/di 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +69 -6
  3. package/dist/ambient/active-container.d.ts +7 -2
  4. package/dist/ambient/active-container.js +6 -1
  5. package/dist/container/binding-builders.d.ts +19 -1
  6. package/dist/container/binding-builders.js +75 -16
  7. package/dist/container/container.js +224 -47
  8. package/dist/core/binding-declaration.d.ts +120 -0
  9. package/dist/core/binding-declaration.js +186 -0
  10. package/dist/core/binding.d.ts +57 -5
  11. package/dist/core/binding.js +48 -1
  12. package/dist/core/module.d.ts +7 -4
  13. package/dist/core/module.js +17 -3
  14. package/dist/core/registry.d.ts +11 -7
  15. package/dist/core/registry.js +122 -38
  16. package/dist/core/state-epoch.d.ts +18 -1
  17. package/dist/core/state-epoch.js +17 -0
  18. package/dist/core/tag.js +1 -1
  19. package/dist/decorators/decorator-metadata.d.ts +9 -0
  20. package/dist/decorators/decorator-metadata.js +20 -0
  21. package/dist/decorators/inject.js +2 -1
  22. package/dist/decorators/injectable.js +3 -1
  23. package/dist/decorators/lifecycle-decorators.js +8 -2
  24. package/dist/errors/errors.d.ts +77 -3
  25. package/dist/errors/errors.js +93 -10
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +2 -1
  28. package/dist/injection/descriptor.js +3 -7
  29. package/dist/injection/resolve-options.js +6 -4
  30. package/dist/introspection/dependency-graph.d.ts +7 -2
  31. package/dist/introspection/dependency-graph.js +46 -23
  32. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  33. package/dist/introspection/inspector.js +6 -9
  34. package/dist/lifecycle/lifecycle-manager.js +14 -2
  35. package/dist/lifecycle/scope-manager.js +28 -10
  36. package/dist/metadata/verifying-metadata-reader.d.ts +4 -3
  37. package/dist/metadata/verifying-metadata-reader.js +28 -6
  38. package/dist/resolution/async-fan-out.d.ts +12 -0
  39. package/dist/resolution/async-fan-out.js +26 -0
  40. package/dist/resolution/cache/activation-need.d.ts +0 -1
  41. package/dist/resolution/cache/activation-need.js +11 -18
  42. package/dist/resolution/cache/binding-lookup-cache.d.ts +0 -7
  43. package/dist/resolution/cache/binding-lookup-cache.js +30 -17
  44. package/dist/resolution/cache/class-introspector.d.ts +11 -0
  45. package/dist/resolution/cache/class-introspector.js +18 -0
  46. package/dist/resolution/context.d.ts +15 -23
  47. package/dist/resolution/context.js +47 -56
  48. package/dist/resolution/path/resolution-path.d.ts +48 -13
  49. package/dist/resolution/path/resolution-path.js +89 -38
  50. package/dist/resolution/plan/instantiation-plan.js +61 -21
  51. package/dist/resolution/plan/plan-codegen.d.ts +7 -4
  52. package/dist/resolution/plan/plan-codegen.js +60 -32
  53. package/dist/resolution/resolver.d.ts +4 -5
  54. package/dist/resolution/resolver.js +288 -258
  55. package/package.json +14 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,270 @@
1
1
  # @codefast/di
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A default-slot alias is a transparent pointer: a request whose criteria no slot of the alias's own token matches is
8
+ forwarded, criteria and all, to its target — `resolve(AbstractLogger, { name: "file" })` reaches `Logger`'s `"file"`
9
+ binding. An exact slot on the alias's token still wins, the nearest container still answers first, and `has` agrees;
10
+ `resolveAll` keeps filtering by the alias token's own slots.
11
+
12
+ - [#896](https://github.com/codefastlabs/codefast/pull/896) Retire the async cascade lane: every `resolveAsync` now runs on the branch lane, so a factory's context answers from its
13
+ own ancestors before and after an `await`. A `whenParentIs` binding requested after an `await` is now selected as the
14
+ sync lane selects it, `ctx.graph.resolutionPath` names the level's own ancestors, and a cycle formed entirely from
15
+ post-`await` edges is named from the true root. A transient class or factory root with a statically visible graph is
16
+ answered by the compiled async plan. `AsyncCascadeContext` and `resolveAsyncFromCascade` are removed from
17
+ `resolution/context`; `ResolutionDiagnostics.builtSubsystems` reports `resolver.asyncRootLevel` once the async lane has
18
+ been entered.
19
+
20
+ - [#937](https://github.com/codefastlabs/codefast/pull/937) Add declared modules: `Module.fromBindings(name, [binding(key, definition), …])` (also `SyncModule.fromBindings`) builds
21
+ a `SyncModule` from a list, each `binding()` stating one binding with the fluent chain's own vocabulary — `to`,
22
+ `toSelf: true`, `toConstantValue`, `toDynamic`, `toDynamicAsync`, `toResolved` / `toResolvedAsync` with `deps`,
23
+ `toAlias`, then `whenNamed`, `whenTagged` (one criterion or several), `when`, `many: true`, `scope` and the lifecycle
24
+ hooks. A definition is typed exactly as the chain is — the key alone decides the value type and the slot names, and the
25
+ compiler rejects a second strategy, a scope on a constant or an alias, a hook on an alias, `onDeactivation` off a
26
+ singleton, and a slot on a collection member. `binding()` checks the same rules at runtime for plain JavaScript and
27
+ throws the new `InvalidBindingDeclarationError` (`INVALID_BINDING_DECLARATION`, with `tokenName` and `reason`), or
28
+ `ManyBindingSlotError` / `SelfBindingRequiresClassError` as the chain does.
29
+
30
+ A declared module loads, unloads, ref-counts and imports exactly as the equivalent `Module.create` setup would, and
31
+ registers each binding already in its final shape, so a binding with a slot, a scope or a hook skips the chain steps a
32
+ fluent setup pays on every load. Nothing a resolve reads changes. `SyncModule`'s `MODULE_SETUP` member now holds either
33
+ the setup callback or the declarations.
34
+
35
+ - [#925](https://github.com/codefastlabs/codefast/pull/925) A constant that a plain last-wins `bind()` displaces now still runs its `onDeactivation` hook at `dispose()`. A
36
+ displaced constant left the registry — so the dispose sweep, which walks the registry, never reached it — and, carrying
37
+ no cached instance, it was absent from the singleton cache too, falling between the two. The container now records a
38
+ displaced constant that still owes a deactivation and drains it at dispose; a refinement that restores the binding (a
39
+ `when*()` that moves the winner off the slot) takes it back out of that set, so nothing deactivates twice. The
40
+ park-and-restore behaviour of `bind()` is otherwise unchanged.
41
+
42
+ - [#931](https://github.com/codefastlabs/codefast/pull/931) Disposing a container now reaches its descendants. A child whose parent (or any ancestor) has been disposed is itself
43
+ disposed: `isDisposed` reads `true`, and every `resolve`, `has` and mutation is refused with `DisposedContainerError`,
44
+ including the child's own bindings — previously a child kept building transients and constants from a torn-down chain,
45
+ and `child.has` and `child.resolve` disagreed. The guard keeps the resolve path cheap: a root still reads only its own
46
+ `#disposed` field, and a child adds one call-free compare of a dedicated dispose-epoch cell against the epoch at which
47
+ its chain was last confirmed live — the ancestor walk runs only after some container in the process is disposed. The
48
+ dispose epoch is separate from the state epoch, so a per-request child's dispose does not invalidate the chain-version
49
+ memo that keeps deep resolves cheap, and `createChild()` is untouched. `resolveAsync` refuses a disposed chain with a
50
+ synchronous throw at the entry, matching how it already guarded a self-disposed container, and `dispose()` stays ungated
51
+ so an `await using` child of a disposed ancestor still tears down cleanly.
52
+
53
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) `generateDependencyGraph({ includeParent: true })` follows resolution's own walk up the chain: every ancestor's bindings
54
+ join the graph, a single dependency connects to the nearest container holding a binding its slot matches — a child
55
+ binding the request cannot select no longer hides the parent's — and an `injectAll` fans out across the whole chain.
56
+ `buildDependencyGraph` takes the ancestor registries, nearest first, in place of the parent registry.
57
+
58
+ - [#921](https://github.com/codefastlabs/codefast/pull/921) A subclass that inherits declared constructor dependencies but declares none of its own — the natural
59
+ `class Derived extends Base {}` with an implicit constructor — is now rejected with `MissingMetadataError` instead of
60
+ being built with `undefined` arguments. Constructor metadata is opt-in per class, so an implicit constructor previously
61
+ slipped through the `target.length === 0` check and produced a silently-broken instance. The error names the base and
62
+ how many dependencies it declares. A subclass whose own `@injectable([])` declares zero deps is unaffected — it is built
63
+ with zero arguments, as declared.
64
+
65
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) Decorators report a runtime without `Symbol.metadata` as `MissingDecoratorMetadataError` at the declaration, naming the
66
+ decorator and the fix (`(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata")` in a module
67
+ imported first), instead of failing with a bare `TypeError` on the first metadata write. TypeScript compiles
68
+ `context.metadata` to `undefined` on such a runtime; the library does not install the symbol itself, since the package
69
+ declares no side effects.
70
+
71
+ - [#926](https://github.com/codefastlabs/codefast/pull/926) Module `unload` is now symmetric with `import`, and `unbindAll` resets module bookkeeping. An `import` inside a module's
72
+ setup incremented the imported module's ref-count but nothing ever decremented it, so `unload(A)` left a module `A`
73
+ imported behind bound forever — the container now records each module's imports and, when a module's own ref-count
74
+ reaches 0, releases the imports it took, unbinding an imported module only when its ref-count reaches 0. And
75
+ `unbindAll()` / `unbindAllAsync()` now clear the module ref-count, binding-id and import tables after deactivation, so a
76
+ module loaded before `unbindAll` can be `load()`ed again instead of being silently skipped as already-loaded.
77
+
78
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A sync `rebind()` whose old binding owes an async deactivation now fails exactly like a sync `unbind()` whatever shape
79
+ the token has: `rebind()` itself throws `AsyncDeactivationError`, the old bindings are removed, and nothing is
80
+ committed. A lone default binding used to let `rebind()` pass and then throw from `.to*()` after the replacement was
81
+ already registered.
82
+
83
+ - [#929](https://github.com/codefastlabs/codefast/pull/929) `runWithContainer` is now typed as synchronous-only. The ambient container lives in a module-level variable restored in
84
+ a `finally`, so it lasts only the callback's synchronous run — it does not survive an `await`. A callback returning a
85
+ `Promise` was silently accepted and lost the context; its return type is now
86
+ `Result extends Promise<unknown> ? never : Result`, so an async callback resolves to `never`.
87
+ `MissingContainerContextError` now says the context does not survive an `await`, and the TSDoc, README and SPEC state
88
+ the sync-only constraint. The engine internals are unchanged — they only ever ran synchronous callbacks.
89
+
90
+ - [#894](https://github.com/codefastlabs/codefast/pull/894) Four size thresholds are gone, each replaced by the algorithm it approximated: alias chains fold exactly to any length
91
+ (`ALIAS_HOP_LIMIT` is removed from `resolution/cache/binding-lookup-cache`); a generated plan is one statement per node
92
+ and inlines to any depth; every synchronous lane checks a cycle by the binding's in-flight flag at any depth
93
+ (`RESOLUTION_SET_THRESHOLD` and `enterResolutionPath` are removed from `resolution/path/resolution-path`); a
94
+ multi-criterion request is selected by one scan at any binding count. `PLAN_CODEGEN_THRESHOLD` is 1024, the measured
95
+ break-even of generating a plan against running its closure.
96
+
97
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) `UnreachableLifecycleHookError` carries a `reason` — `"unbound"` or `"no-deactivatable-binding"` — and an
98
+ `onDeactivation` hook on a token whose every binding is transient or scoped is reported as that, rather than as a token
99
+ nothing is bound to.
100
+
101
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) `validate()` checks an `optional()` dependency whenever it is bound, so a singleton capturing a bound transient or
102
+ scoped dependency through `optional()` is a `ScopeViolationError`; an optional dependency that is not bound still
103
+ imposes nothing.
104
+
105
+ - [#976](https://github.com/codefastlabs/codefast/pull/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
106
+ release with explicit resource management built in (`using`, `await using`, `DisposableStack`, `AsyncDisposableStack`,
107
+ `SuppressedError`) and all of ES2025, so the packages use both as the platform ships them instead of shimming them for
108
+ 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
109
+ while a deployment still runs Node 22.
110
+
111
+ ### Patch Changes
112
+
113
+ - [#977](https://github.com/codefastlabs/codefast/pull/977) The README states the browser floor: Chrome and Edge 136, Firefox 136, and Safari 18.4 or later, the first releases that
114
+ ship every ES2025 builtin. `@codefast/di`'s README also says what a browser program without explicit resource management
115
+ does: it keeps `skipLibCheck` on and calls `dispose()` instead of `await using`.
116
+
117
+ - [#922](https://github.com/codefastlabs/codefast/pull/922) A lifecycle hook that returns a rejecting `Promise` when reached from a synchronous lane no longer crashes the process.
118
+ `runActivationSync` and `runDeactivationSync` call each hook before they can tell it is async, so on discovering a
119
+ returned `Promise` they now adopt its rejection (a no-op `.catch`) before throwing `AsyncActivationError` /
120
+ `AsyncDeactivationError` — a rejecting `@postConstruct`, `onActivation`, `onDeactivation` or `@preDestroy` can no longer
121
+ become an unhandled rejection that ends the process. The hook has still run; retry on `resolveAsync` / `unbindAsync` to
122
+ await it properly.
123
+
124
+ - [#896](https://github.com/codefastlabs/codefast/pull/896) An async level with several dependencies, and a `resolveAllAsync` collection, report the first failing dependency in
125
+ declaration order — the order the sync lanes report — instead of whichever rejection happened to settle first. Every
126
+ dependency still starts before anything is reported. A node with one dependency awaits it directly, with no fan-out to
127
+ settle.
128
+
129
+ - [#924](https://github.com/codefastlabs/codefast/pull/924) `child.has(token)` no longer throws when a parent container is disposed but the child is not. `has` recursed through the
130
+ parent's **public** `has`, which asserts the container is live, so a live child that could still `resolve` a
131
+ parent-owned token threw `DisposedContainerError` from `has` alone — breaking the `if (c.has(token)) c.resolve(token)`
132
+ guard on a healthy container. It now recurses through an internal `#hasInChain` that reads the parent's registry
133
+ directly, keeping the disposed-guard on the public entry point of the container being called.
134
+
135
+ - [#894](https://github.com/codefastlabs/codefast/pull/894) The cold path allocates only what it uses: a container builds its lookup memo, class introspector, context pools and
136
+ lone map on first use; a root's plan is compiled on the request that repeats it, so a container that resolves a root
137
+ once never compiles; a binding's activation need is stamped on the binding instead of memoized in a per-resolver map
138
+ (`ResolutionDiagnostics.builtSubsystems` no longer lists `resolver.activationNeedMemo`); teardown clears instances
139
+ without splicing the singleton list or pairing each binding; a rebind of a lone token is one registration whose
140
+ displaced binding is deactivated on the spot; a fresh registration is one probe and one write; a chain's own `.many()`
141
+ re-slots without probing the registry; each error class names itself with a literal.
142
+
143
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A reader passed through `ContainerOptions.metadataReader` now outranks a `MetadataReaderToken` binding for that
144
+ container's children too, at every depth, as it already did for the container itself; a token binding still reaches the
145
+ children of a container given no reader.
146
+
147
+ - [#920](https://github.com/codefastlabs/codefast/pull/920) `resolveOptional`, `resolveOptionalAsync`, `resolveAll` and `resolveAllAsync` no longer throw when they reach an alias
148
+ whose chain ends at a token nothing matches. An alias is a transparent pointer, so a dangling chain is the same miss the
149
+ target itself would be: the optional lanes return `undefined`, and a dangling alias member is skipped from a collection
150
+ rather than failing the whole fan-out. A required `resolve` / `resolveAsync` still throws, and an alias **cycle** still
151
+ throws `CircularDependencyError` — a cycle has no absent reading.
152
+
153
+ - [#976](https://github.com/codefastlabs/codefast/pull/976) The README states what a program needs for the declarations' disposal members: the explicit resource management types,
154
+ which no numbered `lib` declares before ES2027. `@types/node` 24 or later loads them, and so does `ESNext.Disposable` in
155
+ `lib`. Without either, TypeScript 7 fails inside `container.d.ts` with TS2550 under `skipLibCheck: false`, and at
156
+ `await using` with TS2318. `@codefast/di-testing` drops a `/// <reference lib="esnext.disposable" />` that TypeScript 7
157
+ stripped from its emitted declarations anyway, and takes the lib from its `tsconfig.json` instead.
158
+
159
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A dependency slot declaring `tags: []` states no criterion, as a binding slot already did:
160
+ `injectionSlotToResolveOptions` answers `undefined` for it, and such a dependency no longer escapes a compiled plan.
161
+
162
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A `MetadataReader` passed through `ContainerOptions` is asked about a class once for the life of the reader: its answers
163
+ are memoized per class, and every container handed the same reader shares one verifying wrapper. Lifecycle metadata used
164
+ to be read on every activation, and each root container asked again.
165
+
166
+ - [#894](https://github.com/codefastlabs/codefast/pull/894) The React Flow graph export lays its nodes out on a square grid derived from the node count rather than a fixed five
167
+ columns.
168
+
169
+ - [#934](https://github.com/codefastlabs/codefast/pull/934) `MissingMetadataError` for a subclass that inherits declared constructor dependencies no longer advises giving it an
170
+ explicit constructor, which is rejected the same way. It points at `@injectable([...deps])` — `@injectable([])` for a
171
+ class that takes none — or a `toDynamic()`/`toResolved()` binding.
172
+
173
+ - [#894](https://github.com/codefastlabs/codefast/pull/894) Every resolution lane now answers a graph identically. A per-request child that misses a token its parent's bindings all
174
+ declined reports `NoMatchingBindingError` as the parent does, not `TokenNotBoundError`; a sibling on the async
175
+ interpreted lane is selected against its own path rather than the earlier sibling's frame; a dynamic factory that
176
+ resolves its own token from its synchronous prefix is reported as a cycle before it runs a second time.
177
+
178
+ - [#905](https://github.com/codefastlabs/codefast/pull/905) The chain-versioned lookup memo answers a repeated token from a method small enough for its hot callers to inline: the
179
+ hit is the whole of `defaultEntry`, and everything that fills the memo is the miss. An alias resolve, a parent-owned
180
+ resolve from a child and every other lookup that reaches the memo pays one inlined compare where it paid a call.
181
+
182
+ - [#938](https://github.com/codefastlabs/codefast/pull/938) A constant with an `onDeactivation` hook that a bind inside a module load displaces now runs that hook at `dispose()`,
183
+ as it already did when a plain `container.bind()` displaced it. A module's chains registered without the container's
184
+ displacement bookkeeping, so the displaced constant left the registry untracked and nothing ever tore it down; this held
185
+ for `Module.create` setups and declared modules (`Module.fromBindings`) alike. Unloading the displacing module still
186
+ does not restore the displaced binding — its deactivation stays owed to `dispose()`.
187
+
188
+ - [#918](https://github.com/codefastlabs/codefast/pull/918) A `{ name, tag }` request now selects the same binding as its `{ tags: [slotName.of(n), tag] }` spelling, closing two
189
+ selection bugs. A name that has been interned nowhere in the process no longer makes the request a miss: the request's
190
+ 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
191
+ returning early — the answer no longer depends on whether unrelated code has ever called `whenNamed` with that string.
192
+ And the name-plus-tag fast lane now declines to full selection whenever the token carries any `when()` predicate
193
+ candidate, so the more-specific rule's predicate step is honoured on this lane as on every other.
194
+
195
+ - [#896](https://github.com/codefastlabs/codefast/pull/896) Every binding predicate now reads one `ConstraintContext` shape: the shared root context, the context a selection builds
196
+ over a live path, an async level's prefix and the inspector's probe are all `DefaultConstraintContext`, exported from
197
+ `resolution/context`. A predicate's call site stays monomorphic, and the per-selection object literal with its eager
198
+ `ancestors` slice is gone — `ancestors` is now sliced on first read, as `ctx.graph` already did.
199
+
200
+ - [#916](https://github.com/codefastlabs/codefast/pull/916) `LEARNING.md` is removed, and with it the Learning page the docs site rendered for this package. What a shape is and
201
+ what it guarantees stays in `ARCHITECTURE.md`, the behavioural contract in `SPEC.md`, and usage in `README.md`, whose
202
+ document list no longer points at the removed file.
203
+
204
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) `resolveAll` returns a token's bindings in registration order however a chain refines its own binding. Binding a default
205
+ and then a named, tagged, member or predicate binding used to list the later one first, because the refinement re-added
206
+ what it had displaced behind it; each binding now keeps the place its first registration gave it.
207
+
208
+ - [#902](https://github.com/codefastlabs/codefast/pull/902) A transient factory root resolved with `resolveAsync()` is handed one resolution context per binding, built on the first
209
+ resolve and reused by every later one — its path is its own frame alone and the request carries no options, so the
210
+ context is a function of the binding. The root allocated an array and a context per resolve; it allocates nothing now,
211
+ and a concurrent root reads the same, correct, path after an `await`.
212
+
213
+ - [#919](https://github.com/codefastlabs/codefast/pull/919) A `NoMatchingBindingError`'s "Available slots" diagnostic no longer throws when a bound slot carries a tag value that
214
+ cannot be stringified — a bigint, a null-prototype object, or one whose `toString` throws. `bindingSlotToString` (and
215
+ the dependency-graph edge label) now render such a value as `<unprintable>` through a shared `stringifyTagValue` guard,
216
+ so the real `NoMatchingBindingError` surfaces instead of a masking `TypeError`, matching the guard the request-side
217
+ diagnostic already had.
218
+
219
+ - [#932](https://github.com/codefastlabs/codefast/pull/932) `SPEC.md` carries the behavioural contract and nothing else. The source-tree listing, the copied export barrel, the
220
+ build configuration and the roadmap of already-shipped work are gone — a reader can now rename any file under `src/`
221
+ without contradicting the spec. `Public API` states the surface as a rule instead of a second copy of `index.ts`, which
222
+ had already drifted from it, and `Scope and requirements` states the runtime, module format and TypeScript floor a
223
+ consumer must bring. New `DECISIONS.md` takes the background and the whole InversifyJS v8 comparison, `README.md` takes
224
+ the testing patterns with the `MetadataReader` example corrected to `Container.create({ metadataReader })`, and
225
+ `CONTRIBUTING.md` takes the packaging and decorator-toolchain notes.
226
+
227
+ Checking the spec against the source turned up four public names it never described: `BindingSnapshot.isMany`, the
228
+ `DependencySlot` that both `InjectionDescriptor` and `ParamMetadata` extend, and `bindingSlotToResolveOptions`, which
229
+ turns a snapshot's slot back into the `ResolveOptions` that selects it. All three are specified now, and the claim that
230
+ `toAlias()` returns the one builder with no type parameter is corrected — it has no _value_ type parameter, but it does
231
+ carry the token's slot names.
232
+
233
+ - [#927](https://github.com/codefastlabs/codefast/pull/927) `@postConstruct` / `@preDestroy` on a symbol-keyed method now fail at the declaration with the new
234
+ `SymbolKeyedLifecycleError`, instead of a misleading `InvalidMetadataError` at resolve that blamed a `MetadataReader`
235
+ the caller never configured. The lifecycle reader keys methods by their string name, so a symbol-keyed method could
236
+ never be found again; the decorator now rejects it up front, where the mistake is.
237
+
238
+ - [#957](https://github.com/codefastlabs/codefast/pull/957) `MissingDecoratorMetadataError` suggests installing `Symbol.metadata` as
239
+ `(Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata")`, which type-checks in a `.ts` file; the
240
+ bare `Symbol.metadata ??= …` it quoted failed with TS2540, since TypeScript declares the symbol `readonly`.
241
+
242
+ - [#933](https://github.com/codefastlabs/codefast/pull/933) A rejected async `onActivation` on a transient `toDynamic` binding — per-binding or container-level — no longer surfaces
243
+ as an unhandled rejection: the sync resolve still throws `AsyncActivationError`, and the hook's promise is adopted as it
244
+ already was on every other lane.
245
+
246
+ - [#965](https://github.com/codefastlabs/codefast/pull/965) The documented TypeScript floor is now 7, up from 5.9: TypeScript 7 is the one compiler that type-checks the package and
247
+ emits its published declarations, so it is the floor they support. `README.md`, `SPEC.md` and `DECISIONS.md` state it;
248
+ no code or declaration changed.
249
+
250
+ - [#940](https://github.com/codefastlabs/codefast/pull/940) `unbind(id)` and `unbindAsync(id)` now tear down a binding that a later last-wins bind displaced: its cached singleton
251
+ is deactivated, and a displaced constant runs its `onDeactivation`, at that call instead of waiting for `dispose()`. The
252
+ id lookup asked only the registry, which a displaced binding had already left, so the call did nothing. The unbind also
253
+ invalidates what the displacing chain holds parked, so a later `when*()` refinement of that chain can no longer restore
254
+ the unbound binding.
255
+
256
+ - [#939](https://github.com/codefastlabs/codefast/pull/939) Unloading a module now tears down a binding it registered that a later last-wins bind displaced: a displaced cached
257
+ singleton is deactivated, and a displaced constant runs its `onDeactivation`, at that `unload()`/`unloadAsync()` instead
258
+ of waiting for `dispose()`. Unload looked each of the module's bindings up in the registry, which a displaced binding
259
+ had already left, so it was skipped. The unload also invalidates what the displacing chain holds parked, so a later
260
+ `when*()` refinement of that chain can no longer restore a binding of the unloaded module.
261
+
262
+ - [#923](https://github.com/codefastlabs/codefast/pull/923) `validate()` now reports a container-level `onDeactivation` hook that can never run. The builder type blocks
263
+ `onDeactivation` on `scoped`/`transient` bindings, but `container.onDeactivation(token, handler)` takes any token with
264
+ no such gate, so a hook keyed to a token whose every binding is scoped or transient used to pass validation silently.
265
+ `validate()` now raises `UnreachableLifecycleHookError` for it, reusing the mechanism that already catches a hook on an
266
+ unbound token. A token that also has a singleton or constant binding, and any `onActivation` hook, stay valid.
267
+
3
268
  ## 0.10.1
4
269
 
5
270
  ### Patch Changes
package/README.md CHANGED
@@ -35,9 +35,14 @@ its keep as the dependency graph grows.
35
35
  pnpm add @codefast/di
36
36
  ```
37
37
 
38
- `@codefast/di` requires Node.js 22.12 or later and TypeScript 5.9 or later, with native Stage 3 decorators. Leave
39
- `experimentalDecorators` off — it's off by default. The package is published on 0.x and versioned on its own track:
40
- breaking changes ship as minor versions, so pin the minor version when you need stability.
38
+ `@codefast/di` runs on Node.js 24 or later and in Chrome and Edge 136, Firefox 136, or Safari 18.4 or later
39
+ ([support policy](../../SUPPORT.md#browsers)). It requires TypeScript 7 or later, with native Stage 3 decorators. Leave
40
+ `experimentalDecorators` off — it's off by default. Its declarations use explicit resource management, which no numbered
41
+ `lib` declares yet, so your program needs those types: `@types/node` 24 or later loads them, and any other program adds
42
+ `ESNext.Disposable` to `lib` once its runtime ships explicit resource management. A browser program that targets Safari,
43
+ which has not shipped it, keeps `skipLibCheck` on and calls `dispose()` instead of `await using`. The package is
44
+ published on 0.x and versioned on its own track: breaking changes ship as minor versions, so pin the minor version when
45
+ you need stability.
41
46
 
42
47
  ## Quick start
43
48
 
@@ -282,7 +287,8 @@ const byHand = runWithContainer(container, () => new Controller());
282
287
 
283
288
  An accessor resolves from the container that's constructing the instance. When something else owns the `new` — a router,
284
289
  an ORM, a test helper — open that context with `runWithContainer`. Without one, the accessor throws
285
- `MissingContainerContextError`. `getActiveContainer()` reads the open context.
290
+ `MissingContainerContextError`. `getActiveContainer()` reads the open context. `runWithContainer` is **synchronous
291
+ only**: the context does not survive an `await`, so a callback returning a `Promise` resolves to `never`.
286
292
 
287
293
  ### Auto-registration
288
294
 
@@ -384,6 +390,62 @@ const container = Container.fromModules(AppModule);
384
390
  `Container.fromModulesAsync` or `container.loadAsync`. Modules are ref-counted: loading one twice counts once, and
385
391
  `unload` removes its bindings only when the count reaches zero.
386
392
 
393
+ A module with no logic of its own can be written as a list instead. Each `binding()` states one binding with the fluent
394
+ chain's own vocabulary, is typed exactly as the chain is, and is checked where it is written:
395
+
396
+ ```ts
397
+ import { binding, Module } from "@codefast/di";
398
+
399
+ const InfrastructureModule = Module.fromBindings("app:Infra", [
400
+ binding(LoggerToken, { toConstantValue: console }),
401
+ binding(UserRepository, { toSelf: true, scope: "singleton" }),
402
+ binding(CacheToken, { toDynamic: () => new Map(), whenNamed: "session", scope: "scoped" }),
403
+ ]);
404
+ ```
405
+
406
+ It loads exactly as the equivalent `Module.create` would, and never slower: each binding registers in its final shape,
407
+ so one with a slot, a scope or a hook skips the chain steps a fluent setup pays on every load. A declared module mixes
408
+ into a fluent one with `api.import(...)`.
409
+
410
+ ## Testing
411
+
412
+ Give every test its own container and dispose it afterwards. A container shared across tests carries cached singletons
413
+ from one into the next.
414
+
415
+ ```ts
416
+ let container: Container;
417
+
418
+ beforeEach(() => {
419
+ container = Container.create();
420
+ });
421
+
422
+ afterEach(async () => {
423
+ await container.dispose();
424
+ });
425
+ ```
426
+
427
+ To swap one binding out of a module you already loaded, bind it at a **child** container — the child has no binding of
428
+ its own yet, and resolution prefers the child over its parent:
429
+
430
+ ```ts
431
+ const testContainer = Container.fromModules(AppModule).createChild();
432
+ testContainer.bind(DbToken).toConstantValue(fakeDatabase);
433
+ ```
434
+
435
+ `rebind()` is for replacing a binding that already exists in the **same** container — it throws
436
+ `RebindUnboundTokenError` when the token is only bound at a parent, so it is the wrong tool for the override above.
437
+
438
+ To construct a class the container was never told about — plain JavaScript, generated code, a class you cannot decorate
439
+ — supply the metadata yourself. Pass the reader to `Container.create`: a reader bound through `MetadataReaderToken` on
440
+ the container you are about to use is read too late, because the resolver was built with the container.
441
+
442
+ ```ts
443
+ const container = Container.create({ metadataReader: customReader });
444
+ ```
445
+
446
+ For unit tests where every collaborator should be a mock, [`@codefast/di-testing`](../di-testing/README.md) builds the
447
+ mocks from the class's own declared dependencies, through a real container.
448
+
387
449
  ## Errors
388
450
 
389
451
  Every error extends `DiError` and carries a stable `code`, so you can branch on the code rather than the message:
@@ -402,7 +464,8 @@ Every error extends `DiError` and carries a stable `code`, so you can branch on
402
464
  | `DisposedContainerError` | `DISPOSED_CONTAINER` | A disposed container is used |
403
465
 
404
466
  The full taxonomy — including `MissingMetadataError`, `InvalidMetadataError`, `RebindUnboundTokenError`,
405
- `AsyncModuleLoadError`, and the rest — is exported from the root entry and from `@codefast/di/errors/errors`.
467
+ `AsyncModuleLoadError`, `InvalidBindingDeclarationError`, and the rest — is exported from the root entry and from
468
+ `@codefast/di/errors/errors`.
406
469
 
407
470
  ## Subpath exports
408
471
 
@@ -426,7 +489,7 @@ alongside the method that produced them. Run it yourself rather than taking any
426
489
  - [Rendered docs on codefastlabs.com](https://codefastlabs.com/docs/di)
427
490
  - [`SPEC.md`](./SPEC.md) — the behavioural contract: public API, semantics, and errors.
428
491
  - [`ARCHITECTURE.md`](./ARCHITECTURE.md) — the internal shape and the invariants the hot paths depend on.
429
- - [`LEARNING.md`](./LEARNING.md) — a guided tour of the techniques the engine applies, pointing at the code.
492
+ - [`DECISIONS.md`](./DECISIONS.md) — why the API looks like this, and what it did not take from InversifyJS v8.
430
493
  - [`examples/`](./examples/README.md) — runnable examples from basic tokens to a multi-file Ports & Adapters app.
431
494
  - [`CONTRIBUTING.md`](./CONTRIBUTING.md) — the package workflow: build, exports mirror, tests, and the perf guard.
432
495
  - [`CHANGELOG.md`](./CHANGELOG.md) — release history.
@@ -16,11 +16,16 @@ export interface AmbientResolution {
16
16
  resolveOptional<Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value | undefined;
17
17
  }
18
18
  /**
19
- * Runs a callback with the given container installed as the ambient one and returns its result.
19
+ * Runs a synchronous callback with the given container installed as the ambient one and returns its result.
20
+ *
21
+ * @remarks The context lives in a module-level variable restored in a `finally`, so it lasts only the
22
+ * synchronous run of `fn` — it does not survive an `await`. A callback returning a `Promise` is a
23
+ * mistake the return type flags: it resolves to `never`, and the ambient context would already be torn
24
+ * down by the time the promise settled.
20
25
  *
21
26
  * @since 0.3.16-canary.0
22
27
  */
23
- export declare function runWithContainer<Result>(container: Container, fn: () => Result): Result;
28
+ export declare function runWithContainer<Result>(container: Container, fn: () => Result): Result extends Promise<unknown> ? never : Result;
24
29
  /**
25
30
  * Engine-internal variant of {@link runWithContainer} that also installs a path-continuing resolver.
26
31
  *
@@ -1,7 +1,12 @@
1
1
  let activeContainer;
2
2
  let activeResolution;
3
3
  /**
4
- * Runs a callback with the given container installed as the ambient one and returns its result.
4
+ * Runs a synchronous callback with the given container installed as the ambient one and returns its result.
5
+ *
6
+ * @remarks The context lives in a module-level variable restored in a `finally`, so it lasts only the
7
+ * synchronous run of `fn` — it does not survive an `await`. A callback returning a `Promise` is a
8
+ * mistake the return type flags: it resolves to `never`, and the ambient context would already be torn
9
+ * down by the time the promise settled.
5
10
  *
6
11
  * @since 0.3.16-canary.0
7
12
  */
@@ -6,7 +6,8 @@
6
6
  * binding in the process shares one hidden class; the return type of each step is what pins the
7
7
  * chain's order, so no runtime check has to.
8
8
  */
9
- import type { AliasBindingBuilder, BindingBuilder, BindingSlot, BindToBuilder, ConstantBindingBuilder, ScopedBindingBuilder, SingletonBindingBuilder, SingletonLifecycleBuilder, TransientBindingBuilder } from "#core/binding";
9
+ import type { AliasBindingBuilder, Binding, BindingBuilder, BindingSlot, BindToBuilder, ConstantBindingBuilder, ScopedBindingBuilder, SingletonBindingBuilder, SingletonLifecycleBuilder, TransientBindingBuilder } from "#core/binding";
10
+ import type { DeclaredBinding } from "#core/binding-declaration";
10
11
  import type { BindingRegistry } from "#core/registry";
11
12
  import type { BindingTag } from "#core/tag";
12
13
  import type { Token } from "#core/token";
@@ -25,6 +26,12 @@ export interface BindingRegistration {
25
26
  readonly registry: BindingRegistry;
26
27
  readonly scope: ScopeManager;
27
28
  readonly moduleBindingIds: Array<BindingIdentifier> | undefined;
29
+ /** Runs for a binding this registration displaces, instead of parking it for a later restore. */
30
+ readonly deactivateDisplaced?: ((binding: Binding) => void) | undefined;
31
+ /** Notes a parked displaced binding so the container can still tear it down if no restore reclaims it. */
32
+ readonly onDisplaced?: ((binding: Binding) => void) | undefined;
33
+ /** Undoes {@link BindingRegistration.onDisplaced} when a refinement restores the binding to the registry. */
34
+ readonly onRestored?: ((binding: Binding) => void) | undefined;
28
35
  }
29
36
  /**
30
37
  * The one builder behind `bind()` and every `to*()` return type — and the binding it registers.
@@ -42,6 +49,9 @@ export declare class BindingChain<Value, Names extends string = string> implemen
42
49
  readonly identifier: BindingIdentifier;
43
50
  inFlight: boolean;
44
51
  frame: ResolutionFrame | undefined;
52
+ rootContext: ResolutionContext | undefined;
53
+ activationStamp: number;
54
+ registrationOrder: number;
45
55
  instance: unknown;
46
56
  readonly token: Token<Value, Names> | Constructor<Value>;
47
57
  slot: BindingSlot;
@@ -55,6 +65,14 @@ export declare class BindingChain<Value, Names extends string = string> implemen
55
65
  activationHook: ActivationHandler<Value> | undefined;
56
66
  deactivationHook: DeactivationHandler<Value> | undefined;
57
67
  constructor(token: Token<Value, Names> | Constructor<Value>, registration: BindingRegistration);
68
+ /**
69
+ * Registers a declared module's bindings in list order, each a chain of its own that no step
70
+ * will ever refine.
71
+ *
72
+ * @remarks A displaced binding is handled as `to*()` handles it, never parked: nothing refines the
73
+ * chain, so nothing could restore it.
74
+ */
75
+ static registerDeclared(declarations: ReadonlyArray<DeclaredBinding>, registration: BindingRegistration): void;
58
76
  to(type: Constructor<Value>): BindingBuilder<Value, Names>;
59
77
  toSelf(): BindingBuilder<Value, Names>;
60
78
  toConstantValue(value: Value): ConstantBindingBuilder<Value, Names>;
@@ -1,21 +1,9 @@
1
- import { clearBindingFrame, createBindingSlot, DEFAULT_BINDING_SLOT, generateBindingId, NO_INSTANCE, } from "#core/binding";
1
+ import { clearBindingFrame, NO_ACTIVATION_STAMP, DEFAULT_BINDING_SLOT, generateBindingId, NO_INSTANCE, UNREGISTERED_ORDER, withSlotCriterion, } from "#core/binding";
2
2
  import { mergingConstraintRequirements } from "#core/constraint-requirement";
3
3
  import { slotName } from "#core/tag";
4
4
  import { tokenName } from "#core/token";
5
5
  import { ChainAlreadyRegisteredError, ChainNotRegisteredError, ManyBindingSlotError, SelfBindingRequiresClassError, } from "#errors/errors";
6
6
  import { normalizeToDescriptor } from "#injection/descriptor";
7
- /** One criterion per key: re-tagging the same key replaces it rather than asking for both values. */
8
- function updateSlotTag(slot, criterion) {
9
- const tags = [...slot.tags];
10
- const existingIndex = tags.findIndex((existing) => existing.key === criterion.key);
11
- if (existingIndex === -1) {
12
- tags.push(criterion);
13
- }
14
- else {
15
- tags[existingIndex] = criterion;
16
- }
17
- return createBindingSlot(tags);
18
- }
19
7
  // ── BindingChain ─────────────────────────────────────────────────────────────────────────────────────────────────────
20
8
  /**
21
9
  * The one builder behind `bind()` and every `to*()` return type — and the binding it registers.
@@ -33,6 +21,9 @@ export class BindingChain {
33
21
  identifier = generateBindingId();
34
22
  inFlight = false;
35
23
  frame = undefined;
24
+ rootContext = undefined;
25
+ activationStamp = NO_ACTIVATION_STAMP;
26
+ registrationOrder = UNREGISTERED_ORDER;
36
27
  instance = NO_INSTANCE;
37
28
  token;
38
29
  slot = DEFAULT_BINDING_SLOT;
@@ -76,9 +67,70 @@ export class BindingChain {
76
67
  this.kind = kind;
77
68
  this.scope = scope;
78
69
  this.#isRegistered = true;
79
- this.#commit(undefined);
70
+ // A fresh registration parks nothing yet and restores nothing: one add, then the version.
71
+ const registration = this.#registration;
72
+ const registry = registration.registry;
73
+ const displaced = registry.add(this.#binding);
74
+ if (displaced !== undefined) {
75
+ if (registration.deactivateDisplaced !== undefined) {
76
+ registration.deactivateDisplaced(displaced);
77
+ }
78
+ else {
79
+ this.#displacedByChain = [displaced];
80
+ registration.onDisplaced?.(displaced);
81
+ }
82
+ }
83
+ if (registration.moduleBindingIds !== undefined) {
84
+ registration.moduleBindingIds.push(this.identifier);
85
+ }
86
+ this.#versionAfterLastWrite = registry.version;
80
87
  return this;
81
88
  }
89
+ /**
90
+ * Registers a declared module's bindings in list order, each a chain of its own that no step
91
+ * will ever refine.
92
+ *
93
+ * @remarks A displaced binding is handled as `to*()` handles it, never parked: nothing refines the
94
+ * chain, so nothing could restore it.
95
+ */
96
+ static registerDeclared(declarations, registration) {
97
+ const { registry, moduleBindingIds, deactivateDisplaced, onDisplaced } = registration;
98
+ for (let index = 0; index < declarations.length; index += 1) {
99
+ const declaration = declarations[index];
100
+ const binding = BindingChain.#fromDeclaration(declaration, registration);
101
+ const displaced = registry.add(binding);
102
+ if (displaced !== undefined) {
103
+ if (deactivateDisplaced === undefined) {
104
+ onDisplaced?.(displaced);
105
+ }
106
+ else {
107
+ deactivateDisplaced(displaced);
108
+ }
109
+ }
110
+ moduleBindingIds?.push(binding.identifier);
111
+ }
112
+ }
113
+ /**
114
+ * A registered chain in the shape a declaration's steps would have left.
115
+ *
116
+ * @remarks Typed `unknown` like the declaration it copies, so the value type is never asserted here.
117
+ */
118
+ static #fromDeclaration(declaration, registration) {
119
+ const chain = new BindingChain(declaration.token, registration);
120
+ chain.kind = declaration.kind;
121
+ chain.slot = declaration.slot;
122
+ chain.predicate = declaration.predicate;
123
+ chain.isMany = declaration.isMany;
124
+ chain.scope = declaration.scope;
125
+ chain.target = declaration.target;
126
+ chain.factory = declaration.factory;
127
+ chain.deps = declaration.deps;
128
+ chain.value = declaration.value;
129
+ chain.activationHook = declaration.activationHook;
130
+ chain.deactivationHook = declaration.deactivationHook;
131
+ chain.#isRegistered = true;
132
+ return chain.#binding;
133
+ }
82
134
  // ── Registration ───────────────────────────────────────────────────────────────────────────────────────────────────
83
135
  to(type) {
84
136
  this.#requireUnregistered();
@@ -195,7 +247,7 @@ export class BindingChain {
195
247
  if (this.isMany) {
196
248
  throw new ManyBindingSlotError(tokenName(this.token));
197
249
  }
198
- return this.#reslot(updateSlotTag(this.slot, criterion), this.predicate);
250
+ return this.#reslot(withSlotCriterion(this.slot, criterion), this.predicate);
199
251
  }
200
252
  many() {
201
253
  this.#requireRegistered();
@@ -281,7 +333,13 @@ export class BindingChain {
281
333
  }
282
334
  const displaced = registry.add(registered);
283
335
  if (displaced !== undefined) {
284
- (this.#displacedByChain ??= []).push(displaced);
336
+ if (this.#registration.deactivateDisplaced !== undefined && rewrite === undefined) {
337
+ this.#registration.deactivateDisplaced(displaced);
338
+ }
339
+ else {
340
+ (this.#displacedByChain ??= []).push(displaced);
341
+ this.#registration.onDisplaced?.(displaced);
342
+ }
285
343
  }
286
344
  if (rewrite !== undefined && this.#displacedByChain !== undefined) {
287
345
  this.#restoreNonConflicting(this.#displacedByChain);
@@ -298,6 +356,7 @@ export class BindingChain {
298
356
  // the candidate stays parked.
299
357
  if (!this.#registration.registry.hasSlotOccupant(candidate)) {
300
358
  this.#registration.registry.add(candidate);
359
+ this.#registration.onRestored?.(candidate);
301
360
  displaced.splice(index, 1);
302
361
  }
303
362
  }