generating
Choosing a generator
In preference order:
g:resource— the default for almost all new models. It generates the controller, controller specs, and route scaffolding thatg:modeldoes not, and it's easier to delete an unused controller action later than to retrofit one.g:sti-child— for STI child models building on an existing STI base.g:model— only for models that will never be exposed via any API (join tables, audit logs).g:migration— for a database change that isn't accompanied by a new model.
Generated controller inheritance chain
Both the resource generator and the controller generator create base controllers in every nested directory along the path to the target controller being generated:
- The controller will extend the base controller in its directory
- The base controller will extend the base controller in its parent directory
- The base controller in the outermost controller directory should be one of the following: the
AuthedController,UnauthedController, orMaybeAuthedController(the default for generated routes is always the clientAuthedController, to ensure that generated routes never accidentally expose controllers at unauthenticated routes) - When generating within the
adminorinternalnamespace (e.g., viapnpm psy g:controller admin/content/lessons), the generator special-cases those namespaces and roots the chain atAdmin/AuthedControllerorInternal/AuthedControllerinstead of the clientAuthedController— each surface authenticates its own user type
By using resource and controller generators and following these conventions, access controls will be automatically applied to new resources. To apply different access controls to all controllers rooted at a particular directory, simply add a @BeforeAction method that calls this.forbidden() when the currently authenticated user does not have the necessary permissions.
Reparenting when a surface loosens auth
Because an existing namespace base is reused rather than regenerated, reparenting one is a one-time edit — controllers generated into that namespace afterward inherit the new base automatically. This is how you scaffold a surface that is intentionally not authenticated the same way the client API is:
- Public/optionally-authed reads (e.g.
pnpm psy g:controller Visitor/V1/Places index): generate normally, then reparentVisitor/BaseController.tsto extendMaybeAuthedController. - Webhooks (e.g.
pnpm psy g:controller Webhooks/V1/Zoom create): generate normally, then reparentWebhooks/BaseController.tsto extendUnauthedController, and verify the payload signature in a@BeforeActionon the provider's controller — not the shared base, since each provider's signature scheme differs. - A server-to-server partner API (e.g.
pnpm psy g:resource api/v1/reservations Booking): generate normally, then reparentApi/BaseController.tsto extendUnauthedControllerand verify the API key in a@BeforeActionthere.
Always reparent at the surface's top-level namespace base, never a nested one — see Directory structure for why a surface that loosens auth must be its own top-level namespace rather than nested inside V1/. After generating, verify the tree with pnpm psy inspect:controller-hierarchy or pnpm psy check:controller-hierarchy (the latter exits non-zero if a controller extends too far up the tree or crosses branches).
Note that g:controller wires the inheritance chain and creates the unit spec, but it does not add routes — you're free to wire the route in conf/routes.ts however the URL contract requires, including with an explicit controller: reference when the directory name shouldn't leak into the URL (see Routing when directory names shouldn't appear in URLs).
Resources
A resource is a model with a corresponding controller with one or more of the standard CRUD operations. The resource generator automatically creates everything needed to perform these basic actions on a model, including:
- model + placeholder spec file
- migration
- serializer
- controller with index/show/create/update/destroy routes (customizable with the
--onlyoption) - routes
- controller spec matching the routes in the controller
- a model spec placeholder
All of the controller action implementations generated by this command are by default commented out to prevent accidental publishing of an unintended endpoint, and it is expected that the boilerplate authentication used to load this.currentUser (see AuthedController) will be replaced with your production authentication scheme.
Run pnpm psy g:resource --help or see the g:resource documentation for details of the generator API.
g:resource unconditionally overwrites existing model, spec, factory, and serializer filesg:resource always regenerates the model file, unit spec, factory, and serializer for the given model name — it does not check whether those files already exist, and there is no flag to skip them (--only controls which controller actions are scaffolded, not which files are written). Running it again for a model that already has hand-edited associations, hooks, validations, or serializer fields will silently discard those edits.
This mainly bites when a model already exists (from g:model or a prior g:resource) and you need to add the missing controller/routes after the fact. Commit your work first, so you can review the regenerated model, spec, factory, and serializer and discard those changes.
Nested resource routes
In this example, we'll generate a Places resource and then a nested Rooms resource within Places (so that when a Room is created, it will be associated with the specified Place, and the index will return Rooms for a given Place).
pnpm psy g:resource --owning-model=Host v1/host/places Place name:citext style:enum:place_styles:cottage,cabin,lean_to,treehouse,tent,cave,dump sleeps:integer
g:resource and g:model include id, timestamps, deleted_at, and @SoftDelete() by default; only pass deleted_at when you are adding soft delete to an existing model through a migration.
The routes file will be modified with a namespaced resource.
// conf/routes.ts
r.namespace('v1', r => {
r.namespace('host', r => {
r.resources('places')
})
})
Nested resource paths use {} for the parent id segment and must include --owning-model so the generated controller knows which association to query. For example, to generate Rooms nested under Places:
pnpm psy g:resource --sti-base-serializer --owning-model=Place v1/host/places/\{\}/rooms Room type:enum:room_types:Bathroom,Bedroom,Kitchen,Den,LivingRoom Place:belongs_to position:integer:optional
The generated route may need to be normalized to express the nesting as a resource block:
r.namespace('v1', r => {
r.namespace('host', r => {
r.namespace('places', r => {
r.resources('rooms')
})
r.resources('places')
})
})
which would then need to be updated to:
r.namespace('v1', r => {
r.namespace('host', r => {
r.resources('places', r => {
r.resources('rooms')
})
})
})
At this point, displaying routes:
pnpm psy routes
will nest the rooms routes within places. Notice that in this case, the Place id param is named placeId:
| verb | path | controller and action |
|---|---|---|
| GET | /v1/host/places/:placeId/rooms | V1/Host/Places/Rooms#index |
| POST | /v1/host/places/:placeId/rooms | V1/Host/Places/Rooms#create |
| PUT | /v1/host/places/:placeId/rooms/:id | V1/Host/Places/Rooms#update |
| PATCH | /v1/host/places/:placeId/rooms/:id | V1/Host/Places/Rooms#update |
| GET | /v1/host/places/:placeId/rooms/:id | V1/Host/Places/Rooms#show |
| DELETE | /v1/host/places/:placeId/rooms/:id | V1/Host/Places/Rooms#destroy |
| GET | /v1/host/places | V1/Host/Places#index |
| POST | /v1/host/places | V1/Host/Places#create |
| PUT | /v1/host/places/:id | V1/Host/Places#update |
| PATCH | /v1/host/places/:id | V1/Host/Places#update |
| GET | /v1/host/places/:id | V1/Host/Places#show |
| DELETE | /v1/host/places/:id | V1/Host/Places#destroy |
Simple Controller
To generate a controller, use the provided cli tool as demonstrated below:
pnpm psy g:controller V1/Guest/Places index show
The controller produced will automatically have the methods specified provided for you, with OpenAPI decorators automatically registered on your methods, but no implementation.
Additionally, a spec file will be generated, which is empty by default, but ready for you to add spec examples.
After the generator runs
- Update the migration file as needed (e.g., add
unique()to a column) — see the migrations guide. - Run migrations:
pnpm psy db:migrate. Under the defaultNODE_ENV=testthis also runssyncautomatically — don't follow it with a separatepnpm psy syncin that environment. - For a resource generator, update the generated controller spec first, then the corresponding controller. Generated action code starts commented out, so a spec that hits it will hang waiting on a response until you fill in the implementation.
Run pnpm psy sync again any time you change an association, a serializer, an @OpenAPI decorator, a route, or add a decorator that declares a virtual column (@deco.Virtual(), @deco.Encrypted()) outside of a migration. A controller spec with type errors about what an endpoint accepts or returns is the usual symptom of a stale sync.
Generator gotcha: duplicate route namespaces
Running g:resource for a resource in an existing namespace (e.g., generating v1/guest/reviews when v1/guest/bookings already exists) may add a second r.namespace('guest', ...) block to conf/routes.ts instead of reusing the existing one. Always check routes.ts after running a generator and consolidate duplicate namespace blocks by hand.