Skip to content

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.

Empty event form from entity schema
Filled edit form after clicking a list row
Navigate Events → Guest list in the host shell

Handlers live in handlers/event-handlers.ts and register on the feature:

r.entity("event", eventEntity);
r.writeHandler(eventCreateHandler);
r.writeHandler(eventUpdateHandler);
r.writeHandler(eventDeleteHandler);
r.queryHandler(eventListHandler);
r.queryHandler(eventDetailHandler);
From src/features/show-pony/feature.ts.

Each handler is built with defineEntityCreateHandler / defineEntityListHandler and friends, so no manual executor wiring is needed. eventCreateHandler wraps the stock create handler with a tier cap (withStockCap) so free-tier tenants cannot exceed their event limit.

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));

The public page needs the event without login:

export const eventBySlugQuery = defineQueryHandler({
name: "event:by-slug",
schema: z.object({ slug: z.string().min(1).max(120) }),
access: { roles: [...access.anonymous] },
handler: async (query, ctx) => {
return (await findEvent(ctx, (row) => row.slug === query.payload.slug)) ?? null;
},
});
From src/features/show-pony/handlers/event-by-slug.query.ts.

findEvent runs against the subdomain tenant (chapter 4). A slug on another host is invisible here.

Dashboard screens and sidebar entries live in register/screens.ts and register/nav.ts. These are data definitions, not React components:

export const eventListScreen: EntityListScreenDefinition = {
id: "event-list",
type: "entityList",
entity: "event",
columns: ["title", "slug", "startsAt", "location", "guestLimit"],
pageSize: 25,
defaultSort: { field: "title", dir: "asc" },
};
registerShowPonyScreens(r);
registerShowPonyNav(r);
From src/features/show-pony/register/screens.ts and register/nav.ts.

The events list is sorted server-side by defaultSort. There is no Meilisearch adapter in the current sample. List filtering is through the schema-driven list query, not a separate search backend.

Terminal window
docker compose up -d # Postgres + Redis
bun dev

Log in as [email protected] / changeme. On bun dev there are no seeded events. Create one in Events → New event, or follow chapter 11 for the production seed path. Switch to Acme Studios in the top bar to confirm tenant isolation. No custom dashboard React is needed, just schema + screens.

// 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 { inviteBrandingQuery } 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";
// RSVP rows carry guest PII (name/email/note) — unlike event CRUD, this is
// restricted to Admin rather than openToAll: show-pony has no dedicated
// organizer role yet, and Admin is the same privileged role billing-info
// and usage already gate on.
const rsvpReadAccess = { access: { roles: ["Admin"] } } 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(inviteBrandingQuery);
r.entity("rsvp", rsvpEntity);
r.writeHandler(rsvpSubmitHandler);
r.queryHandler(defineEntityListHandler("rsvp", rsvpEntity, rsvpReadAccess));
r.queryHandler(defineEntityDetailHandler("rsvp", rsvpEntity, rsvpReadAccess));
r.queryHandler(billingInfoQuery);
r.queryHandler(usageQuery);
registerShowPonyScreens(r);
registerShowPonyNav(r);
});

📄 On GitHub: src/features/show-pony/feature.ts