Skip to content

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.

  • 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 lockingincrement reads the current count, computes the new one, and updates with the version it read. Two concurrent increments cannot both succeed.
  • skipOptimisticLock for last-writer-winsreset deliberately bypasses the version check because the operation is admin-driven and resetting twice is semantically the same as resetting once.
  • Custom queriescounter:active filters the projection in TypeScript after the executor’s list returns. Useful when the filter is computed (above-threshold) rather than indexed.
  • failNotFound helper — typed error response for the 404 case without a hand-built KumikoError subclass.

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.

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.

Terminal window
bun kumiko test integration samples/custom-handlers

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