CRUD Actions
@crud wires five default actions: index, get, create, update, and destroy. Each is configurable through the @crud options object, and any of them can be overridden by defining a method with the same name on the controller class.
index — Collection Query
@crud(Campaign, {
index: {
// Named scopes the client can request (from @scope decorators on the model)
scopes: ['active', 'draft', 'paused', 'completed', 'upcoming'],
// Applied if the client doesn't specify scopes
defaultScopes: ['active'],
// Parameter-driven scopes — ?search=foo → Campaign.search('foo')
paramScopes: {
search: { type: 'string' },
minBudget: { type: 'number' },
},
// Columns the client may sort by
sortable: ['createdAt', 'name', 'budget', 'startDate'],
// Applied if the client doesn't specify sort
defaultSort: { field: 'createdAt', dir: 'desc' },
// Simple equality filters — ?status=active
filterable: ['status', 'creatorId'],
// Always eager-loaded
include: ['creator'],
perPage: 25,
maxPerPage: 100,
},
})Request Parameters
The generated oRPC Zod schema accepts:
{
scopes?: string[] // ['active', 'draft']
sort?: { field: string; dir: 'asc' | 'desc' }
filters?: Record<string, unknown>
search?: string // for paramScopes.search
page?: number // default 0
perPage?: number // capped to maxPerPage
}Response Shape
{
data: CampaignAttrs[]
pagination: {
page: number
perPage: number
totalCount: number
hasMore: boolean
}
}Overriding index
@controller()
@crud(Campaign, { index: { /* ... */ } })
export class CampaignController extends ActiveController<AppContext> {
async index() {
// this.relation is already scoped by @scope(teamId)
const items = await this.relation
.where({ status: 'active' })
.includes('creator')
.order('createdAt', 'desc')
.limit(25)
.load()
return { data: items, pagination: { totalCount: items.length } }
}
}get — Single Record
get: {
// Eager-load for the detail view — can load more than index
include: ['creator', 'team', 'assets'],
}Fetches by :id, scoped to the current relation (meaning a user from another team cannot access a record that belongs to a different team). Throws NOT_FOUND if the record doesn't exist within the scope.
Overriding get
async get() {
const campaign = await this.relation
.where({ id: this.params.id })
.includes({ creator: true, team: { include: ['plan'] } })
.firstBang()
return campaign
}create — Insert a New Record
create: {
// Fields the client is allowed to write
permit: ['name', 'budget', 'status', 'startDate', 'assetIds'],
// Fields injected from context (client cannot override)
autoSet: {
creatorId: (ctx) => ctx.user.id,
teamId: (ctx) => ctx.teamId,
},
}The create action:
- Filters
datatopermit-listed fields only (everything else is silently dropped) - Merges
autoSetfields (overwriting anything the client sent) - Calls
Model.create(filtered)— this runs all@validateand@serverValidatehooks
On success: returns the created record. On validation failure: throws UNPROCESSABLE_ENTITY with { fields: { fieldName: ['error'] } }.
Overriding create
async create() {
const { data } = this.params
const campaign = await Campaign.create({
...data,
teamId: this.params.teamId,
creatorId: this.context.user.id,
})
await CampaignSearch.index(campaign)
return campaign
}update — Partial Update
update: {
permit: ['name', 'budget', 'status', 'startDate'],
// Update permit can differ from create permit — common to omit sensitive fields
}The update action:
- Loads the record by
:idfrom the scoped relation - Filters
datatopermit-listed fields - Calls
record.update(filtered)— runs validations, updates only changed columns
On success: returns the updated record. On validation failure: UNPROCESSABLE_ENTITY. If not found: NOT_FOUND.
Overriding update
async update() {
const campaign = await this.relation.where({ id: this.params.id }).firstBang()
const permitted = pick(this.params.data, ['name', 'budget', 'startDate'])
await campaign.update(permitted)
await CampaignSearch.reindex(campaign)
return campaign
}destroy — Delete a Record
No configuration — destroy has no options in the @crud config.
The destroy action:
- Loads the record by
:id - Calls
record.destroy()— runs@beforeDestroyand@afterDestroyhooks
Returns { success: true } on completion.
Overriding destroy
async destroy() {
const campaign = await this.relation.where({ id: this.params.id }).firstBang()
if (!campaign.isDraft()) throw new BadRequest('Only draft campaigns can be deleted')
await campaign.destroy()
return { success: true }
}Disabling Individual Actions
Pass false to disable a specific action:
@crud(Campaign, {
index: { scopes: ['active'] },
get: { include: ['creator'] },
create: { permit: ['name'] },
update: false, // no PATCH route generated
destroy: false, // no DELETE route generated
})The permit Security Model
permit is a whitelist — if a field isn't listed, it's silently dropped. This means:
idis never writable (always blocked, even if listed)createdAt/updatedAtare never writable (always blocked)- Scope fields (
teamId,userIdfrom@scope) are never writable from the request body - Any extra fields the client sends are discarded without error
The safety model is: the server defines what's writable, period. Clients can send anything — only permit-listed fields reach the model.
Accessing Scope Params in Actions
Scope parameters are available in this.params:
async create() {
const campaign = await Campaign.create({
...this.params.data,
teamId: this.params.teamId, // from @scope('teamId')
})
return campaign
}The scope is also pre-applied to this.relation — so any query through this.relation is automatically filtered to the correct tenant without any manual WHERE clauses:
async index() {
// This is already WHERE team_id = :teamId
const items = await this.relation.order('createdAt', 'desc').load()
return { data: items }
}