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, searchable: true }), slug: createTextField({ required: true, searchable: true }), startsAt: createTimestampField({ required: true }), location: createTextField({ searchable: true }), description: createLongTextField({ allowPlaintext: "is-business-data" }), guestLimit: createNumberField({ sortable: true }), },});src/features/show-pony/feature.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 — so 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, searchable: true }), name: createTextField({ required: true, sortable: true, searchable: true, allowPlaintext: "guest-list search/sort, no KMS provisioned" }), email: createTextField({ searchable: true, allowPlaintext: "guest-list search, no KMS provisioned" }), status: createSelectField({ options: RSVP_STATUSES, default: "yes", filterable: true }), plusN: createNumberField({ sortable: true }), note: createLongTextField({ allowPlaintext: "anonymous-guest-input" }), },});src/features/show-pony/feature.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 — flat model, no second guests table.eventIdwithin the tenant — no global FK; tenant scoping is enough.
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 — 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/feature.ts
The complete file (entities only)
Section titled “The complete file (entities only)”Entities and tables as they stand before wiring — the full feature.ts grows in
the next chapter:
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createLongTextField, createNumberField, createTextField, createTimestampField,} 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, searchable: true }), slug: createTextField({ required: true, searchable: true }), startsAt: createTimestampField({ required: true }), location: createTextField({ searchable: true }), // Host-authored public event copy — business data, not third-party PII. description: createLongTextField({ allowPlaintext: "is-business-data" }), guestLimit: createNumberField({ sortable: true }), },});
export const eventTable = buildEntityTable("event", eventEntity);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, searchable: true }), // Guest-submitted personal data (collected anonymously). Searchable/sortable // guest-list lookup is a core feature here, which structurally conflicts with // `pii: true` (encrypted fields can't be searched/sorted) — declared plaintext // instead, same pattern as `note` below. name: createTextField({ required: true, sortable: true, searchable: true, allowPlaintext: "guest-list search/sort, no KMS provisioned", }), email: createTextField({ searchable: true, allowPlaintext: "guest-list search, no KMS provisioned", }), status: createSelectField({ options: RSVP_STATUSES, default: "yes", filterable: true }), plusN: createNumberField({ sortable: true }), // Free text from an anonymous submitter — no user FK, so userOwned can't // apply; declare it plaintext business input with an explicit reason. note: createLongTextField({ allowPlaintext: "anonymous-guest-input" }), },});
export const rsvpTable = buildEntityTable("rsvp", rsvpEntity);
export const rsvpExecutor = createEventStoreExecutor(rsvpTable, rsvpEntity, { entityName: "rsvp",});📄 On GitHub: schema/event.ts · schema/rsvp.ts