Controller Decorators
@controller
Marks a class as a controller and optionally sets the URL path prefix.
@controller() // infers: CampaignController → /campaigns
@controller('/v2/campaigns') // explicit path
export class CampaignController extends ActiveController<AppContext> {}If no path is given, the class name is transformed: CampaignController → /campaigns, TeamSettingsController → /team-settings.
@scope
Nests the controller under a parent resource URL. Multiple scopes stack.
@scope('teamId')
// → /teams/:teamId/campaigns
@scope('teamId')
@scope('campaignId')
// → /teams/:teamId/campaigns/:campaignId/assetsThe scope field name is also used as a WHERE clause: every query automatically filters by the scope parameter. This prevents cross-tenant data leaks — you can never get a campaign for the wrong team.
@crud
Attaches a model and CRUD configuration to the controller.
@crud(Campaign, {
index: {
scopes: ['active', 'draft'], // named scopes (user can request)
defaultScopes: ['active'], // always applied
paramScopes: ['byName'], // ?byName=foo → Campaign.byName('foo')
sortable: ['createdAt', 'name'],
defaultSort: { field: 'createdAt', dir: 'desc' },
filterable: ['status', 'teamId'],
include: ['creator'], // always eager-loaded
perPage: 25,
maxPerPage: 100,
},
create: {
permit: ['name', 'budget'], // only these fields are written
autoSet: { teamId: ctx => ctx.user.teamId }, // forced from context
},
update: { permit: ['name', 'budget'] },
get: { include: ['team', 'creator'] },
})Security rules:
id,createdAt,updatedAtare NEVER writeable (excluded from all permits)@scopefields cannot be included inpermit— they're set from the URL- Unknown filter fields throw
400 Bad Request - Unknown sort fields throw
400 Bad Request
@singleton
For "one per parent" resources (like user settings, team profile):
@singleton(TeamSettings, {
findBy: (ctx) => ({ teamId: ctx.user.teamId }),
findOrCreate: true, // creates if missing (race-safe)
defaultValues: { timezone: 'UTC' },
update: { permit: ['timezone', 'locale'] },
})
@scope('teamId')
export class TeamSettingsController extends ActiveController<AppContext> {}Generates routes: GET /teams/:teamId/team-settings, PATCH /teams/:teamId/team-settings, and optionally POST /teams/:teamId/team-settings (findOrCreate).
@mutation
Marks an instance method as a custom mutation. The record is auto-loaded and passed as the first argument.
@mutation()
async launch(campaign: Campaign) {
campaign.status = 'active'
return campaign.save()
}Bulk Mutations
Use bulk: true to operate on multiple records. By default, all records are loaded into memory and passed as an array.
// For 3-10 records: load them all
@mutation({ bulk: true })
async archive(campaigns: Campaign[]) {
for (const c of campaigns) { c.status = 'archived'; await c.save() }
return campaigns
}For large batches (100+), use records: false to skip loading and perform a single SQL update instead:
// For 100+ records: efficient bulk update
@mutation({ bulk: true, records: false })
async archive(ids: number[]) {
// this.relation is already scoped to organizationId (via scopeBy)
// and filtered to the requested ids
await this.relation.updateAll({ status: 'archived' })
return { count: ids.length }
}Routes generated:
- Non-bulk:
POST /campaigns/:id/launch - Bulk:
POST /campaigns/archive(accepts{ ids: [1, 2, 3] })
@action
Marks a method as an explicit REST endpoint. Unlike @mutation (which always loads a single record by :id), @action gives you full control over the route shape.
// Collection-level: no record loading
@action('GET')
async stats(): Promise<{ totalBudget: number; activeCount: number }> {
const rel = this.relation
const totalBudget = await rel.sum('budget')
const activeCount = await rel.active().count()
return { totalBudget, activeCount }
}
// → GET /campaigns/stats
@action('POST')
async recalculate(input: { fieldset: string }) {
await recalculateAll(this.relation, input.fieldset)
return { ok: true }
}
// → POST /campaigns/recalculateRecord-loading actions — pass { load: true } as the third argument to auto-load the record by :id, just like @mutation:
@action('GET', undefined, { load: true })
async score(record: Campaign): Promise<{ score: number }> {
return { score: await computeScore(record) }
}
// → GET /campaigns/:id/score
@action('POST', undefined, { load: true })
async duplicate(record: Campaign) {
const copy = await Campaign.create({ ...record.attributes, name: `${record.name} (copy)` })
return copy
}
// → POST /campaigns/:id/duplicateWhen load: true, the loaded record is also available as this.record inside @before hooks that run for that action — useful for ownership checks.
Custom paths:
@action('POST', '/campaigns/batch-import')
async batchImport(input: { rows: { name: string; budget: number }[] }) { ... }
// → POST /campaigns/batch-importGenerated frontend names follow the prefix rules:
@action('GET') stats→ctrl.indexStats()— prefixed withindex@action('GET') indexKeypoints→ctrl.indexKeypoints()— already hasindex, no double-prefix@action('POST') recalculate→ctrl.mutateRecalculate()— prefixed withmutate@action('GET', ..., { load: true }) score→ctrl.indexScore(id)— takes an id
@before / @after
Hooks that run before/after actions. Inherited from parent classes (parent hooks fire first, like Rails before_action inheritance).
export class BaseTeamController extends ActiveController<AppContext> {
protected team!: Team
@before()
async loadTeam() {
this.team = await Team.find(this.params.teamId)
// Team.find() throws RecordNotFound if missing — auto-converted to 404
}
}
@controller()
@crud(Campaign, { /* ... */ })
@scope('teamId')
export class CampaignController extends BaseTeamController {
// loadTeam() fires before every action automatically
@before({ only: ['create', 'update'] })
async checkPlanLimits() {
if (!this.team.canCreateCampaigns()) throw new Forbidden('Upgrade your plan')
}
}this.record in hooks: When an action auto-loads a record (@mutation or @action({ load: true })), the record is set on this.record before before-hooks run:
@before({ only: ['launch', 'update'] })
async ensureOwner() {
if (this.record.creatorId !== this.context.user.id) {
throw new Forbidden('Not your campaign')
}
}Options:
only: ['create', 'update']— run only for these actionsexcept: ['index']— run for all EXCEPT these actionsif: 'methodName'orif: () => boolean— conditional execution
@rescue
Rails-style error handler. Declare a method that receives the thrown error and either converts it to a different error or returns a fallback value.
class SomeServiceError extends Error {}
@controller('/campaigns')
@crud(Campaign, { /* ... */ })
@scope('teamId')
export class CampaignController extends ActiveController<AppContext> {
// Convert third-party errors into user-friendly 400s
@rescue(SomeServiceError)
async handleServiceError(e: SomeServiceError) {
throw new BadRequest(`Service unavailable: ${e.message}`)
}
// Swallow a transient error with a fallback (only for 'index')
@rescue(CacheError, { only: ['index'] })
async handleCacheMiss(_e: CacheError) {
return { data: [], pagination: { totalCount: 0 } }
}
}@rescue handlers are inherited — define them in a base controller and every subclass gets them automatically.
Options:
only: ['create', 'update']— rescue only for these actionsexcept: ['index']— rescue for all actions except these
Auto-rescue for RecordNotFound
You don't need @rescue for RecordNotFound. Any RecordNotFound error thrown anywhere in the dispatch cycle is automatically converted to a NOT_FOUND (404) response — including errors thrown by Model.find() inside action bodies.