Wire the business logic
defineFeature is where entities meet HTTP: CRUD handlers, custom queries, screens,
and navigation. After this chapter the host can create events — the screens below
render from the schema with no custom dashboard React.

Host CRUD on event
Section titled “Host CRUD on event”const hostAccess = { access: { openToAll: true } } as const;
r.entity("event", eventEntity);registerEntityCrud(r, "event", eventEntity, { write: hostAccess, read: hostAccess });src/features/show-pony/feature.ts
registerEntityCrud generates create/update/delete/list/detail handlers from the
entity — no manual executor wiring.
Read handlers on rsvp
Section titled “Read handlers on rsvp”Hosts see the guest list; guests write through a custom handler (chapter 7):
r.entity("rsvp", rsvpEntity);r.queryHandler(defineEntityListHandler("rsvp", rsvpEntity, hostAccess));r.queryHandler(defineEntityDetailHandler("rsvp", rsvpEntity, hostAccess));Anonymous event lookup by slug
Section titled “Anonymous event lookup by slug”The public page needs the event without login:
r.queryHandler( "event:by-slug", z.object({ slug: z.string().min(1).max(120) }), async (e, ctx) => { const events = await ctx.db.selectMany(eventTable); return events.find((row) => row.slug === e.payload.slug) ?? null; }, { access: { roles: [...access.anonymous] } },);src/features/show-pony/feature.ts
ctx.db is already scoped to the subdomain’s tenant (chapter 4) —
a slug on another host is invisible here.
Screens and nav
Section titled “Screens and nav”Dashboard screens are data, not React components:
export const eventListScreen: EntityListScreenDefinition = { id: "event-list", type: "entityList", entity: "event", columns: ["title", "slug", "startsAt", "location", "guestLimit"], searchable: true, pageSize: 25, defaultSort: { field: "title", dir: "asc" },};
r.screen(eventListScreen);r.screen(eventEditScreen);r.screen(rsvpListScreen);r.nav({ id: "events", label: "showpony:nav.events", order: 10, screen: "showpony:screen:event-list" });r.nav({ id: "guests", label: "showpony:nav.guests", order: 20, screen: "showpony:screen:rsvp-list" });src/features/show-pony/feature.ts
searchable: true turns the toolbar’s search box from a dead slot into a real
query: the framework’s SearchAdapter re-indexes every searchable field
(title, slug, location) automatically through the event-dispatcher —
no manual indexing hook. It needs a search backend wired into ctx.searchAdapter
at boot; show-pony wires Meilisearch in
bin/server.ts
and runs it locally via the meilisearch service in
docker-compose.yml.
Without a wired adapter, searchable: true is inert — the box renders but
every query returns everything.
Boot check
Section titled “Boot check”docker compose up -d # Postgres, Redis, Meilisearchbun devLog in as [email protected] / changeme. The seeded Rooftop Launch Party
appears on the demo tenant; switch to Acme Studios in the top bar to see
Acme Offsite RSVP. Click a row to edit, or type into the events toolbar’s
search box — it hits Meilisearch, not a client-side filter. No custom
dashboard React — just schema + screens.
The complete file
Section titled “The complete file”// show-pony — event + RSVP feature (server registration only).
import { mailFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/mail-foundation";import { defineEntityDetailHandler, defineEntityListHandler, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";import { billingInfoQuery } from "./handlers/billing-info.query";import { eventBySlugQuery } from "./handlers/event-by-slug.query";import { eventCreateHandler, eventDeleteHandler, eventDetailHandler, eventListHandler, eventUpdateHandler,} from "./handlers/event-handlers";import { createInviteBrandingQuery } from "./handlers/invite-branding.query";import { rsvpSubmitHandler } from "./handlers/rsvp-submit.write";import { usageQuery } from "./handlers/usage.query";import { showPonyTranslations } from "./i18n";import { INVITE_BRANDING_KEYS } from "./invite-branding";import { registerShowPonyNav } from "./register/nav";import { registerShowPonyScreens } from "./register/screens";import { eventEntity, rsvpEntity } from "./schema";
const hostAccess = { access: { openToAll: true } } as const;
export { eventEntity, rsvpEntity, rsvpTable } from "./schema";
export const showPonyFeature = defineFeature("showpony", (r) => { r.requires(mailFoundationFeature.name, "config", "managed-pages");
r.config({ keys: INVITE_BRANDING_KEYS });
r.translations({ keys: showPonyTranslations });
r.entity("event", eventEntity); r.writeHandler(eventCreateHandler); r.writeHandler(eventUpdateHandler); r.writeHandler(eventDeleteHandler); r.queryHandler(eventListHandler); r.queryHandler(eventDetailHandler);
r.queryHandler(eventBySlugQuery); r.queryHandler(createInviteBrandingQuery());
r.entity("rsvp", rsvpEntity); r.writeHandler(rsvpSubmitHandler);
r.queryHandler(defineEntityListHandler("rsvp", rsvpEntity, hostAccess)); r.queryHandler(defineEntityDetailHandler("rsvp", rsvpEntity, hostAccess));
r.queryHandler(billingInfoQuery); r.queryHandler(usageQuery);
registerShowPonyScreens(r); registerShowPonyNav(r);});📄 On GitHub: src/features/show-pony/feature.ts