What changed, and why
A run in Revisionist is entirely client-side. The objective, the five dispatches, the timeline ledger, the Chronicle epilogue, and the win/loss verdict all live in the Pinia store and vanish on reload. The only database write today is a billing row that records a run existed so a credit can be charged; it never stores what happened.
This change persists a finished run's end-state to Supabase when the game ends, owns it by the player's account subject, and adds a read-only "your runs" view to revisit any past run. The record is designed for the later (separate) share feature: a stable run id, an immutable versioned snapshot, ownership by subject — so sharing becomes a thin read-only view, not a rebuild.
The pieces, in reading order: the versioned contract that defines the saved shape, the boundary validator that bounds an untrusted save, the migration and the store that hold it, the three routes, the save hook on game end, the read-only view, and the one linking decision the issue left open.
The versioned contract
A snapshot is a flat, fully JSON-native projection of the run's end-state. It is a SNAPSHOT, not a live record: in-progress save/resume is out of scope; a run is short (five messages) and persists at the end. The version field is the load- bearing decision — a reader can branch on it to migrate an older shape rather than mis-read it, so a later change can't silently break runs already saved.
This module is the shared half of the contract: types plus the version constant, and nothing else. It carries only type-only imports, so the client store can import the version and the types without dragging any server-only code into the browser bundle.
The version stamp and the full RunSnapshot shape (composed from the existing game types).
utils/run-snapshot.ts · 108 lines
⋯ 29 lines hidden (lines 1–29)
⋯ 37 lines hidden (lines 47–83)
Bounding an untrusted save
The save endpoint is a public POST, so its body is untrusted. sanitizeSnapshot is the gate: it coerces an arbitrary body into a valid snapshot or returns null, using the same cleanString / cleanArray / clampInt helpers every other route uses. It rejects a run that isn't terminal or has no objective / run id (there is nothing meaningful to persist), bounds every string and number, and — critically — stamps the server's own version rather than trusting the client's.
The gate (terminal status, run id, objective) and the assembled result with the server-owned version.
server/utils/run-snapshot.ts · 165 lines
⋯ 63 lines hidden (lines 1–63)
⋯ 58 lines hidden (lines 91–148)
The schema
The migration adds a run_snapshots table keyed one-to-one to the run id, with the full snapshot as versioned jsonb plus a few denormalized columns (status, title, progress) so the list view need not parse every blob. It also adds a user_id owner column to the billing runs row. Like every existing table, RLS is on with no policy: the table is service-key only, unreachable by the public client.
Naming stays careful: the header gauge's "runs remaining" is a purchasable credit (the balances table); this is a played run (the game record). Same noun, two states, kept distinct in the schema.
The table, its owner/list columns, and the (user_id, created_at) index for the list query.
supabase/migrations/0005_run_snapshots.sql · 32 lines
⋯ 10 lines hidden (lines 1–10)
⋯ 1 line hidden (lines 32–32)
The store: one port, two adapters
Persistence follows the repo's established idiom (see run-store / balance-store): a port interface, an in-memory adapter as the dependency-free default, a Supabase adapter when configured, and a memoized factory chosen by supabaseConfig(). Every method is keyed by the account subject. The save stamps the owner onto the billing runs row too, then upserts the snapshot on the run-id primary key — so the second (post-epilogue) save overwrites the first instead of erroring.
The port, the Supabase save (owner stamp + idempotent upsert), and the config-driven factory.
server/utils/run-snapshot-store.ts · 151 lines
⋯ 24 lines hidden (lines 1–24)
⋯ 49 lines hidden (lines 33–81)
⋯ 37 lines hidden (lines 101–137)
The routes: write once, read scoped
Three thin handlers. POST /api/run-save validates the body, skips a degraded (non-uuid, never-recorded) run as a no-op, resolves the subject, and saves — all best-effort, since the client fires it without blocking the end screen. The two read routes resolve the subject server-side and scope every query to it.
The detail route answers 404 — never 403 — on an id the subject doesn't own. A 403 would confirm the id exists; a 404 leaks nothing. The id is uuid-shape-gated before any query, so a malformed param never reaches the database.
Validate → skip degraded → save, best-effort with a 500 only on a store failure.
server/api/run-save.post.ts · 36 lines
Shape-gate the id, scope to the subject, 404 on a non-owned or unknown run.
server/api/runs/[id].get.ts · 22 lines
Saving on game end
The store builds the snapshot from exactly what the end screen shows — objective, verdict, ledger, dispatches, rolls, and the Chronicle epilogue — reusing the run id already minted for the run. The ledger is mapped to drop its in-memory Date timestamps (a snapshot needs none of the duration math). It fires only for a completed, server-backed run, and a failure is swallowed: persisting the run is a nicety that must never disturb the end screen.
The hook sits in sendMessage's tail, beside the existing chronicle refresh. The run is saved immediately (so it survives even if the epilogue refresh fails), then again once that refresh lands — the idempotent upsert lets the second write capture the final telling.
The game-end hook, the action's guards, and the snapshot it builds and posts.
stores/game.ts · 1377 lines
⋯ 1067 lines hidden (lines 1–1067)
⋯ 183 lines hidden (lines 1085–1267)
⋯ 17 lines hidden (lines 1275–1291)
⋯ 48 lines hidden (lines 1330–1377)
Replaying a finished run, read-only
RunView renders a saved run from the snapshot alone — no store, no AI, no controls. It deliberately reuses EndGameScreen's verdict strings, glyphs, and design tokens, so a past run reads exactly as it did the moment it ended. The /runs list and /runs/:id pages are thin useFetch wrappers around the read routes; an entry point sits in the account popover. This component is also the seam the later share feature reuses: give it a snapshot, it draws the run.
The verdict masthead bound to the snapshot, and the prop-only script — no store.
components/RunView.vue · 114 lines
⋯ 5 lines hidden (lines 1–5)
⋯ 72 lines hidden (lines 24–95)
⋯ 1 line hidden (lines 114–114)
Anonymous → account linking (the one open decision)
A visitor is already an anonymous Supabase user with a durable id, so a run is owned from turn one. Sign-in splits two ways. A new account upgrades that anonymous user in place — same id — so its runs carry over for free, no migration. An existing account is a different id; the anonymous user's runs are left behind.
The issue left this as the one decision: merge the existing-account path now, or ship the new-account path and document the gap. This PR defers the merge and documents it right where the identical balance-merge gap already lives. Re-keying runs without also re-keying the balance would be inconsistent, so the two should move together in a later change.
The module doc, now covering saved runs alongside the balance deferral.
utils/sign-in.ts · 82 lines
⋯ 63 lines hidden (lines 20–82)
How it was verified
npx vitest run is green (751 tests) and npx nuxt typecheck is clean. New tests cover the save → list → load round-trip and ownership scoping, the boundary sanitizer (rejects non-terminal runs, clamps and caps fields, stamps the version), the store save action (the exact posted shape, the no-op guards, the swallowed failure), and the read-only view.
Beyond tests, the feature was exercised in a live boot with the in-memory store: POST a snapshot, list it, open it — with a fresh device seeing nothing, a non-owned id returning 404, and the /runs and /runs/:id pages rendering the run end to end.