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, 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 }),
},
});
— from 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 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" }),
},
});
— from src/features/show-pony/feature.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 — flat model, no second guests table.
  • eventId within the tenant — no global FK; tenant scoping is enough.
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 — 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/feature.ts

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