Skip to content

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:

Event list, one row per event entity
Guest list, one row per rsvp 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 }),
},
});
From 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.

SurfaceLocalCloud
Host login (apex)http://show-pony.localhost:4180/loginhttps://show-pony.kumiko.rocks/login
Demo invite (rooftop)http://demo.show-pony.localhost:4180/e/rooftop-launchhttps://demo.show-pony.kumiko.rocks/e/rooftop-launch
Acme invite (offsite)http://acme.show-pony.localhost:4180/e/acme-offsitehttps://acme.show-pony.kumiko.rocks/e/acme-offsite

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",
}),
},
});
From src/features/show-pony/schema/rsvp.ts.
  • name required, email optional. One-tap RSVP; email only for confirmation.
  • status is an enum: coming / not coming / maybe. RSVP_STATUSES is the single source of truth for validation and UI.
  • plusN is extra guests. The model stays flat, with no second guests table.
  • eventId is scoped within the tenant; there is no global FK.
  • name/email/note are personal: "self": guest PII, encryptable at rest and erasable per row. find stays allowed on an annotated field; sortable doesn’t, which is why the guest-list screen sorts by status instead (chapter 14).
Actoreventrsvp
Host (signed in)create, update, delete, list, detaillist, 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.

export const eventTable = buildEntityTable("event", eventEntity);
export const rsvpTable = buildEntityTable("rsvp", rsvpEntity);
From src/features/show-pony/schema/event.ts · schema/rsvp.ts.

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