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:
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({ description: "One event a tenant hosts: title, a slug that forms the public invite link within the tenant's subdomain, start time, optional location, description and guest limit. The host-authored fields are public invite-page copy, not personal data.", fields: { title: createTextField({ required: true, sortable: true, searchable: true, personal: false, reason: "is_business_data", }), slug: createTextField({ required: true, personal: false, reason: "technical_reference", }), startsAt: createTimestampField({ required: true }), location: createTextField({ searchable: true, personal: false, reason: "is_business_data" }), // Host-authored public event copy, like title/slug/location above — // event:by-slug serves it to anonymous visitors by design. 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 the callers read. fetchOne still runs SELECT * (kumiko's// query handlers don't strip output either), it just narrows what callers see.export type EventRow = { id: string; slug: string; title: string };
export function findEventById(ctx: HandlerContext, id: string): Promise<EventRow | undefined> { return ctx.db.fetchOne<EventRow>(eventTable, { id });}
export function findEventBySlug(ctx: HandlerContext, slug: string): Promise<EventRow | undefined> { return ctx.db.fetchOne<EventRow>(eventTable, { slug });}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:
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({ description: "One guest reply to an event of the same tenant: the event's id, the guest's name, optional email and note, attendance status (yes, no or maybe) and the number of additional guests. Name, email and note are guest personal data.", fields: { eventId: createTextField({ required: true, personal: false, reason: "technical_reference", }), // 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",});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({ description: "One event a tenant hosts: title, a slug that forms the public invite link within the tenant's subdomain, start time, optional location, description and guest limit. The host-authored fields are public invite-page copy, not personal data.", fields: { title: createTextField({ required: true, sortable: true, searchable: true, personal: false, reason: "is_business_data", }), slug: createTextField({ required: true, personal: false, reason: "technical_reference", }), startsAt: createTimestampField({ required: true }), location: createTextField({ searchable: true, personal: false, reason: "is_business_data" }), // Host-authored public event copy, like title/slug/location above — // event:by-slug serves it to anonymous visitors by design. 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 the callers read. fetchOne still runs SELECT * (kumiko's// query handlers don't strip output either), it just narrows what callers see.export type EventRow = { id: string; slug: string; title: string };
export function findEventById(ctx: HandlerContext, id: string): Promise<EventRow | undefined> { return ctx.db.fetchOne<EventRow>(eventTable, { id });}
export function findEventBySlug(ctx: HandlerContext, slug: string): Promise<EventRow | undefined> { return ctx.db.fetchOne<EventRow>(eventTable, { slug });}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({ description: "One guest reply to an event of the same tenant: the event's id, the guest's name, optional email and note, attendance status (yes, no or maybe) and the number of additional guests. Name, email and note are guest personal data.", fields: { eventId: createTextField({ required: true, personal: false, reason: "technical_reference", }), // 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