What changed, and why
Slice A (#46) made cost-per-run measurable by tagging every AI call with a run id. This makes a run a server fact. Until now a run was purely client state: the browser minted the id. Now POST /api/run mints it and records the run, so the id the cost telemetry groups by points at a real, persisted run — and a per-player run balance can later decrement against it. That balance is the monetization keystone; this PR lays the ground for it without building it yet.
The change is small and has a clear seam. A run store with two adapters (one for dev, one for prod), a one-route endpoint that uses it, a client that calls that endpoint instead of minting locally, and a one-table migration. No balance, no identity, no cost columns — those come later. Cost stays a projection of the telemetry, never a stored value.
The run store: one port, two adapters
The store is a port with two implementations. In-memory is the default, so npm run dev and the unit tests need no external service — it just mints and returns a real id, persisting nothing (a dev run needn't outlive the process). Supabase is the durable one; it inserts the run as a row via the service key, and translates a Supabase error envelope into a thrown error — the one branch the begin-run 500 / client-fallback contract leans on.
The port and its two adapters. The id is minted server-side; cost is never a column here.
server/utils/run-store.ts · 88 lines
⋯ 19 lines hidden (lines 1–19)
⋯ 33 lines hidden (lines 56–88)
Chosen by config — what keeps dev simple
Which adapter runs is decided by config, not a flag: if SUPABASE_URL and SUPABASE_SECRET_KEY are both present it's Supabase, otherwise in-memory. This is the repo's existing env-degradation idiom (the same shape as moderationMode and the AI-lane override), and it's exactly what lets a dev run with nothing wired. The store is memoized so one Supabase client is reused; resetRunStore is the test seam to re-read config.
Creds from runtimeConfig or raw env (null → stay in-memory); memoized factory.
server/utils/run-store.ts · 88 lines
⋯ 57 lines hidden (lines 1–57)
The begin-run endpoint
One route mints the run id and records the run. It takes no input, so there's nothing to validate or inject. A failure is a 500 — and the client treats begin-run as best-effort, so a store outage degrades the ledger, never the turn.
Mint + record + return { runId }; failures are non-fatal to play (see the client).
server/api/run.post.ts · 24 lines
⋯ 12 lines hidden (lines 1–12)
The client mints server-side now
In #46 the client minted the run id locally. Now ensureRunId calls /api/run the first time a run needs an id, then reuses it, and every AI call is tagged with x-run-id as before. Three properties matter. The fallback: if begin-run fails, the client mints a local id so the turn still proceeds and telemetry still groups — the server record is best-effort, the game is not. The in-flight memo: concurrent first-calls share one mint, so two overlapping calls at run start don't create two run rows. And the epoch guard: a resetGame mid-mint abandons the stale mint instead of bleeding the dead run's id into the new one.
ensureRunId: one shared in-flight mint, epoch-guarded, with a local fallback; the now-async aiHeaders.
stores/game.ts · 1064 lines
⋯ 470 lines hidden (lines 1–470)
⋯ 558 lines hidden (lines 507–1064)
The table, and how it's reached
The migration is one table, kept to id + created_at — nothing speculative. RLS is on with no policy on purpose: the anon key can touch nothing, while the service (secret) key bypasses RLS — so the table is server-only by construction. The secret key reaches the server through private runtimeConfig and never the client.
id / created_at; RLS on, no policy = service-key-only.
supabase/migrations/0001_create_runs.sql · 15 lines
Supabase creds as private (server-only) runtimeConfig; absent → in-memory.