Custom handlers
Replace the default CRUD with handler bodies that carry business rules.
This recipe ships a counter entity with three custom write handlers
(create, increment with read-modify-write, reset with last-writer-wins)
plus a custom query that filters the projection.
The point is to show the boundary: where the entity executor stops
doing work for you and where your code has to take over. The framework
gives you createEntityExecutor so the executor’s create / update /
detail / list are still one-liners — only the surrounding business
logic is yours.
What it shows
Section titled “What it shows”r.writeHandler(name, schema, handler, options)— the positional inline form, useful when the handler body is short and stays close to its registration.- Read-modify-write with optimistic locking —
incrementreads the current count, computes the new one, and updates with the version it read. Two concurrent increments cannot both succeed. skipOptimisticLockfor last-writer-wins —resetdeliberately bypasses the version check because the operation is admin-driven and resetting twice is semantically the same as resetting once.- Custom queries —
counter:activefilters the projection in TypeScript after the executor’s list returns. Useful when the filter is computed (above-threshold) rather than indexed. failNotFoundhelper — typed error response for the 404 case without a hand-builtKumikoErrorsubclass.
When to reach for it
Section titled “When to reach for it”You have an entity but the default CRUD doesn’t capture what your
business rule actually does. The default update blindly writes the
payload; your increment needs to read first, decide, and write. That’s
the line.
Source
Section titled “Source”The whole feature lives in src/feature.ts (~100 lines). Integration
tests cover the optimistic-lock case, the skip-lock case, and the
custom-filter query.
bun kumiko test integration samples/custom-handlersSource code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Custom Handlers Sample// Shows: writeHandler with business logic, queryHandler with custom filtering,// handler that modifies payload before DB write.
import { createEntity, createEntityExecutor, createNumberField, createTextField, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";import { z } from "zod";
export const counterEntity = createEntity({ table: "read_sample_counters", fields: { name: createTextField({ required: true }), count: createNumberField({ default: 0 }), lastIncrementedBy: createTextField(), },});
export const counterFeature = defineFeature("counters", (r) => { r.entity("counter", counterEntity);
// createEntityExecutor bundles buildEntityTable + createEventStoreExecutor — // the same pair every custom write-handler opens with. Collapses 3 lines // + the { entityName } bookkeeping into one destructure. const { executor: counterExecutor } = createEntityExecutor("counter", counterEntity);
// Standard create r.writeHandler( "counter:create", z.object({ name: z.string().min(1) }), async (event, ctx) => counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db), { access: { roles: ["Admin"] } }, );
// Custom handler: increment with business logic r.writeHandler( "counter:increment", z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }), async (event, ctx) => { const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db); if (!current) { return failNotFound("counter", event.payload.id); }
const newCount = (current["count"] as number) + event.payload.amount; // Read-modify-write on counter: use the version we just read so two // concurrent increments don't clobber each other's changes. return counterExecutor.update( { id: event.payload.id, version: current["version"] as number, changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` }, }, event.user, ctx.db, ); }, { access: { roles: ["Admin", "User"] } }, );
// Custom handler: reset to zero r.writeHandler( "counter:reset", z.object({ id: z.uuid() }), async (event, ctx) => // Admin reset: last-writer-wins is intentional — there's no useful // concurrent-reset race to guard against. counterExecutor.update( { id: event.payload.id, changes: { count: 0, lastIncrementedBy: "" } }, event.user, ctx.db, { skipOptimisticLock: true }, ), { access: { roles: ["Admin"] } }, );
// Custom query: only counters above a threshold r.queryHandler( "counter:active", z.object({ minCount: z.number().default(1) }), async (query, ctx) => { const all = await counterExecutor.list({}, query.user, ctx.db); return { ...all, rows: all.rows.filter((r) => (r["count"] as number) >= query.payload.minCount), }; }, { access: { openToAll: true } }, );
// Standard detail r.queryHandler( "counter:detail", z.object({ id: z.uuid() }), async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db), { access: { openToAll: true } }, );});📄 On GitHub: samples/recipes/custom-handlers/src/feature.ts