@codefast/di 0.12.0 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @codefast/di
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** `Container` no longer declares `[Symbol.dispose]`, and `SyncDisposalNotSupportedError` is removed. A
8
+ deactivation hook may be async, so a container disposes through `await using` or `dispose()` only, and a synchronous
9
+ `using` on one is now a compile error instead of a `SyncDisposalNotSupportedError` at runtime. Replace `using` with
10
+ `await using`, and drop any `instanceof SyncDisposalNotSupportedError` check.
11
+
12
+ - [#1024](https://github.com/codefastlabs/codefast/pull/1024) **Breaking:** `@postConstruct()` and `@preDestroy()` now reject a private (`#name`) method, as they already rejected a
13
+ static or symbol-keyed one. The lifecycle manager calls a hook by name on the instance, so a private hook passed
14
+ `validate()` and then failed at resolve with an `InvalidMetadataError` that blamed the metadata reader, or at
15
+ `dispose()` for `@preDestroy()`. Decorating one now throws the new `PrivateLifecycleMethodError` when the class is
16
+ defined. The decorators' types also accept only a public, instance, string-named method, and `@inject` only an instance
17
+ accessor, so TypeScript reports all of these at the decoration site (TS1240, TS1241). To run private code from a hook,
18
+ call it from a public one.
19
+
20
+ - [#1022](https://github.com/codefastlabs/codefast/pull/1022) `TokenNotBoundError` and `NoMatchingBindingError` now carry `path`: the token names from the outermost request down to
21
+ the one that missed, alias hops included. A miss below the token asked for ends its message with that path —
22
+ `Path: UserService → UserRepository → Database → app:Logger` — so a deep graph says which consumer needed the binding; a
23
+ miss on the token asked for keeps its message as it was. Every lane reports the same path: sync and async, generated
24
+ plans, factory bodies, and `explain()` for an ancestor that selects nothing.
25
+
26
+ - [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** a `scoped` instance is now deactivated with the child container that cached it. Disposing that child runs
27
+ the binding's `onDeactivation`, the container-level `onDeactivation` hooks of the container that owns the binding, and
28
+ `@preDestroy()` for every scoped instance it cached, latest first and before its own singletons; unbinding a scoped
29
+ binding on the child that cached it deactivates that instance too. `.scoped()` now offers `onDeactivation`, and
30
+ `Module.fromBindings` accepts `onDeactivation` with `scope: "scoped"`, so per-request resources — a transaction, a
31
+ connection — can be released where they are declared. `validate()` no longer reports a deactivation hook on a
32
+ scoped-only token as unreachable. Code that relied on a scoped instance never being torn down, or that closed one by
33
+ hand after `dispose()`, should drop the manual close.
34
+
35
+ - [#1022](https://github.com/codefastlabs/codefast/pull/1022) **Breaking:** `validate()` now throws the error `resolve()` would for a singleton graph that cannot resolve — a required
36
+ dependency nothing binds (`TokenNotBoundError`), a slot nothing matches (`NoMatchingBindingError`), or a cycle
37
+ (`CircularDependencyError`) — with the path from that singleton down to the problem. A parent-owned singleton reached
38
+ from a child is now read from the parent's chain, as `resolve` reads it. It still leaves alone what it cannot decide: a
39
+ transient or scoped consumer, whose dependencies a child container may bind, and a request only a `when()` candidate
40
+ matches. A container that passed `validate()` while holding such a graph now fails it; bind the missing dependency, or
41
+ mark it `optional()` when it may be absent.
42
+
3
43
  ## 0.12.0
4
44
 
5
45
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @codefast/di
2
2
 
3
- Lightweight, type-safe dependency injection for TypeScript applications, built on TC39 Stage 3 decorators with no
3
+ Type-safe dependency injection for TypeScript applications, with zero dependencies, built on standard decorators with no
4
4
  `reflect-metadata` and no `experimentalDecorators`.
5
5
 
6
6
  [![npm version](https://img.shields.io/npm/v/@codefast/di)](https://www.npmjs.com/package/@codefast/di)
@@ -20,12 +20,12 @@ its keep as the dependency graph grows.
20
20
 
21
21
  - **Typed tokens.** `Token<Value>` flows through every `bind → resolve` path, so `resolve()` returns exactly the type
22
22
  you registered.
23
- - **Native Stage 3 decorators.** `@injectable`, `inject`, `optional`, `injectAll`, `@postConstruct`, and `@preDestroy`
24
- declare dependencies explicitly — no runtime reflection.
23
+ - **Standard decorators.** `@injectable`, `inject`, `optional`, `injectAll`, `@postConstruct`, and `@preDestroy` declare
24
+ dependencies explicitly — no runtime reflection.
25
25
  - **Fluent bindings.** Constants, classes, sync and async factories, aliases, named/tagged/predicate constraints, and
26
26
  lifecycle hooks compose in one invariant chain order.
27
27
  - **Scopes with validation.** Choose `singleton`, `scoped`, or `transient`, and call `validate()` to catch captive
28
- dependencies before the first request.
28
+ dependencies, missing bindings and cycles before the first request.
29
29
  - **Modules and introspection.** Bundle bindings into reusable modules, and inspect a container or render its dependency
30
30
  graph as DOT, Mermaid, Cytoscape, or React Flow.
31
31
 
@@ -36,7 +36,7 @@ pnpm add @codefast/di
36
36
  ```
37
37
 
38
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
39
+ ([support policy](../../SUPPORT.md#browsers)). It requires TypeScript 7 or later, with standard decorators. Leave
40
40
  `experimentalDecorators` off — it's off by default. Its declarations use explicit resource management, which no numbered
41
41
  `lib` declares yet, so your program needs those types: `@types/node` 24 or later loads them, and any other program adds
42
42
  `ESNext.Disposable` to `lib` once its runtime ships explicit resource management. A browser program that targets Safari,
@@ -44,6 +44,67 @@ which has not shipped it, keeps `skipLibCheck` on and calls `dispose()` instead
44
44
  published on 0.x and versioned on its own track: breaking changes ship as minor versions, so pin the minor version when
45
45
  you need stability.
46
46
 
47
+ ### Toolchain
48
+
49
+ No JavaScript engine runs decorators natively yet — the TC39 proposal went back to Stage 2.7 in May 2026 — so a
50
+ decorated class always passes through a transform that lowers it. What that takes depends on the toolchain:
51
+
52
+ | Toolchain | What it needs |
53
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
54
+ | tsx | Nothing: it lowers standard decorators for the Node it runs on. |
55
+ | esbuild | A `target` below `esnext`, such as `es2024` or `node24`: at its default, `esnext`, decorators pass through. |
56
+ | Vite 8, and Vitest on it | The Babel decorators plugin: their Oxc transform lowers only `experimentalDecorators`. |
57
+ | SWC 1.15.21 or later | `jsc.parser.decorators: true` and `jsc.transform.decoratorVersion: "2023-11"`. |
58
+ | `tsc`, then Node directly | A `target` below `ESNext`, and `Symbol.metadata` installed before any decorated class is defined. |
59
+
60
+ For Vite and Vitest, add `@babel/core`, `@babel/plugin-proposal-decorators` and `@rolldown/plugin-babel` as dev
61
+ dependencies, then the decorators preset to the config — `vitest.config.ts` takes the same one:
62
+
63
+ ```ts
64
+ // vite.config.ts
65
+ import babel, { defineRolldownBabelPreset } from "@rolldown/plugin-babel";
66
+ import { defineConfig } from "vite";
67
+
68
+ export default defineConfig({
69
+ plugins: [
70
+ babel({
71
+ presets: [
72
+ defineRolldownBabelPreset({
73
+ preset: () => ({ plugins: [["@babel/plugin-proposal-decorators", { version: "2023-11" }]] }),
74
+ // Every decorator holds an `@`, so Babel skips only files that cannot hold one.
75
+ rolldown: { filter: { code: "@" } },
76
+ }),
77
+ ],
78
+ }),
79
+ ],
80
+ });
81
+ ```
82
+
83
+ The filter is what keeps Babel off the files without decorators; the plugin applies it only when every preset carries
84
+ one and `plugins` is left empty. Keep it the plain `"@"`: a narrower pattern can miss a legal position such as
85
+ `export @injectable() class`, and a missed file ships its decorators raw. Beside other presets, list this one last,
86
+ since Babel runs presets last to first.
87
+
88
+ > **Warning.** Without the plugin the decorators pass through untouched, and nothing flags it at build time:
89
+ > `vite build` succeeds, then the bundle throws `SyntaxError: Invalid or unexpected token` as it loads — the same error
90
+ > Vitest reports for a test file that imports a decorated class.
91
+
92
+ `tsc` leaves decorators in its output at `target: "ESNext"`. At any lower target it lowers them, but on a runtime
93
+ without `Symbol.metadata` it hands them no metadata object, which every decorator here reports as
94
+ `MissingDecoratorMetadataError`. Install the symbol in a module the entry point imports first:
95
+
96
+ ```ts
97
+ (Symbol as { metadata?: symbol }).metadata ??= Symbol.for("Symbol.metadata");
98
+ ```
99
+
100
+ Not every toolchain lowers them yet. Node's own type stripping rejects decorators; Next.js cannot select the `2023-11`
101
+ version (vercel/next.js#96328 adds it); and Bun 1.4.2 and earlier inject the wrong values, silently, into a module that
102
+ holds two decorated classes (oven-sh/bun#28316).
103
+
104
+ [The specification → tsconfig setup](./spec/decorators.md#tsconfig-setup) covers the compiler options. And
105
+ [decorators](#decorators) are optional: an app wired through `toResolved`, `toDynamic` and `Module.fromBindings` needs
106
+ none of this.
107
+
47
108
  ## Quick start
48
109
 
49
110
  ```ts
@@ -98,9 +159,9 @@ A class constructor works as a key too: `container.bind(UserService).toSelf()`,
98
159
 
99
160
  ## Bindings
100
161
 
101
- A binding describes how the container produces a value for a token. You declare it as a chain, in one fixed order: a
102
- strategy (the `to*` step, which produces the value), then optional constraints, a scope, and lifecycle hooks. Only
103
- `container.bind(key).to*(…)` is required; everything after it is optional.
162
+ A binding describes how the container produces a value for a token. You declare it as a chain, in one fixed order:
163
+ optional [constraints](#constraints), then a strategy (the `to*` step, which produces the value and registers the
164
+ binding), then an optional scope and lifecycle hooks. Only `container.bind(key).to*(…)` is required.
104
165
 
105
166
  | Strategy | Produces |
106
167
  | --------------------------------- | ----------------------------------------------------------------- |
@@ -134,7 +195,7 @@ no scope produces a new instance every time.
134
195
  | Scope | Lifetime |
135
196
  | -------------- | ---------------------------------------------------------------------------------------- |
136
197
  | `.singleton()` | One instance for the container that owns the binding; children resolve the same instance |
137
- | `.scoped()` | One instance per child container — resolving from a container with no child scope throws |
198
+ | `.scoped()` | One instance per child container, deactivated with it — resolving outside a child throws |
138
199
  | `.transient()` | A new instance on every resolution — the default |
139
200
 
140
201
  ```ts
@@ -148,9 +209,10 @@ container.bind(RequestContextToken).toSelf().scoped();
148
209
  ### Lifecycle hooks
149
210
 
150
211
  A lifecycle hook runs your code as an instance is created or torn down. `.onActivation(fn)` runs right after an instance
151
- is created, and may replace it; `.onDeactivation(fn)` runs when the owning container is disposed or the binding is
152
- unbound. Both are also available container-wide, through `container.onActivation(token, fn)` and
153
- `container.onDeactivation(token, fn)`:
212
+ is created, and may replace it; `.onDeactivation(fn)` runs as the instance's lifetime ends — a singleton's when its
213
+ container is disposed or the binding is unbound, a scoped instance's when the child container that cached it is
214
+ disposed. A transient instance has no deactivation. Both hooks are also available container-wide, through
215
+ `container.onActivation(token, fn)` and `container.onDeactivation(token, fn)`:
154
216
 
155
217
  ```ts
156
218
  container
@@ -212,6 +274,19 @@ container.resolve(StorageToken, { tag: Provider.of("s3") }); // → S3Storage
212
274
  `injectAll` alike. Chain `.whenTagged(...)` once per criterion a slot carries, and request several at once with
213
275
  `{ tags: [...] }`.
214
276
 
277
+ **One binding per slot; a collection with `many()`.** Binding a token again in a slot it already fills replaces the
278
+ earlier binding — `bind` is last-wins per slot, so `resolveAll` then finds only the newer one. To register several
279
+ implementations for `resolveAll` and `injectAll` to gather, mark each one a collection member with `.many()`:
280
+
281
+ ```ts
282
+ container.bind(PluginToken).many().to(AuditPlugin);
283
+ container.bind(PluginToken).many().to(MetricsPlugin);
284
+
285
+ container.resolveAll(PluginToken); // → an AuditPlugin and a MetricsPlugin, in registration order
286
+ ```
287
+
288
+ A member keeps the default slot and is never what a single `resolve` selects.
289
+
215
290
  **Predicate constraints — graph-aware selection.** When a slot isn't enough — when you need to choose based on _who_ is
216
291
  resolving — pass a predicate to `.when(ctx => boolean)`. It runs at resolve time, after slot matching. These ready-made
217
292
  predicates ship from the root entry:
@@ -252,7 +327,7 @@ class UserRepository {
252
327
  constructor(
253
328
  private readonly db: Database,
254
329
  private readonly cache: Cache | undefined,
255
- private readonly plugins: Array<Plugin>,
330
+ private readonly plugins: ReadonlyArray<Plugin>,
256
331
  private readonly audit: Logger,
257
332
  ) {}
258
333
 
@@ -349,17 +424,21 @@ parent's singletons, while each `scoped` binding gets a fresh instance per child
349
424
 
350
425
  A captive dependency is a long-lived binding that holds a shorter-lived one — a `singleton` that depends on a `scoped`
351
426
  or `transient` binding — which silently freezes that dependency for the singleton's whole life. `validate()` fails fast
352
- on captive dependencies, and on constraints no request can satisfy, before the first resolve.
427
+ on captive dependencies, and on constraints no request can satisfy, before the first resolve. It also throws the error
428
+ `resolve()` would for a singleton graph that cannot resolve: a required dependency nothing binds, a slot nothing
429
+ matches, or a cycle.
353
430
 
354
431
  ```ts
355
- container.validate(); // throws ScopeViolationError on the first violation
432
+ container.validate(); // throws on the first violation — ScopeViolationError, TokenNotBoundError, …
356
433
  ```
357
434
 
435
+ A transient or scoped consumer's dependencies are left to `resolve()`, because a child container may still bind them.
436
+
358
437
  ### Disposal
359
438
 
360
439
  `Container` implements `AsyncDisposable`, so `await using` runs every deactivation hook automatically as the block
361
- exits. Synchronous `using` isn't supported — `onDeactivation` may be async — and `Symbol.dispose` throws
362
- `SyncDisposalNotSupportedError`.
440
+ exits, and disposing a child deactivates the scoped instances it cached. Synchronous `using` is a compile error: a
441
+ deactivation hook may be async, so the container implements `Symbol.asyncDispose` only.
363
442
 
364
443
  ### Introspection
365
444
 
@@ -475,6 +554,9 @@ Every error extends `DiError` and carries a stable `code`, so you can branch on
475
554
  | `MissingContainerContextError` | `MISSING_CONTAINER_CONTEXT` | An `@inject` accessor initializes with no container open |
476
555
  | `DisposedContainerError` | `DISPOSED_CONTAINER` | A disposed container is used |
477
556
 
557
+ A miss below the token you asked for names its path: `TokenNotBoundError` and `NoMatchingBindingError` carry `path`, and
558
+ the message ends with it — `Path: UserService → UserRepository → app:Logger`.
559
+
478
560
  The full taxonomy — including `MissingMetadataError`, `InvalidMetadataError`, `RebindUnboundTokenError`,
479
561
  `AsyncModuleLoadError`, `InvalidBindingDeclarationError`, and the rest — is exported from the root entry and from
480
562
  `@codefast/di/errors`.
@@ -495,8 +577,9 @@ introspection under `introspection/*`: `@codefast/di/introspection/inspector`, `
495
577
  ## Benchmarks
496
578
 
497
579
  A first-party benchmark suite lives in the monorepo, at [`benchmarks/di`](../../benchmarks/di). It runs the same
498
- workloads through `@codefast/di`, InversifyJS, Awilix, and tsyringe, and its `RESULTS.md` ledger records the numbers
499
- alongside the method that produced them. Run it yourself rather than taking any figure on faith.
580
+ workloads through `@codefast/di`, InversifyJS, Awilix, tsyringe, Brandi, ditox, and injection-js, and its `RESULTS.md`
581
+ ledger records the numbers alongside the method that produced them. Run it yourself rather than taking any figure on
582
+ faith.
500
583
 
501
584
  ## Documentation
502
585
 
@@ -36,7 +36,6 @@ export interface Container {
36
36
  createChild(): Container;
37
37
  dispose(): Promise<void>;
38
38
  [Symbol.asyncDispose](): Promise<void>;
39
- [Symbol.dispose](): never;
40
39
  initializeAsync(): Promise<void>;
41
40
  validate(): void;
42
41
  has<Names extends string = string>(token: Token<unknown, Names> | Constructor, options?: NoInfer<ResolveOptions<Names>>): boolean;
@@ -6,14 +6,14 @@ import { isSyncModule, MODULE_SETUP } from "#core/module";
6
6
  import { BindingRegistry } from "#core/registry";
7
7
  import { advanceDisposeEpoch, disposeEpochRef } from "#core/state-epoch";
8
8
  import { tokenName } from "#core/token";
9
- import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, SyncDisposalNotSupportedError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors";
9
+ import { AsyncModuleLoadError, CircularDependencyError, DisposedContainerError, InternalError, RebindUnboundTokenError, ScopeViolationError, UnreachableConstraintError, UnreachableLifecycleHookError, } from "#errors";
10
10
  import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#injection/dependency-slot";
11
11
  import { buildDependencyGraph } from "#introspection/dependency-graph";
12
12
  import { RESOLUTION_DIAGNOSTICS } from "#introspection/diagnostics";
13
13
  import { explainRequest } from "#introspection/explanation";
14
14
  import { Inspector } from "#introspection/inspector";
15
15
  import { LifecycleManager } from "#lifecycle/hooks";
16
- import { ScopeManager } from "#lifecycle/scopes";
16
+ import { SCOPED_MISS, ScopeManager } from "#lifecycle/scopes";
17
17
  import { MetadataReaderToken } from "#metadata/reader-token";
18
18
  import { defaultMetadataReader } from "#metadata/symbol-reader";
19
19
  import { verifyingMetadataReader } from "#metadata/verifying-reader";
@@ -223,7 +223,10 @@ class DefaultContainer {
223
223
  else if (this.#owesConstantDeactivation(binding)) {
224
224
  (pairs ??= []).push(binding, binding.value);
225
225
  }
226
- this.#scope.deleteScoped(binding.scopedCacheKey);
226
+ const scopedInstance = this.#scope.takeScoped(binding.scopedCacheKey);
227
+ if (scopedInstance !== SCOPED_MISS) {
228
+ (pairs ??= []).push(binding, scopedInstance);
229
+ }
227
230
  return pairs;
228
231
  }
229
232
  /**
@@ -637,6 +640,11 @@ class DefaultContainer {
637
640
  await this.#scope.settleInflight();
638
641
  const reader = this.#getMetadataReader();
639
642
  const errors = [];
643
+ // A scoped instance may hold this container's singletons and never the reverse, so it goes first, latest first.
644
+ const scopedTeardown = this.#scope.deactivateScoped(errors);
645
+ if (scopedTeardown !== undefined) {
646
+ await scopedTeardown;
647
+ }
640
648
  // Iterate a copy: a deactivation handler is user code, and the live list is what
641
649
  // materializing or dropping a singleton mutates. Reverse materialization order, so a
642
650
  // dependent tears down before the dependencies it may still reach through.
@@ -684,9 +692,6 @@ class DefaultContainer {
684
692
  [Symbol.asyncDispose]() {
685
693
  return this.dispose();
686
694
  }
687
- [Symbol.dispose]() {
688
- throw new SyncDisposalNotSupportedError();
689
- }
690
695
  // ── Initialization ─────────────────────────────────────────────────────────────────────────────────────────────────
691
696
  async initializeAsync() {
692
697
  this.#assertNotDisposed();
@@ -729,8 +734,8 @@ class DefaultContainer {
729
734
  if (!this.#isBoundInChain(hookToken)) {
730
735
  throw new UnreachableLifecycleHookError(tokenName(hookToken), phase);
731
736
  }
732
- // A deactivation only ever runs for a singleton or constant, so a hook on a token whose every
733
- // binding is scoped or transient can never fire — the builder blocks it, `container.onDeactivation` cannot.
737
+ // A deactivation never runs for a transient, so a hook on a token whose every binding is transient
738
+ // can never fire — the builder blocks it, `container.onDeactivation` cannot.
734
739
  if (phase === "onDeactivation" && !this.#hasDeactivatableBindingInChain(hookToken)) {
735
740
  throw new UnreachableLifecycleHookError(tokenName(hookToken), phase, "no-deactivatable-binding");
736
741
  }
@@ -776,10 +781,10 @@ class DefaultContainer {
776
781
  const parent = this.#parent;
777
782
  return parent !== undefined && parent.#isBoundInChain(token);
778
783
  }
779
- // Whether the token has a binding a deactivation hook can run for — a singleton or a constant.
784
+ // Whether the token has a binding a deactivation hook can run for — anything but a transient.
780
785
  #hasDeactivatableBindingInChain(token) {
781
786
  for (const binding of this.#registry.getAll(token)) {
782
- if (effectiveBindingScope(binding) === "singleton") {
787
+ if (effectiveBindingScope(binding) !== "transient") {
783
788
  return true;
784
789
  }
785
790
  }
@@ -793,22 +798,21 @@ class DefaultContainer {
793
798
  return binding.kind === "class" || binding.kind === "resolved" || binding.kind === "resolved-async";
794
799
  }
795
800
  /**
796
- * DFS over explicit constructor / `toResolved*` dependency edges. Follows `toAlias` chains to the
797
- * terminal binding for scope checks.
801
+ * DFS over explicit constructor / `toResolved*` dependency edges, following `toAlias` chains to the terminal binding.
798
802
  *
799
- * @remarks A `toDynamic*` dependency is scope-checked like any other — its declared scope is what
800
- * makes it captive — but the DFS does not descend into the factory, whose body is opaque.
803
+ * @remarks Every binding reached is a singleton resolved from its owner's chain, so a required miss or a repeat
804
+ * on one path fails however the graph is entered; a `toDynamic*` edge is scope-checked, its factory not descended.
801
805
  */
802
806
  #validateSingletonBindingGraph(root, reader) {
803
807
  const rootName = tokenName(root.token);
804
- const dfs = (current, pathNames, pathBindingIds) => {
808
+ const dfs = (current, view, pathNames, pathBindingIds) => {
805
809
  if (pathBindingIds.has(current.identifier)) {
806
- return;
810
+ throw new CircularDependencyError(pathNames);
807
811
  }
808
812
  const extendedPathIds = new Set(pathBindingIds);
809
813
  extendedPathIds.add(current.identifier);
810
- for (const edge of this.#collectStaticDependencyEdges(current, reader)) {
811
- const { terminal, depTokenName } = edge;
814
+ for (const edge of this.#collectStaticDependencyEdges(current, view, reader, pathNames)) {
815
+ const { terminal, owner, depTokenName } = edge;
812
816
  const depScope = this.#validationScopeFromTerminal(terminal);
813
817
  if (depScope !== "singleton") {
814
818
  throw new ScopeViolationError({
@@ -820,11 +824,11 @@ class DefaultContainer {
820
824
  });
821
825
  }
822
826
  if (terminal.kind === "class" || terminal.kind === "resolved" || terminal.kind === "resolved-async") {
823
- dfs(terminal, [...pathNames, depTokenName], extendedPathIds);
827
+ dfs(terminal, owner, [...pathNames, depTokenName], extendedPathIds);
824
828
  }
825
829
  }
826
830
  };
827
- dfs(root, [rootName], new Set());
831
+ dfs(root, this.#resolver, [rootName], new Set());
828
832
  }
829
833
  /**
830
834
  * The scope this edge is judged against.
@@ -839,32 +843,35 @@ class DefaultContainer {
839
843
  }
840
844
  return terminal.scope;
841
845
  }
842
- #followAliasChainToTerminal(binding, options) {
846
+ /** Where an alias chain ends as `resolve` walks it from `view`: its terminal binding, or the names up to a miss. */
847
+ #followAliasChainToTerminal(entry, view, options) {
843
848
  const cyclePath = [];
844
849
  const seenAliasIds = new Set();
845
- let current = binding;
846
- while (current !== undefined && current.kind === "alias") {
847
- if (seenAliasIds.has(current.identifier)) {
850
+ let current = entry;
851
+ while (current.binding.kind === "alias") {
852
+ const alias = current.binding;
853
+ if (seenAliasIds.has(alias.identifier)) {
848
854
  throw new CircularDependencyError(cyclePath);
849
855
  }
850
- seenAliasIds.add(current.identifier);
851
- cyclePath.push(tokenName(current.token));
852
- const nextToken = current.target;
853
- const next = this.#resolver.peekBindingForValidate(nextToken, options);
856
+ seenAliasIds.add(alias.identifier);
857
+ cyclePath.push(tokenName(alias.token));
858
+ const next = view.peekBindingForValidate(alias.target, options);
854
859
  if (next === undefined) {
855
- return undefined;
860
+ return { found: false, missingToken: alias.target, hopNames: cyclePath };
856
861
  }
857
- current = next.binding;
862
+ current = next;
858
863
  }
859
- return current;
864
+ return { found: true, terminal: current.binding, owner: current.owner };
860
865
  }
861
- /** What one dependency could resolve to: every candidate for `injectAll`, else at most one. */
862
- #peekDependencyCandidates(dep, options) {
866
+ /** What one dependency could resolve to from `view`: every candidate for `injectAll`, else at most one. */
867
+ #peekDependencyCandidates(dep, view, options) {
863
868
  if (dep.multi) {
864
- return this.#resolver.peekCandidateBindingsForValidate(dep.token, options);
869
+ return view
870
+ .peekCandidateBindingsForValidate(dep.token, options)
871
+ .map((binding) => ({ binding, owner: view.ownerForValidate(binding) }));
865
872
  }
866
- const found = this.#resolver.peekBindingForValidate(dep.token, options);
867
- return found === undefined ? [] : [found.binding];
873
+ const found = view.peekBindingForValidate(dep.token, options);
874
+ return found === undefined ? [] : [found];
868
875
  }
869
876
  /** What a binding declares up front — a class's params, a factory's descriptors, else nothing. */
870
877
  #staticDependencies(binding, reader) {
@@ -876,15 +883,39 @@ class DefaultContainer {
876
883
  }
877
884
  return [];
878
885
  }
879
- #collectStaticDependencyEdges(binding, reader) {
886
+ /**
887
+ * The bindings a singleton's declared dependencies resolve to, throwing the error `resolve` would for a required one
888
+ * nothing selects.
889
+ */
890
+ #collectStaticDependencyEdges(binding, view, reader, pathNames) {
880
891
  const edges = [];
881
892
  for (const dep of this.#staticDependencies(binding, reader)) {
882
893
  // An optional dependency that is absent peeks no candidate; one that is bound is captured like any other.
883
894
  const depOptions = injectionSlotToResolveOptions(dep);
884
- for (const candidate of this.#peekDependencyCandidates(dep, depOptions)) {
885
- const terminal = this.#followAliasChainToTerminal(candidate, depOptions);
886
- if (terminal !== undefined) {
887
- edges.push({ terminal, depTokenName: tokenName(terminal.token) });
895
+ // A collection may be empty and an optional dependency absent, so only a required one can miss.
896
+ const required = !dep.multi && !dep.optional;
897
+ const candidates = this.#peekDependencyCandidates(dep, view, depOptions);
898
+ if (required && candidates.length === 0) {
899
+ const miss = view.missForValidate(dep.token, depOptions, [...pathNames, tokenName(dep.token)]);
900
+ if (miss !== undefined) {
901
+ throw miss;
902
+ }
903
+ }
904
+ for (const candidate of candidates) {
905
+ const end = this.#followAliasChainToTerminal(candidate, view, depOptions);
906
+ if (end.found) {
907
+ edges.push({ terminal: end.terminal, owner: end.owner, depTokenName: tokenName(end.terminal.token) });
908
+ continue;
909
+ }
910
+ if (required) {
911
+ const miss = view.missForValidate(end.missingToken, depOptions, [
912
+ ...pathNames,
913
+ ...end.hopNames,
914
+ tokenName(end.missingToken),
915
+ ]);
916
+ if (miss !== undefined) {
917
+ throw miss;
918
+ }
888
919
  }
889
920
  }
890
921
  }
@@ -96,11 +96,14 @@ export interface TransientBindingBuilder<Value> {
96
96
  id(): BindingIdentifier;
97
97
  }
98
98
  /**
99
- * The fluent chain after `scoped()`, sharing the `transient()` surface.
99
+ * The fluent chain after `scoped()`, where both lifecycle hooks stay available: a child deactivates what it cached.
100
100
  *
101
101
  * @since 0.3.16-canary.0
102
102
  */
103
- export interface ScopedBindingBuilder<Value> extends TransientBindingBuilder<Value> {
103
+ export interface ScopedBindingBuilder<Value> {
104
+ onActivation(fn: ActivationHandler<Value>): this;
105
+ onDeactivation(fn: DeactivationHandler<Value>): this;
106
+ id(): BindingIdentifier;
104
107
  }
105
108
  /**
106
109
  * The fluent chain a constant enters once a lifecycle hook is added.
@@ -23,11 +23,11 @@ type SlotDefinition<Names extends string> = ({
23
23
  } & Never<"whenNamed" | "whenTagged">);
24
24
  /** How long an instance lives, and the hooks that lifetime allows. */
25
25
  type LifetimeDefinition<Value> = {
26
- readonly scope: "singleton";
26
+ readonly scope: "singleton" | "scoped";
27
27
  readonly onActivation?: ActivationHandler<Value> | undefined;
28
28
  readonly onDeactivation?: DeactivationHandler<Value> | undefined;
29
29
  } | ({
30
- readonly scope?: "transient" | "scoped" | undefined;
30
+ readonly scope?: "transient" | undefined;
31
31
  readonly onActivation?: ActivationHandler<Value> | undefined;
32
32
  } & Never<"onDeactivation">);
33
33
  type BuiltDefinition<Value, Deps extends ReadonlyArray<InjectableDependency>> = ({
@@ -157,8 +157,8 @@ export function binding(key, definition) {
157
157
  if (kind === "alias" && (onActivation !== undefined || onDeactivation !== undefined)) {
158
158
  throw new InvalidBindingDeclarationError(name, "`toAlias` takes no lifecycle hook");
159
159
  }
160
- if (onDeactivation !== undefined && kind !== "constant" && scope !== "singleton") {
161
- throw new InvalidBindingDeclarationError(name, '`onDeactivation` needs `scope: "singleton"`');
160
+ if (onDeactivation !== undefined && kind !== "constant" && scope === "transient") {
161
+ throw new InvalidBindingDeclarationError(name, '`onDeactivation` needs `scope: "singleton"` or `scope: "scoped"`');
162
162
  }
163
163
  if (definition.many !== undefined && definition.many !== true) {
164
164
  throw new InvalidBindingDeclarationError(name, "`many` takes `true`");
@@ -2,7 +2,9 @@
2
2
  import type { Token } from "#core/token";
3
3
  import type { Constructor } from "#core/types";
4
4
  import type { InjectionDescriptor, InjectOptions } from "#injection/descriptor";
5
- type ClassAccessorDecorator<This, Value> = (target: ClassAccessorDecoratorTarget<This, Value>, context: ClassAccessorDecoratorContext<This, Value>) => ClassAccessorDecoratorResult<This, Value> | void;
5
+ type ClassAccessorDecorator<This, Value> = (target: ClassAccessorDecoratorTarget<This, Value>, context: ClassAccessorDecoratorContext<This, Value> & {
6
+ readonly static: false;
7
+ }) => ClassAccessorDecoratorResult<This, Value> | void;
6
8
  /**
7
9
  * Creates a dual-role value: an injection descriptor that also works as a class accessor decorator.
8
10
  *
@@ -1,14 +1,20 @@
1
- type MethodDecorator = (target: unknown, context: ClassMethodDecoratorContext) => void;
1
+ /** The only method a hook can be: the lifecycle reader calls it on the instance by its string name. */
2
+ type LifecycleMethodContext = ClassMethodDecoratorContext & {
3
+ readonly static: false;
4
+ readonly private: false;
5
+ readonly name: string;
6
+ };
7
+ type LifecycleMethodDecorator = (target: unknown, context: LifecycleMethodContext) => void;
2
8
  /**
3
9
  * Marks an instance method to run after the container constructs and wires the instance.
4
10
  *
5
11
  * @since 0.3.16-canary.0
6
12
  */
7
- export declare function postConstruct(): MethodDecorator;
13
+ export declare function postConstruct(): LifecycleMethodDecorator;
8
14
  /**
9
15
  * Marks an instance method to run when the instance's container or scope is disposed.
10
16
  *
11
17
  * @since 0.3.16-canary.0
12
18
  */
13
- export declare function preDestroy(): MethodDecorator;
19
+ export declare function preDestroy(): LifecycleMethodDecorator;
14
20
  export {};
@@ -1,12 +1,16 @@
1
1
  import { decoratorMetadataOf } from "#decorators/metadata-record";
2
- import { StaticMemberDecoratorError, SymbolKeyedLifecycleError } from "#errors";
2
+ import { PrivateLifecycleMethodError, StaticMemberDecoratorError, SymbolKeyedLifecycleError } from "#errors";
3
3
  import { LIFECYCLE_KEY } from "#metadata/keys";
4
4
  /** Records the decorated method under one lifecycle phase; both decorators differ only in that phase. */
5
5
  function recordLifecycleMethod(phase) {
6
+ // Typed wide on purpose: the checks below are what an untyped caller meets instead of the compiler.
6
7
  return function (target, context) {
7
8
  if (context.static) {
8
9
  throw new StaticMemberDecoratorError(phase, String(context.name));
9
10
  }
11
+ if (context.private) {
12
+ throw new PrivateLifecycleMethodError(phase, String(context.name));
13
+ }
10
14
  // The lifecycle reader keys methods by their string name, so a symbol-keyed method can never be
11
15
  // found again — fail here, where the declaration is, rather than at resolve.
12
16
  if (typeof context.name === "symbol") {