Pelotero is a fantasy baseball engine. It ingests data from the MLB Stats API, persists it in PostgreSQL, snapshots league lineups per game, scores teams against a league's scoring rules, and runs auto-drafts. The codebase is a single Haskell binary (pelotero) with an effectful-based effect layer over rel8/hasql, Katip structured logging, and a crem-typed draft state machine. Licensed under the GNU AGPLv3 or later.
The code is layered:
Pelotero.Domain.*: pure data types (players, teams, games, rosters, lineups, scoring, draft). No effect dependencies, no Aeson. Hedgehog-testable in isolation.Pelotero.MLB.*: wire types and conversion.Wire.*parses upstream JSON;Convertproduces domain values plus a list ofConvertWarnings rather than halting on the first malformed record.Fetchis the production HTTP path.Pelotero.DB.*:rel8schemas, transaction-level CRUD, and aProviderKeyedtypeclass that shares the external-id linking pattern acrossPlayer,Team, andGame. The migration runner is custom (hasql-migrationis broken againstcrypton) and treats migration files as immutable post-apply: each file is SHA-256 hashed on first apply, subsequent runs verify the hash.Pelotero.Effects.*: dynamiceffectfuleffects with both DB-backed and in-memory interpreters. The in-memory interpreters mimicBIGSERIALvia anIORef-held counter so property tests don't need Postgres.Pelotero.Sync.*: idempotent ingestion pipelines for rosters, schedule, and boxscores. All three short-circuit on SHA-256 payload match againstprovider_fetch_log.Pelotero.Lineup.Snapshot: freezes each team's lineup intolineup_snapshotkeyed by(league_team_id, game_id). First-snapshot-wins viaON CONFLICT DO NOTHINGplus a pre-check. Fixes the correctness bug where late lineup edits used to retroactively change historical scores.Pelotero.Score: reads lineup snapshots (not the live lineup) and boxscore rows via two date-range queries per scoring call, independent of period length.PointsisRational, notDouble, so a season total agrees with the sum of game lines.Pelotero.Draft+Pelotero.Draft.Machine: the pure transition logic for the draft.cremenforces the state topology at compile time; illegal transitions are type errors.Pelotero.Draft.Runis the effectful auto-draft loop.
The CLI in app/Main.hs is one binary with subcommands; everything else is library code.
From a clean clone to a working dev environment:
git clone https://github.com/cardanonix/pelotero-engine
cd pelotero-engine
nix develop
sops-bootstrap # generate age key + encrypted secrets
pg-start # local PostgreSQL on port 5433
cabal run pelotero -- db check # apply migrations
After that, every subsequent shell entry is just nix develop. The shell hook decrypts PGPASSWORD from sops if a key is available; otherwise the password defaults to the bootstrap fallback (which you should rotate via pg-rotate-credentials).
nix develop provides scripted access to everything you need.
Database (PostgreSQL on port 5433):
pg-start/pg-stop/pg-cleanup: lifecyclepg-connect: psql into the project databasepg-backup/pg-restore <file>: timestamped backups under~/.local/share/pelotero-engine/backupspg-rotate-credentials: generate a new password; update viasops secrets/pelotero-engine.yamlpg-stats: size, connection count, per-schema breakdownwith-db <cmd>: run a command with DB env vars in scope
Secrets (sops + age, derived from your SSH key):
sops-init-key: derive an age key from~/.ssh/id_ed25519sops-bootstrap: create.sops.yamlandsecrets/pelotero-engine.yamlwith a random DB passwordsops-status: show key and decryption statesops-get <key>/sops-exec <cmd>: single-value lookup or env-in-scope execution
Development:
pe-dev: ensure DB is up, print connection infope-deploy: tmux session with DB stats and dev panespe-stop: backup, stop DB, kill tmux session./tui: project TUI (build, run, test, DB shortcuts); see below
The repo ships a small TUI wrapper:
./tui # interactive (use inside `nix develop`; requires gum)
./tui --build-all # CI-friendly: nix-build every cabal executable
./tui --help
Plain cabal works too:
cabal build all
cabal run pelotero -- <subcommand>
Every command runs migrations first. Logs are JSON on stdout via Katip.
cabal run pelotero -- db check
cabal run pelotero -- sync rosters --season 2025
cabal run pelotero -- sync schedule --from 2025-04-01 --to 2025-04-07
cabal run pelotero -- sync boxscores --from 2025-04-01 --to 2025-04-07
cabal run pelotero -- snapshot lineups --on-date 2025-04-01
cabal run pelotero -- score --league-id 1
cabal run pelotero -- draft run --league-id 1
All sync commands are idempotent. They hash the upstream payload and short-circuit if it matches the most recently logged SHA for the same scope. Boxscore syncs always pay the HTTP cost (the bytes have to be fetched to be hashed) but skip parse, convert, upsert, and log on a match.
Operational ordering for a fresh ingest: sync rosters first, then sync schedule for a date range, then sync boxscores for the same range. snapshot lineups and score operate on whatever league data is already in the DB.
Connection parameters via standard libpq env vars plus two tunables. The dev shell sets sensible defaults:
PGHOST # default: $PGDATA (local socket)
PGPORT # default: 5433
PGDATABASE # default: pelotero-engine
PGUSER # default: $(whoami)
PGPASSWORD # default: decrypted from secrets/pelotero-engine.yaml
PELOTERO_DB_POOL_SIZE # default: 10
PELOTERO_DB_ACQUIRE_TIMEOUT # default: 10.0 (seconds)
Migrations live in db/migrations/V<NNNN>__<desc>.sql and are applied on every command. Don't edit applied migrations: create a new file, or the hash check refuses to run.
Two test suites:
cabal test pelotero-test # unit + property tests, no DB
cabal test pelotero-integration-test # DB-backed; requires pg-start
The pure layers are Hedgehog-testable directly. The effectful layers ship in-memory interpreters (runPlayersInMemory, runTeamsInMemory, runGamesInMemory, runFetchLogInMemory, etc.) that mimic DB semantics including surrogate-id assignment without spinning up Postgres. The integration suite covers the rel8 path end-to-end against a real database.
Issues and PRs welcome. The code is opinionated: pure-first, effects-at-the-edge, Aeson-free in Domain, no orphan instances outside Pelotero.DB.Rel8Instances, idempotency wherever a sync touches upstream data. Module-level design decisions are documented in pelotero.chase annotations.
Made with ⚾ by Harry Pray IV.
Copyright (C) 2024-2026 Harry Pray IV. Distributed under the GNU AGPLv3 or later.