Mission: make backups boring, sharing explicit, and player data recoverable — without hurting live API latency.
Code SSOT: src/shared/fleet.ts · src/shared/bestPractices.ts · script scripts/backup-postgres.mjs
Fleet DB map (no secrets): GrudgeBuilder shared/fleet/dbConnections.ts · shared/fleet/storage.ts
Industry wiring: skill grudge-production-wiring · asset index grudge-d1-r2
Design inspiration: PlanetScale — Massively parallel Postgres backups (filesystem/base backup + WAL catch-up, off-primary workers, object storage, prove-restore every cycle).
Grudge is not one database. Backups and sharing rules differ by layer:
| Layer | Authority | What lives here | Backup class |
|---|---|---|---|
| Player SSOT | Railway Postgres (grudge-api-production-0d46) |
users, characters, account bag, island, wallet, JWT auth | P0 — must dump + prove restore |
| Definitions | ObjectStore / info git JSON | recipes, items, weapons, biomes | Git is the backup (tags/releases) |
| Binaries | R2 grudge-assets → assets.grudge-studio.com |
GLB, textures, audio, icons | Versioned keys + multipart re-upload; optional bucket versioning |
| Asset INDEX | Cloudflare D1 (grudge-assets-db, grudge-objectstore, …) |
r2_key → uuid, search meta | Export or re-seed from manifests |
| Realtime rooms | Per-game Railway (Multiverse /api/mv, GRUDOX room, …) |
ephemeral room state | Usually non-durable; do not treat as player SSOT |
| Cache | Puter KV / browser localStorage | mirrors only | Never sole truth |
Apps (Open · client · Multiverse · crafting …)
│
├─ PLAYER STATE → Railway Postgres ← backups P0
├─ DEFINITIONS → ObjectStore / info ← git history
├─ BINARIES → R2 CDN ← keys + magic-byte
└─ INDEX → D1 ← re-seedable
Law: D1 is never player bag/XP SSOT. Room Railways are not character SSOT.
| Scope | Examples | How games share |
|---|---|---|
| Account | bag, resources, GBUX, wallet | One Railway account row; all eras/games read same bag |
| Character | professions, equipment, progress, revision | Per-character UUID; never merge XP across heroes |
| Era / game | gameEra on character |
Filter roster with ?era=; one login, many games |
| Definitions | recipes, item defs | All games load ObjectStore JSON |
| Meshes | grudge6 kits, maps | All games load R2 CDN keys |
DATABASE_URL in the browser. Same-origin /api/* → Railway (or explicit CORS for Puter sites)./api/account/*; progress on /api/characters/:id/progress with revision./api/mv ≠ Carrier). Room state is not a substitute for account/character rows.*.grudge-studio.com, product domains, *.puter.site, localhost.| Bad | Good |
|---|---|
| Two games with separate character tables as dual SSOT | One Railway roster + era filter |
| D1 as “shared bag” | Railway /api/account |
| Multiverse Firebase/Railway room as hero ownership | Railway characters |
| Hardcoding another game’s internal DB URL | Fleet REST + ObjectStore |
PlanetScale’s parallel design targets petabyte sharded clusters. Grudge today is smaller and mostly one primary Postgres (player SSOT) plus edge stores. We still adopt their rules of physics:
| PlanetScale idea | Grudge practice |
|---|---|
| Make it boring | Scheduled job + health probe + offsite artifact; no heroics |
| Don’t thrash the primary | Prefer DATABASE_PUBLIC_URL / off-peak; never long pg_dump from app request path |
| Base + WAL / prove restore | At our scale: full logical dump and quarterly restore drill into a throwaway DB |
| Object storage destination | R2 prefix backups/postgres/<service>/<timestamp>/ (encrypted at rest) |
| Parallelism | Dump tables in parallel (logical); later, parallel by Railway service (api vs rooms) |
| RPO-aware cadence | Dump duration must stay well under schedule interval (e.g. nightly 15m dump → OK for daily RPO) |
| Backups ≠ only DR | Use dumps to seed staging, migrate eras, replace a broken service |
| Freeze point T | Every artifact ships meta.json with backupTime, source, tables, rowCounts |
Service: grudge-api on Railway · public health:
https://grudge-api-production-0d46.up.railway.app/api/health
Priority tables (names may evolve; always verify with \dt / schema):
| Priority | Tables (typical) | Why |
|---|---|---|
| Critical | users, accounts, characters, account_inventory, account_resources |
Login + heroes + bag |
| High | home_islands, player_ships, gbux_transactions, uuid_ledger |
Island / economy |
| Medium | sessions, combat logs, dungeon runs | Reconstructible or low RPO need |
Methods (ordered preference):
scripts/backup-postgres.mjs (this repo) or Docker pg_dump:
pg_basebackup + continuous archive) — adopt when DB size or RPO demands it (PlanetScale’s gold path). Not required until growth forces it.Inspired by “backup workers per shard,” we parallelize by table on one Postgres:
backup job (off API process)
├─ workers: dump users, accounts, characters, … concurrently
├─ write backups/<stamp>/tables/*.jsonl
├─ write backups/<stamp>/meta.json ← time T, counts, git sha
└─ optional: upload → R2 backups/postgres/grudge-api/<stamp>/
Rules:
pg_dump --serializable-deferrable when using pg_dump.backups/ is gitignored).SELECT count(*).PlanetScale rebuilds from the previous backup each run so restore is continuously proven. Grudge minimum bar:
| Cadence | Action |
|---|---|
| Every dump | Write meta.json; verify non-zero critical tables |
| Weekly | npm run restore:postgres -- --docker — load latest dump into local postgres:16, compare counts to meta.json |
| After schema migration | Immediate dump + restore smoke |
| Disaster drill (quarterly) | Full restore to a new Railway Postgres → point a staging API at it |
| Store | Backup / recovery |
|---|---|
| D1 | wrangler d1 export <name> --remote or re-seed from seed-d1 / ObjectStore manifests (preferred recovery for asset index) |
| R2 | Immutable versioned keys; re-upload from ObjectStore dist/ + convert pipeline; magic-byte verify |
| ObjectStore JSON | Git history + GitHub Releases; deploy is republish |
| Room Railways | Document as ephemeral; if a game stores durable progress only in room state, that is a bug — fix wiring to player Postgres |
| Item | Rule |
|---|---|
DATABASE_URL / DATABASE_PUBLIC_URL |
Railway / keytar only — never VITE_*, never client bundles |
| Dump artifacts | Local backups/ or private R2 prefix; admin-only |
| Restore credentials | Separate staging credentials; never point production API at a half-restored DB |
# From grudge-dev-tool — set DATABASE_URL from Railway (public proxy for local tools)
$env:DATABASE_URL = "<from Railway Postgres → Connect → public URL>"
npm run backup:postgres
# artifacts → backups/<iso-stamp>/
Optional flags:
node scripts/backup-postgres.mjs --concurrency 4
node scripts/backup-postgres.mjs --tables users,accounts,characters
node scripts/backup-postgres.mjs --pg-dump # Docker postgres:16 pg_dump if available
# After local dump — use existing R2 credentials (keytar / env)
# Target key pattern:
# backups/postgres/grudge-api/2026-08-01T21-00-00Z/meta.json
# backups/postgres/grudge-api/2026-08-01T21-00-00Z/tables/characters.jsonl
Prefer Worker signed PUT or S3 API with multipart for large dumps. Same admin gate as asset uploads.
npx wrangler d1 export grudge-assets-db --remote --output=backups/d1-assets-$(Get-Date -Format yyyyMMdd).sql
npx wrangler d1 export grudge-objectstore --remote --output=backups/d1-objectstore-$(Get-Date -Format yyyyMMdd).sql
# After a dump — never uses production DATABASE_URL
npm run restore:postgres -- --docker
# or explicit folder
npm run restore:postgres -- --dir backups\<stamp> --docker --keep
# custom-format dump
npm run restore:postgres -- --dir backups\<stamp> --docker --pg-restore
Script: scripts/restore-postgres.mjs. Loads tables/*.jsonl.gz into restore_<table> jsonb rows and checks counts against meta.json. --docker starts ephemeral postgres:16 on localhost:55432. Staging URL only via RESTORE_DATABASE_URL (Railway hostnames refused unless --i-know-staging).
drizzle / SQL migrations from GrudgeBuilder).pg_restore for full.dump, or JSONL prove-restore above).DATABASE_URL only; smoke /api/health + one test account.Adopt physical backups when any of these hit:
Then:
wal-g or managed equivalent).Until then, parallel logical dumps + proved restores are the correct Grudge bar.
When touching databases or multi-game state:
[ ] Is this player state? → Railway only
[ ] Is this a definition? → ObjectStore / info, not inventing tables
[ ] Is this a mesh? → R2 + D1 index after convert
[ ] New game multiplayer? → own room Railway if needed; still characters on player API
[ ] Schema change? → migration + immediate backup:postgres
[ ] Sharing bag/XP? → account vs character scope correct
[ ] Never DATABASE_URL in frontend
[ ] Never commit backups/
[ ] After dump: npm run restore:postgres -- --docker (prove restore)