Custom operations
Kavo has no separate lifecycle-hook system, no beforeCreate/afterUpdate. The two extension points below cover that ground: replace a standard operation's behavior, or declare an entirely new operation of your own. Both go through the exact same pipeline every built-in route does: schema resolution, deserialization, serialization, the ETag, problem-details errors, and the module's app context.
Replacing a standard operation's handler
The config-level equivalent of @Override (see Routes & controllers): swap the behavior behind a standard id without touching its route.
@Kavo(Book, {
operations: {
createOne: {
handler: {
async execute(body, context) {
return context.repository.create({ ...body, title: body.title.trim() }, context);
},
},
},
},
})context.repository is the entity's own RepositoryAdapter, reached the same way a built-in handler reaches it. This is a plain object literal, evaluated at @Kavo decoration time, before any DataSource exists, so nothing is closed over. Everything a handler needs comes off context.
Declaring a custom operation
An operations key that isn't one of the standard eight declares a whole new operation: its own registry entry, its own route, its own place in the precedence chain.
@Kavo(Order, {
operations: {
markPaidOne: {
schema: { input: MarkPaidDto },
handler: {
async execute({ id, body }: { id: number; body: MarkPaidDto }, context) {
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 entry needs a handler (there's no built-in to fall back to) and accepts:
kind("read"|"write", default"write"): a read runs query resolution and takes no body; a write takes one.cardinality("one"|"many", default"one"):"many"returns the list envelope, so the handler must return{ entities, total }the wayfindManydoes.schema: since a custom operation has no rootschemaslot, this is the only way to give it a shape. With noschema.output, the result is projected through the entity's own columns. A result sharing nothing with them raises aKAVO_CONFIG_INVALIDnaming the operation, rather than silently serializing to{}.meta.routes: same route options every standard operation gets. With none, the route defaults toPOST /<operation id>.realtimeEvent(one ofRealtimeEventId, unset by default): which realtime event this operation's write publishes as — see Realtime events. Only valid onkind: "write",cardinality: "one"; declaring it anywhere else is a bootstrap error. Unset, the operation publishes nothing.
Naming follows the same convention as the built-ins: camelCase, always spelling out cardinality (markPaidOne, not markPaid).
Call it in code through run:
await service.run("markPaidOne", { id: 7, body: { reference: "INV-42" } });Things worth knowing before reaching for one
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.- Custom routes are matched first. Registered ahead of the standard table, so a custom
GET /orders/pendingreaches its own handler rather than falling through toGET /orders/:id. - A handler that needs an injected application service, not just the database, is a case for
@Overrideor a hand-written route instead. A config-level handler is a plain object with nothisand nothing to inject into. - Realtime events are keyed by standard operation id. A custom operation emits nothing, however it changes a row. See Realtime events.
- A custom operation reaches GraphQL/MCP only when it declares
schema.output. Both bindings walk the same operation registry route generation reads (issue #153) — there's no second, protocol-specific list — but a custom id has no entity-derived schema fallback the way the standard eight do, so an enabled operation with no declared output shape has nothing to build even a loose schema from, and is left out.@kavo/mcpthen needs nothing further: an eligible id gets a<entity>.<operationId>tool with JSON Schema loose enough not to need a class-derived shape.@kavo/graphqlneeds one more thing, because a typed field needs a realGraphQLOutputType(and, for a write with a body, aGraphQLInputObjectType) that nothing can derive automatically: the operation's id also has to be named in that schema'soperationsoption, with those types supplied by hand — the same "declare what you want" shapecreateInputType/updateInputTypealready have for the standard mutations.
See Guides/Configuration/Operations for the full field reference, including custom list metadata (adding data to findMany's meta bag without replacing the whole handler), and CRUD engine for the pipeline internals.