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
const hostAccess = { access: { openToAll: true } } as const;
r.entity("event", eventEntity);
registerEntityCrud(r, "event", eventEntity, { write: hostAccess, read: hostAccess });
— from src/features/show-pony/feature.ts

registerEntityCrud generates create/update/delete/list/detail handlers from the entity — no manual executor wiring.

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:

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] } },
);
— from 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.

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" });
— from 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.

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

Log 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.

// 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