Operations
Per-operation overrides and fully custom operations on @Kavo(Entity, config), plus how to add data to a list response's meta bag.
operations
Per-operation overrides, keyed by operation id. A key that names one of the eight standard operations configures it; any other key declares a custom operation.
operations is an explicit whitelist once it's declared at all. An entity with no operations key gets every standard operation at its global/built-in default (createOne/findOne/findMany/updateOne/patchOne/deleteOne on, restoreOne/purgeOne off unless soft delete is declared). The moment operations is present, every standard id it doesn't name is off. true/false names one explicitly, in either direction, with no settings attached; an object carrying settings enables by being named — there's no enabled field, since a settings object's own presence already says so.
@Kavo(Book, {
operations: {
createOne: true,
findOne: true,
findMany: true,
updateOne: true,
deleteOne: true,
restoreOne: { meta: { routes: { path: ":id/undelete" } } },
// patchOne isn't named, so it's off.
},
})An OperationConfig object accepts:
handler(OperationHandler<Entity>): a replacement handler function, keeping the default schema/serialization scaffolding around it.meta(OperationMetadata): an opaque bag consumed by the framework layer; in@kavo/nestthis is{ routes: KavoRouteOptions }.schema({ input?, output?, query? }): overrides the entity's rootschemaslot for this operation only, see below.- any settings key (same shape as global
KavoSettings): overrides that apply to this operation only, one level above the entity's own settings — merged with, not replacing, the entity/global settings it doesn't mention.
operations.<id>.schema narrows one operation's request body, response, or query contract independently of the entity's root schema slots (§schema on Entity config). Only the fields a given operation actually has are accepted: input/output on a write, output/query on a read, neither on deleteOne/purgeOne (void results).
@Kavo(Book, {
schema: { output: { item: BookItemSchema } }, // entity-wide default for every read
operations: {
findOne: { schema: { output: BookDetailSchema } }, // findOne only
createOne: { schema: { input: CreateBookRequestSchema, output: BookCreatedSchema } },
},
})Fallback order per field: operations.<id>.schema.<field>, then the entity's root schema.input.<slot>/schema.output.<slot>, then the entity-derived default. Setting a field an operation doesn't have (schema.query on createOne, say) is a bootstrap ConfigurationException. See Schema system §8 for the full applicability table and the fallback chain in the engine.
operations.<id>.meta.routes (@kavo/nest's KavoRouteOptions) accepts:
method("GET"|"POST"|"PUT"|"PATCH"|"DELETE", default: the operation's standard verb): overrides which HTTP verb the generated route uses.path(string, default: the operation's standard path): route path relative to the controller (e.g.":id/activate").enabled(boolean, default:true):falsemakes the operation service-only: still callable throughservice.engine.execute(...), but no route is generated.successStatus(number, default:201create,204delete,200otherwise): overrides the response status code on success.
See NestJS integration for how route generation reads this, and Registry-driven operations for why routes always come from the same registry the engine uses.
Custom operations
An operations key that is not one of the eight standard ids declares an operation of your own. It's an ordinary registry entry, so it gets the same pipeline every built-in route gets: schema resolution, deserialization, serialization, the ETag, problem-details errors, and the module's app context.
A custom id is exempt from the whitelist rule above — it's always registered when present — but declaring one still counts as declaring operations, so it still silences every standard operation you don't also name. The example below is deliberately CRUD-only-plus-one: if Order also needs findOne/findMany/etc., they need naming here too.
@Kavo(Order, {
operations: {
markPaidOne: {
schema: { input: MarkPaidDto },
handler: {
async execute({ id, body }: { id: number; body: MarkPaidDto }, context) {
// `context.repository` is this entity's own repository adapter.
const order = await context.repository.findOneById(id, null, context);
if (order === null) {
throw new NotFoundException({ messageParams: { entity: context.entityName, id: String(id) } });
}
return context.repository.patch(id, { paidAt: new Date(), reference: body.reference }, context);
},
},
meta: { routes: { method: "POST", path: ":id/mark-paid" } },
},
},
})
@Controller("orders")
export class OrderController {}A custom-operation entry accepts:
handler(OperationHandler<Entity>, optional): the operation's behavior. There's no built-in to fall back to, so an entry without one needs something else to supply it — in@kavo/nest, an@Override(id)method on the controller (below). Neither present is a bootstrap error.kind("read"|"write", default:"write"): a read runs query resolution and takes no request body; the generated route binds@Queryinstead of@Body.cardinality("one"|"many", default:"one"):"many"returns the list envelope, so the handler must return{ entities, total }the way afindManyhandler does.enabled(boolean, default:true):falseregisters the entry inert: no route, and calling it answers405 KAVO_OPERATION_DISABLED.schema({ input?, output?, query? }):input/outputon a write,output/queryon a read. A custom operation has no root schema slot of its own, so this is where it gets a shape.meta(OperationMetadata, default:{}): the route, as above. Without it the operation is routedPOST /<operation id>.realtimeEvent(RealtimeEventId, unset by default): which of the five standard event ids this operation's write publishes as (Realtime events). Only valid onkind: "write",cardinality: "one"— declaring it on a read or a"many"write is a bootstrap error. Unset, the operation publishes nothing.- any settings key (same shape as global
KavoSettings): the operation scope of the precedence chain, exactly as for a standard id.
Naming follows the same convention the built-ins do: camelCase, always spelling out cardinality (markPaidOne, findPendingMany). An id that differs from a standard one only by case is refused at bootstrap, since deleteone is a slip rather than a name.
Reaching the database from a handler
context.repository is the entity's RepositoryAdapter, reads and writes both, and it's how a handler gets at data (ADR-0025). Nothing is closed over, which is what makes the example above work at all: a @Kavo config literal is evaluated when the class is defined (ADR-0012), so a DataSource built inside KavoModule.forRootAsync's factory doesn't exist yet, and neither does the adapter derived from it.
Adapter methods take a context of their own, so pass the one you were given back: context.repository.patch(id, data, context). That puts the call inside the request's transaction, applies the resolved soft-delete strategy, and gives it the settings view in force for this call. Two things it deliberately is not:
- It is this entity's adapter only. A write to another entity is yours to make, through whatever you already use to reach it (an injected service on the controller, the ORM directly). Kavo doesn't hand out a registry of every entity's adapter.
- It is not narrowed by
kind. Akind: "read"handler is handed the writer half too.kinddecides the request's shape, not what your code is allowed to do.
A handler that needs an injected application service, rather than the database, is still a case for @Override or a hand-written route: a config-level handler is a plain object, with no this and no constructor to inject into.
In code, a custom operation is called through run, which takes the id and returns the same envelope-unwrapped result the named methods do:
await service.run("markPaidOne", { id: 7, body: { reference: "INV-42" } });Worth knowing before you reach for one:
Custom routes are matched first. Custom entries are registered ahead of the standard table, so
GET /orders/pendingreaches its own handler rather thanGET /orders/:id. The flip side is that a custom entry whosemeta.routesreproduces a standard route's shape takes that route.The handler is built at decoration time (ADR-0012), like everything else in a
@Kavoconfig, so it's a plain object with nothing in scope but its arguments. Data access comes fromcontext.repository(above), and anything else it needs has to be reachable from module scope.@Override(id)can be the whole implementation. A DI-aware custom operation — one that needs another Nest provider, or a cross-entity transaction — cannot be expressed as a config-levelhandler; write it as an@Override(id)method instead and leavehandleroff the config entry entirely (issue #424).@kavo/nestresolves the override ahead of the generated route, so the config-level handler is never reached. Omitting both is caught atKavoModule's bind time (onModuleInit), not silently:ConfigurationExceptionnames the operation and says it needs one or the other. See@Override.If-Matchis refused, not ignored. Nothing in the schema says which row a custom operation targets, so a conditional request against one answers412 KAVO_PRECONDITION_UNSUPPORTEDrather than writing unguarded (ADR-0020).The result is projected through the entity, unless you say otherwise. A custom operation goes through the whole pipeline, and that includes serialization: with no
schema.output, the handler's return value is filtered to the entity's own columns (plus any opted-in virtual field), exactly as afindOneresponse would be. A result that is a narrower entity shape is served as-is. A result with its own shape needs a schema:tsclass ImportOutcomeDto { applied = 0; skus: string[] = []; } operations: { // `One`, not `Many`: cardinality names the *response*, and this one // answers with a single outcome however many rows it wrote. importPricesOne: { handler, schema: { output: ImportOutcomeDto } }, }Every field needs a runtime initializer, since an uninitialized class field erases and the class then narrows nothing.
A result the projection empties raises, rather than serving
{}.KAVO_CONFIG_INVALIDnames the operation and says which of three mistakes it is: no schema and no field in common with the entity, a registered schema the handler's keys don't match, or a registered schema with no runtime fields. It fires on a plain object, on a class instance whose values are accessors, and on an array (the last being what a handler that meantcardinality: "many"and left it at the default returns). It does not fire under an explicitselect=, which can empty a projection on its own.Two things follow from it being a request-time refusal. The handler has already run, so a write it made through
context.repositorystands. And a partial strip, a result mixing entity fields with its own, is still silent, because that's what a projection is for.The route defaults to
POSTand201. A custom id is absent from the standard route table, so it falls back toPOST /<controller>/<operation id>with a201: a custom operation is a write against the collection until itsmeta.routessays otherwise. A read that returns an existing row almost certainly wantsmeta: { routes: { method: "GET", path: ":id/summary", successStatus: 200 } }.
Custom operations are a REST and programmatic feature only: the GraphQL and MCP bindings expose the standard operations.
Custom list metadata
The list envelope's meta bag (ListResultDto.meta) is the place for anything about the list that isn't a row: facet counts, a freshness stamp, a cursor. It doesn't need a schema or a config key. Whatever the findMany handler returns as meta is what the client receives.
It's the envelope's one optional field. Until a handler fills it, the key is absent from the response, not {}, so the common zero-config list doesn't carry an empty bag on every request; a contributor that returns {} leaves it absent too. Type it and read it accordingly: body.meta?.inStock.
ListResultDto.metaon the response andoperations.<id>.metaabove are unrelated. The first is an open bag on the list envelope; the second isOperationMetadata, route options the framework layer reads, which never reach a response body.
withListMeta wraps an existing handler so a contributor function's keys land on the bag, which saves rewriting the built-in findMany just to add one number:
// book.controller.ts
import { builtInHandlers, withListMeta } from "@kavo/core";
const findMany = builtInHandlers<Book>()("findMany");
@Kavo(Book, {
operations: {
// Every other standard operation, named to keep it at its own default —
// declaring `operations` at all makes it a whitelist (see above).
createOne: true,
findOne: true,
updateOne: true,
patchOne: true,
deleteOne: true,
findMany: {
handler: withListMeta(findMany, (result) => ({
inStock: result.entities.filter((book) => book.stock > 0).length,
countedAt: new Date().toISOString(),
})),
},
},
})
@Controller("books")
export class BookController {}{ "items": [...], "limit": 20, "offset": 0, "total": 2, "meta": { "inStock": 1, "countedAt": "2026-01-01T00:00:00.000Z" } }builtInHandlers<Book>() takes no adapter: the handlers it returns read the request's own context.repository (ADR-0025), which is what lets this wrap be written inside a @Kavo config, evaluated when the class is defined and before any DataSource exists. Pass one (builtInHandlers(replica)) only to point those handlers somewhere other than the entity's own adapter.
withListMeta behaves as follows:
- Contributor input: the wrapped handler's whole result (
entities,total, and anymetait already set) plus the requestKavoContext. It may beasync. - Merge precedence: the contributor's keys win. The inner handler's
metais the base and the contributor merges over it, so the outermost wrap owns any key it names; keys it doesn't name pass through. - Overriding that: the inner bag is in hand, so return
{ ...mine, ...result.meta }to let the inner handler win instead. - Serialization: none.
metais your data, not entity data: no schema projection, noselect=selection, no renaming. It must be JSON-serializable. - Nothing contributed: the key is left off the response entirely. Judged on the merged bag, so
{}from a contributor is the same as no contributor at all. - Wrong-shaped handler: wrapping a handler that doesn't return
{ entities, total }raisesConfigurationException(KAVO_CONFIG_INVALID) naming the operation, rather than serving a malformed envelope.
The wrapper is a convenience, not a requirement. The engine reads meta off whatever the findMany handler returns, so a hand-written one works the same way:
import type { FindManyResult, KavoContext } from "@kavo/core";
const handler = {
async execute(_input: null, context: KavoContext<Book>) {
// `builtInHandlers(...)` hands back `OperationHandler<Book>`, whose
// output type is `unknown`, the same erasure `withListMeta` works
// around with its runtime shape check. Hand-rolling the wrap means
// narrowing it yourself.
const inner = (await findMany.execute(null, context)) as FindManyResult<Book>;
return { ...inner, meta: { inStock: 1 } };
},
};Transport support. meta rides the same envelope everywhere, so it reaches REST responses and MCP tool results unchanged. It is not exposed by the GraphQL binding: that binding's generated list type declares items/total/limit/offset only, so a GraphQL client can't select meta today.