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:

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 });
}
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:

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",
});
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({
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