Seed and ship
Local dev: you create events by hand in the dashboard. Production and the live demo at show-pony.kumiko.rocks need content on first boot — a seed — and a container image.
Seeding the demo
Section titled “Seeding the demo”The demo tenant is created by admin bootstrap (tenantKey: "demo"). A second
tenant acme proves subdomain isolation. The seed adds Rooftop Launch Party
(four guests on demo) and Acme Offsite RSVP on acme — the same content
Playwright uses for screenshots. It also writes invite branding per tenant
(Mira Events / immersive hero on demo, Acme Studios / split on acme). On the
free tier (one event per tenant), Winter Warmup on demo is skipped in
production; use the acme invite for a second public page.
Writes go through real handlers via systemWriteAs — no raw SQL:
const event = await ctx.systemWriteAs( "showpony:write:event:create", { title: "Rooftop Launch Party", slug: "rooftop-launch", /* … */ }, DEMO_TENANT_ID,);seeds/2026-06-28-demo-event-rsvps.ts
Each seed filename runs once — recorded in kumiko_es_operations.
await runProdApp({ features: APP_FEATURES, staticDir: "./dist", seedsDir: "./seeds",});bin/main.ts
Demo ops — learning sample, not prod archaeology
Section titled “Demo ops — learning sample, not prod archaeology”Show Pony’s cloud demo is resettable. Prod uses Pulumi resetDbOnDeploy: true
(infra #251) — on each real deploy the showpony database is wiped and reseeded
from scratch.
When demo copy or guests change:
- Edit
2026-06-28-demo-event-rsvps.ts(one file). - Merge to
main— the next deploy resets the DB automatically. - Locally:
docker compose up -d+ runbin/main.tsagainst Postgres, or create events in the UI viabun dev(dev skipsseedsDir).
Do not stack dated “repair” seeds for drift (duplicate slugs, copy patches, membership fixes). That pattern lengthens boot, hides bugs until deploy, and has crash-looped prod when a seed threw on startup.
Boot seeds must not throw — seedsDir runs on every pod start. A failed write
aborts the whole boot (503 until fixed). The guard is
seed-boot-safety.test.ts.
bun dev skips seedsDir — localhost only gets inline boot seeds (accounts,
legal). Demo events appear after you create them in the UI, or when you run the
prod bootstrap against Docker Postgres. Always reproduce seed failures locally
before touching deploy pipelines.
| 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 |
Production entry point
Section titled “Production entry point”bin/main.ts is runProdApp: secrets from env, https origins, built HTML from
./dist:
hostDispatch: ({ host }) => { const h = hostnameOf(host); if (h === BASE_DOMAIN || h === `www.${BASE_DOMAIN}`) { return { kind: "html", file: "admin.html", injectSchema: true }; } return { kind: "html", file: "index.html", injectSchema: false };},bin/main.ts
file is relative to staticDir (./dist) — not ./dist/admin.html.
Live demo mode (cloud only)
Section titled “Live demo mode (cloud only)”Production sets DEMO_READ_ONLY=true (via Pulumi demoReadOnly: true). That:
- Blocks
POST /api/writeand/api/batch— browse and login still work. - Exposes host + sysadmin credentials on the login screen (
GET /demo-mode). - Hides the public RSVP form; invite pages remain viewable.
- Shows a read-only banner in the admin shell after login.
Local dev omits the flag — full interactivity on localhost.
const fetch = withDemoReadOnlyFetch(handle.fetch);// autoListen: false + Bun.serve({ fetch }) so the guard wraps all trafficbin/main.ts and src/demo-mode.ts
Build and image
Section titled “Build and image”bun run build# dist/ — client bundles + admin.html + index.html# dist-server/ — server.js + kumiko.js (schema CLI)Two-stage Dockerfile: build in Bun alpine, runtime copies dist-server, dist/,
kumiko/migrations, seeds/.
ci.yml calls shared infra workflows: build → ghcr.io/.../show-pony:latest →
deploy show-pony-app namespace on merge to main.
The complete files
Section titled “The complete files”Seed:
// Demo content for the deployed public page: Rooftop Launch + Winter Warmup +// sample RSVPs on the "demo" tenant. Matches tutorial screenshots.//// This is the **only** dated seed for demo content — show-pony is a learning// sample with a resettable prod DB (see README "Demo ops"). Do not add repair// migrations; edit this file and reset the database.//// Tenants + accounts come from boot seeds in bin/main.ts / bin/server.ts.// Seeds run once per filename (kumiko_es_operations). Dispatcher writes commit// outside the marker tx — retries MUST be idempotent (slug guard before create).//// RUNTIME: seeds/ is copied into the Docker image WITHOUT src/ and WITHOUT// @cosmicdrift/* in node_modules — only type-only framework imports + inline QNs.
import type { SeedMigration } from "@cosmicdrift/kumiko-framework/es-ops";import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";import { ACME_TENANT_ID, DEMO_EVENT_ID, DEMO_TENANT_ID, findEventBySlug, toRawSqlRunner,} from "./_demo-event-db";
const INVITE_HERO_IMAGE_URL = "showpony:config:invite-hero-image-url";const INVITE_HERO_STYLE = "showpony:config:invite-hero-style";const BRANDING_TITLE = "managed-pages:config:branding-title";const BRANDING_DESCRIPTION = "managed-pages:config:branding-description";const BRANDING_ACCENT_COLOR = "managed-pages:config:branding-accent-color";
function seedWarn(label: string, err: unknown): void { console.warn(`show-pony seed: ${label} skipped — ${err instanceof Error ? err.message : String(err)}`);}
async function seedInviteBranding( ctx: Parameters<SeedMigration["run"]>[0], tenantId: TenantId, entries: ReadonlyArray<readonly [string, string]>,): Promise<void> { for (const [key, value] of entries) { try { await ctx.systemWriteAs("config:write:set", { key, value }, tenantId); } catch (err) { seedWarn(`branding ${key}`, err); } }}
const ROOFTOP_DESC = `✨ You're on the list for something special.
Join us on the 24th floor — cocktails, live DJ, and the Show Pony 2.0 launch at midnight 🎉
Dress code: rooftop-ready 👠Bring someone you'd proudly introduce to the team 💜`;
const ACME_DESC = `🎨 Team offsite season is here!
Workshops in the morning, pizza on the studio floor, and zero mandatory fun runs 😅
RSVP so we know how many chairs (and how much coffee) to order ☕️`;
const GUESTS = [ { name: "Ava Chen", status: "yes", plusN: 2 }, { name: "Marcus Bell", status: "yes", plusN: 0 }, { name: "Priya Raman", status: "maybe", plusN: 1 }, { name: "Diego Santos", status: "no", plusN: 0 },] as const;
export default { description: "demo tenant content: Rooftop Launch Party + sample RSVPs (demo) + seeded event (acme)", run: async (ctx) => { const raw = toRawSqlRunner(ctx.db);
let rooftop = await findEventBySlug(raw, DEMO_TENANT_ID, "rooftop-launch"); if (rooftop) { try { await ctx.systemWriteAs( "showpony:write:event:update", { id: rooftop.id, version: rooftop.version, changes: { description: ROOFTOP_DESC }, }, DEMO_TENANT_ID, ); } catch (err) { seedWarn("rooftop description patch", err); } } else { const event = await ctx.systemWriteAs( "showpony:write:event:create", { title: "Rooftop Launch Party", slug: "rooftop-launch", startsAt: "2026-09-12T19:00:00.000Z", location: "Sky Lounge, 24th floor", description: ROOFTOP_DESC, guestLimit: 80, }, DEMO_TENANT_ID, ); if (!event.isSuccess) { throw new Error( `show-pony seed: event:create failed — ${event.error.code}: ${event.error.message}`, ); } rooftop = await findEventBySlug(raw, DEMO_TENANT_ID, "rooftop-launch"); }
const warmup = await findEventBySlug(raw, DEMO_TENANT_ID, "warmup-drinks"); if (!warmup) { try { await ctx.systemWriteAs( "showpony:write:event:create", { title: "Winter Warmup Drinks", slug: "warmup-drinks", startsAt: "2026-11-28T18:00:00.000Z", location: "Ground-floor bar", description: "Low-key pre-holiday drinks for the team and friends. No agenda — just show up.", guestLimit: 40, }, DEMO_TENANT_ID, ); } catch (err) { seedWarn("warmup event", err); } }
const eventId = rooftop?.id ?? DEMO_EVENT_ID;
for (const guest of GUESTS) { try { await ctx.systemWriteAs( "showpony:write:rsvp:submit", { eventId, ...guest }, DEMO_TENANT_ID, ); } catch (err) { seedWarn(`rsvp:submit for ${guest.name}`, err); } }
const acme = await findEventBySlug(raw, ACME_TENANT_ID, "acme-offsite"); if (acme) { try { await ctx.systemWriteAs( "showpony:write:event:update", { id: acme.id, version: acme.version, changes: { description: ACME_DESC }, }, ACME_TENANT_ID, ); } catch (err) { seedWarn("acme description patch", err); } } else { try { await ctx.systemWriteAs( "showpony:write:event:create", { title: "Acme Offsite RSVP", slug: "acme-offsite", startsAt: "2026-10-03T18:00:00.000Z", location: "Acme HQ — Studio floor", description: ACME_DESC, guestLimit: 60, }, ACME_TENANT_ID, ); } catch (err) { seedWarn("acme event", err); } }
await seedInviteBranding(ctx, DEMO_TENANT_ID, [ [BRANDING_TITLE, "Mira Events"], [BRANDING_DESCRIPTION, "✨ Rooftop invites with sparkle ✨"], [BRANDING_ACCENT_COLOR, "#7c3aed"], [INVITE_HERO_IMAGE_URL, "/heroes/demo-rooftop.webp"], [INVITE_HERO_STYLE, "immersive"], ]);
await seedInviteBranding(ctx, ACME_TENANT_ID, [ [BRANDING_TITLE, "Acme Studios"], [BRANDING_DESCRIPTION, "Clean design. Loud ideas. 🎨"], [BRANDING_ACCENT_COLOR, "#0d9488"], [INVITE_HERO_IMAGE_URL, "/heroes/acme-studio.webp"], [INVITE_HERO_STYLE, "split"], ]); },} satisfies SeedMigration;Production image:
## Production image for show-pony.## Server-bundle variant — the runtime image doesn't know the repo, only:## dist/ ← client build (kumiko-build): admin.html + index.html# dist-server/ ← server bundle (server.js + kumiko.js) + minimal package.json# kumiko/migrations/ ← checked-in SQL migrations (schema apply)# seeds/ ← demo content seeds (run once at boot)## Build context: repository root (standalone repo).
ARG BUN_VERSION=1.3.14ARG NPM_AUTH_TOKEN=ARG BUILD_VERSION=devARG BUILD_TIME=unknown
# ----- build: produces dist/ + dist-server/ ---------------------------------FROM oven/bun:${BUN_VERSION}-alpine AS buildWORKDIR /app
# All @cosmicdrift/* deps are public NPM. @cosmicdriftgamestudio/kumiko-guards# is a restricted org package — NPM_AUTH_TOKEN (GH_PACKAGES_PAT in CI) required.ARG NPM_AUTH_TOKENENV GITHUB_TOKEN=${NPM_AUTH_TOKEN}
COPY . .RUN bun install --frozen-lockfileRUN bun run build
# ----- runtime: bun-alpine, knows only the bundle artifact ------------------FROM oven/bun:${BUN_VERSION}-alpine AS runtime# Global ARGs are only visible in a stage after re-declaration — without these# lines ENV BUILD_VERSION stayed empty and /api/version always showed "dev".ARG BUILD_VERSION=devARG BUILD_TIME=unknownWORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/dist-server ./RUN bun install --production
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/kumiko ./kumiko
COPY --from=build --chown=app:app /app/seeds ./seeds
USER app
ENV NODE_ENV=productionENV PORT=3000ENV KUMIKO_REPO_ROOT=/appENV INIT_CWD=/appENV BUILD_VERSION=$BUILD_VERSIONENV BUILD_TIME=$BUILD_TIME
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ CMD wget --quiet --spider "http://127.0.0.1:${PORT}/health" || exit 1
CMD ["sh", "-c", "exec bun run server.js"]📄 On GitHub: seeds/ · deploy/Dockerfile
That’s the whole app — from install to an image behind a wildcard domain, with the same anonymous RSVP round trip you started with.