The data model
Before handlers and screens, define what exists in your domain. You already
know where requests land (chapter 4);
ShowPony has two entities: event (host-authored) and rsvp (guest-submitted).
Everything else in the tutorial is behaviour around them.
Those entities become the columns and forms you see on the host dashboard:


The event entity
Section titled “The event entity”A host creates events with a title, slug, time, location, and optional copy:
export const eventEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true }), slug: createTextField({ required: true }), startsAt: createTimestampField({ required: true }), location: createTextField({}), description: createLongTextField({ personal: false, reason: "is_business_data", }), guestLimit: createNumberField({ sortable: true, integer: true, min: 0 }), },});src/features/show-pony/schema/event.ts.
Slug is unique per tenant, not globally. The public URL is
<host>.show-pony.kumiko.rocks/e/<slug> (cloud) or
demo.show-pony.localhost:4180/e/<slug> (local). The host comes from the
subdomain (Host header), not the path. The slug only has to be unique
within one tenant.
| Surface | Local | Cloud |
|---|---|---|
| Host login (apex) | http://show-pony.localhost:4180/login | https://show-pony.kumiko.rocks/login |
| Demo invite (rooftop) | http://demo.show-pony.localhost:4180/e/rooftop-launch | https://demo.show-pony.kumiko.rocks/e/rooftop-launch |
| Acme invite (offsite) | http://acme.show-pony.localhost:4180/e/acme-offsite | https://acme.show-pony.kumiko.rocks/e/acme-offsite |
The rsvp entity
Section titled “The rsvp entity”The row a guest creates when they answer:
export const RSVP_STATUSES = ["yes", "no", "maybe"] as const;
export const rsvpEntity = createEntity({ fields: { eventId: createTextField({ required: true }), name: createTextField({ required: true, personal: "self", find: "fuzzy", }), email: createTextField({ personal: "self", find: "exact", format: "email", }), status: createSelectField({ options: RSVP_STATUSES, default: "yes", filterable: true, sortable: true, }), plusN: createNumberField({ sortable: true, integer: true }), note: createLongTextField({ personal: "self", find: "none", }), },});src/features/show-pony/schema/rsvp.ts.
Why these fields
Section titled “Why these fields”namerequired,emailoptional. One-tap RSVP; email only for confirmation.statusis an enum: coming / not coming / maybe.RSVP_STATUSESis the single source of truth for validation and UI.plusNis extra guests. The model stays flat, with no second guests table.eventIdis scoped within the tenant; there is no global FK.name/email/notearepersonal: "self": guest PII, encryptable at rest and erasable per row.findstays allowed on an annotated field;sortabledoesn’t, which is why the guest-list screen sorts bystatusinstead (chapter 14).
Who writes, who reads
Section titled “Who writes, who reads”| Actor | event | rsvp |
|---|---|---|
| Host (signed in) | create, update, delete, list, detail | list, detail only |
| Guest (anonymous) | read via slug query (chapter 5) | submit (chapter 7) |
openToAll for host CRUD is safe. It means any signed-in user can use it, but writes
and queries are tenant-scoped, so a host only ever sees their own tenant’s data.
Handlers and screens that implement this table are chapter 6. Tenant routing is chapter 4; the anonymous write is chapter 7.
Entity tables
Section titled “Entity tables”export const eventTable = buildEntityTable("event", eventEntity);export const rsvpTable = buildEntityTable("rsvp", rsvpEntity);src/features/show-pony/schema/event.ts · schema/rsvp.ts.
The complete file (entities only)
Section titled “The complete file (entities only)”These are the entities and tables before wiring. The full feature.ts grows in
the next chapter:
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createLongTextField, createNumberField, createTextField, createTimestampField, type HandlerContext,} from "@cosmicdrift/kumiko-framework/engine";
// The event slug is unique per tenant (tenant-scoping is enough): the public// URL is <host>.show-pony.<domain>/e/<slug>, and the host comes from the// subdomain — so the slug only has to be collision-free within one tenant,// not globally.export const eventEntity = createEntity({ fields: { title: createTextField({ required: true, sortable: true }), slug: createTextField({ required: true }), startsAt: createTimestampField({ required: true }), location: createTextField({}), // Host-authored public event copy — business data, not third-party PII. description: createLongTextField({ personal: false, reason: "is_business_data", }), guestLimit: createNumberField({ sortable: true, integer: true, min: 0 }), },});
export const eventTable = buildEntityTable("event", eventEntity);
// Only the columns findEvent's callers read. selectMany still runs// SELECT * (this type doesn't strip response columns — kumiko's query// handlers don't strip output either), it just narrows what callers see.type EventRow = { id: string; slug: string; title: string };
function selectAllEvents(ctx: HandlerContext) { return ctx.db.selectMany<EventRow>(eventTable);}
// ponytail: O(n) scan over the tenant's events — fine for a handful per// host; a slug/id-filter query is the scale-up. Shared by the three call// sites that need "find one event by a predicate" (event:by-slug,// rsvp-confirmation-mail, rsvp:submit) so they don't duplicate the// scan-then-find.export async function findEvent( ctx: HandlerContext, predicate: (row: EventRow) => boolean,): Promise<EventRow | undefined> { const events = await selectAllEvents(ctx); return events.find(predicate);}import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createLongTextField, createNumberField, createSelectField, createTextField,} from "@cosmicdrift/kumiko-framework/engine";
export const RSVP_STATUSES = ["yes", "no", "maybe"] as const;export type RsvpStatus = (typeof RSVP_STATUSES)[number];
// RSVP: arrives through the anonymous public write. name is required, email// optional (only for the confirmation mail). plusN = extra guests, status =// coming / not coming / maybe. eventId references the event within the same// tenant.export const rsvpEntity = createEntity({ fields: { eventId: createTextField({ required: true }), // Guest PII — personal: "self" so subject-key KMS can encrypt at rest and // crypto-shredding:write:forget-subject can erase the key. find fuzzy/exact // is allowed with a subject annotation since fw#1610 (search decrypts into // a derived Meili index that purge-subject clears on key erase). sortable // stays forbidden on annotated fields — guest-list lookup is search-driven, // not sort-paginated (same convention as solon E9 / show-pony#91). name: createTextField({ required: true, personal: "self", find: "fuzzy", }), email: createTextField({ personal: "self", find: "exact", format: "email", }), status: createSelectField({ options: RSVP_STATUSES, default: "yes", filterable: true, sortable: true, }), plusN: createNumberField({ sortable: true, integer: true }), // Free text from an anonymous guest — still personal data about that // guest, subject = the RSVP row itself (personal: "self"). note: createLongTextField({ personal: "self", find: "none", }), },});
export const rsvpTable = buildEntityTable("rsvp", rsvpEntity);
export const rsvpExecutor = createEventStoreExecutor(rsvpTable, rsvpEntity, { entityName: "rsvp",});📄 On GitHub: schema/event.ts · schema/rsvp.ts