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, access }) — the inline form, useful when the handler body is short and stays close to its registration.
  • Read-modify-write with optimistic locking — increment 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-wins — reset deliberately bypasses the version check because the operation is admin-driven and resetting twice is semantically the same as resetting once.
  • Custom queries — counter:active passes a server-side filter (eq/ne/lt/gt/lte/gte/in) and totalCount to the executor’s list. Never filter a page in TypeScript: list is paginated, so rows after the first page would be missed.
  • 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 a server-side filter,
// 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({ personal: false, reason: "is_business_data", required: true }),
count: createNumberField({ default: 0 }),
lastIncrementedBy: createTextField({ personal: "ref" }),
},
});
export const counterFeature = defineFeature("counters", (r) => {
r.entity({ name: "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({
name: "counter:create",
schema: z.object({ name: z.string().min(1) }),
handler: async (event, ctx) =>
counterExecutor.create({ ...event.payload, count: 0 }, event.user, ctx.db),
access: { roles: ["Admin"] },
});
// Custom handler: increment with business logic
r.writeHandler({
name: "counter:increment",
schema: z.object({ id: z.uuid(), amount: z.number().min(1).max(100) }),
handler: async (event, ctx) => {
const current = await counterExecutor.detail({ id: event.payload.id }, event.user, ctx.db);
if (!current) {
return failNotFound("counter", event.payload.id);
}
// detail() returns the entity's own row type; count is nullable because the field is not required.
const { count, version } = current;
const newCount = (count ?? 0) + 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,
changes: { count: newCount, lastIncrementedBy: `user:${event.user.id}` },
},
event.user,
ctx.db,
);
},
access: { roles: ["Admin", "User"] },
});
// Custom handler: reset to zero
r.writeHandler({
name: "counter:reset",
schema: z.object({ id: z.uuid() }),
handler: 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. list() returns one page
// (default 50, max 200, MAX_LIST_LIMIT), so a JS filter would miss rows after
// page 1; filter in the query instead. totalCount counts over the whole WHERE.
r.queryHandler({
name: "counter:active",
schema: z.object({ minCount: z.number().int().default(1), cursor: z.string().optional() }),
handler: async (query, ctx) =>
counterExecutor.list(
{
filter: { field: "count", op: "gte", value: query.payload.minCount },
totalCount: true,
cursor: query.payload.cursor,
},
query.user,
ctx.db,
),
access: {
openToAll: {
reason:
"any signed-in user may list active counters; the sample has no per-user counter ownership",
},
},
});
// Standard detail
r.queryHandler({
name: "counter:detail",
schema: z.object({ id: z.uuid() }),
handler: async (query, ctx) => counterExecutor.detail(query.payload, query.user, ctx.db),
access: {
openToAll: {
reason:
"any signed-in user may read a counter by id; the sample has no per-user counter ownership",
},
},
});
});

📄 On GitHub: samples/recipes/custom-handlers/src/feature.ts