Chess Club Admin System -- a Scala 3 / ZIO application that integrates with the Chess.com public API for club membership tracking, player recruitment, and match history analysis.
- Scala 3, sbt, JDK 25 LTS (Temurin) — exact versions in
project/Versions.scala,project/build.propertiesand.sdkmanrc - ZIO 2.x (zio-http, zio-json, zio-config)
- Magnum ORM + PostgreSQL 17
- Docker Compose for local dev
Prerequisites: JDK 25 LTS, SBT, Docker
The JDK is pinned in .sdkmanrc. With SDKMAN installed, sdk env install in the repo root fetches and selects it; sdk env alone selects it once it is present, and setting sdkman_auto_env=true in ~/.sdkman/etc/config applies it on cd. Without SDKMAN, install Temurin 25 by whatever means you prefer and point JAVA_HOME at it.
# Select the pinned JDK (SDKMAN)
sdk env install
# Start PostgreSQL (creates both ccas and ccas_test databases)
docker compose up -d
# Configure environment
cp .env.example .env # edit with your values
# Enable the tracked git hooks (pre-push runs the docs check and `sbt lint`, then the full test suite)
git config core.hooksPath .githooks
# Have git blame skip the commits that only reformatted the tree
git config blame.ignoreRevsFile .git-blame-ignore-revs
# Run the server (default port 8080)
sbt "runMain ccas.cli.Main server up"
# Run tests (requires ccas_test database from docker compose)
sbt test
# Check formatting (scalafmt) and the coding conventions (scalafix), as CI does
sbt lint
# Apply both tools' fixes
sbt lintFixcore.hooksPath and blame.ignoreRevsFile are per-clone and not carried by git clone, so set them once on every machine you work from — otherwise pushes skip the pre-push test run silently, and git blame credits every reformatted line to the reformat.
In IntelliJ, set the Scala code style's formatter to Scalafmt (Settings → Editor → Code Style → Scala), so that Reformat Code runs scalafmt with .scalafmt.conf. Optimize Imports is IntelliJ's own and may order imports differently; sbt lint catches any it leaves.
On a new dev machine, scripts/install-cli.sh does the whole client-side setup in one go — stages the binary, symlinks it onto your PATH, and installs shell completion for your shell:
scripts/install-cli.sh # add --shell zsh|bash|fish to override shell detection
scripts/install-cli.sh --help # flags: --bin-dir, --stage, --no-stage, --no-rcIt is idempotent: re-run it after a git pull (with --stage to force a rebuild) and it refreshes the launcher and completion script, leaving your rc file untouched if the line is already there. It deliberately stops short of server configuration — run ccas config init afterwards. The manual equivalent of each step is below.
sbt stage (sbt-native-packager) produces two launcher scripts under target/universal/stage/bin/:
ccas— the CLI / primary entry pointccas-server— the standalone server entry (used by hosted deploys)
To run ccas from anywhere, stage once and symlink the launcher into a directory on your PATH:
sbt stage
ln -sf "$PWD/target/universal/stage/bin/ccas" ~/.local/bin/ccas # ensure ~/.local/bin is on your PATH
ccas --helpThe symlink keeps working across rebuilds — sbt stage regenerates the launcher in place. Re-run sbt stage after changing code.
The CLI is a thin HTTP client to a local CcasServer, so start a server first (it is not auto-spawned):
# Terminal 1 — the server. Needs DATABASE_URL/DB_* and CCAS_CONTACT_EMAIL.
# Under sbt these are sourced from .env automatically:
sbt "runMain ccas.cli.Main server up" # boots CcasServer on 127.0.0.1:8080
# Or, with those vars exported into the environment, the staged binary: ccas server up
# Terminal 2 — commands. No environment needed; they just call the server.
ccas use-club team-alpha # set the current club once…
ccas jobs
ccas cancel <job-id> # request cancellation of a running job
ccas membership # …then commands target it with no slug
ccas membership --club team-beta # override for one command
ccas membership --all # every managed club
ccas recruit --target 30 --club team-alpha
ccas blacklist list --club team-alpha
ccas --help # full command tree
ccas <command> --help # per-command flagsCommands: server {up|down|status|list}, use-club, membership, history, recruit, stats, jobs, logs, cancel, blacklist {add|list|remove}, schedule {list|add|remove}, club {add|remove|list|show}, config {get|set|unset|list|show|path|init}, completion.
Cancelling a job. ccas cancel <job-id> requests cancellation of a running job — it interrupts the job's fiber on the server, which records the run as Cancelled. Cancellation is best-effort: an in-flight blocking database statement runs to completion first, so the job stops at its next interruptible point rather than instantly. A cancelled recruitment run leaves the candidates it found so far as deferred (nothing invited), so you can review and confirm them afterwards. Cancel is single-server-scoped — it reaches jobs running on the server it is sent to.
Club targeting. Slug-requiring commands take the club via --club <slug> rather than a positional argument; membership/history accept a comma-separated --club a,b or --all (every managed club) — but not both at once (--all with --club is rejected as a likely mistake). When neither is given, the command falls back to the current club set with ccas use-club <slug> (stored as current_club in the config file); an explicit --club/--all always wins. ccas use-club is a local config write that always succeeds — even with the server down — so it works offline. When a server is reachable it additionally makes a short best-effort check: it asks which club the slug reaches and remembers that club, not just the name, noting when the slug is a former name or reaches no club at all; it also refreshes the completion cache and notes if the club isn't one you manage (it never rejects — an unmanaged club is a valid target). If the server can't be reached in a second or two it falls back to a cache-based hint and sets the club anyway.
Clubs rename, and names get recycled. A slug you type is resolved to a club rather than matched: a name the club no longer holds still reaches it, and if the name has since been taken over, the command acts on whoever holds it now — the CLI says so either way. The one thing it won't guess at is a name nobody holds any more that several clubs held before; it lists them and asks which you meant, or, with output redirected, tells you to name one with --club-id <id>, which every command that names a club takes (club add / remove / show take it in place of the slug). An id names a club outright, so it replaces --club rather than joining it — passing both is a usage error, since two ways of naming one club can disagree. To check which club a name or id reaches before acting on it, ccas club show prints the club and, when the name is not its own, why — the same answer the next command would act on, and nothing run against it.
ccas use-club # print the current club (exit 2 if none is set)
ccas use-club team-alpha # set it
ccas use-club --clear # unset it
ccas club list # the managed set; '*' marks the current club
ccas club show team-alpha # which club a name reaches, without acting on itThe current club and the managed set are separate things: ccas club {add,remove,list} edits the server-side set of clubs you run CCAS for (shared by every client of that server), while ccas use-club is a local per-machine pointer at which of them your commands target by default. Removing the club you're currently using clears the pointer, so the next bare command asks you to pick one instead of silently running against a club you no longer manage.
Flags go before positional arguments. ccas use-club --clear works; ccas use-club team-alpha --clear does not — the underlying CLI library swallows an option written after a positional and treats it as another argument. use-club detects this and fails with the working order rather than acting on a misread command, but elsewhere in the tree a trailing flag is silently ignored, so keep options first: ccas blacklist add --club team-alpha alice bob.
The server URL resolves in order: a global --server <url> flag, else api_url from the config file, else the built-in default http://127.0.0.1:8080.
ccas --version prints the version; ccas --help and ccas <command> --help show usage.
ccas server up runs in the foreground (logs to stdout, Ctrl-C to stop). To background it:
ccas server up --detach # (or -d) spawns a detached server, waits for /health/ready, prints "started, pid=<n>, db <host>/<db>"
ccas server status # "running (ready) pid <n> 127.0.0.1:8080 db ok (<host>/<db>)" (exit 0), else "not running" (exit 1)
ccas server down # reads this port's pid file, asks first unless the server's database is local, sends SIGTERM, waits for a clean shutdown
ccas server list # one row per pid file, plus a foreground server on this shell's port (marked *): "<port> pid <n> ready db <host>/<db>"
ccas server status --port 8080 # status or down for another port's server, instead of this shell'sserver status probes the loopback /health/ready endpoint (so it also detects a foreground server, which writes no pid file) and augments that with the pid file; it exits 0 only when the server is up and the database is reachable. The database it names is the one the server reports, not this shell's configuration.
Every boot (ccas server up, detached or not, and ccas-server) asks Boot against <host>/<db>? [y/N] on the terminal unless the database's address is localhost or a loopback IP, because a boot writes to its database before it serves anything. Only the address is checked: a tunnel to a remote database through localhost never asks, and an address that can't be read from the configuration does. Redirecting the server's output doesn't skip the question, and a server started in the background from a shell (ccas-server &) stops at it until brought back with fg. With no terminal at all (systemd, cron) nothing asks.
ccas server down asks the same way, Stop the server on 127.0.0.1:<port> (pid <n>, db <host>/<db>)? [y/N], unless the database the server reports is local, since a shell for the wrong worktree reaches the other server through its port. A server that reports no database is asked about too. With no terminal to ask on (a script, an agent's shell) such a server is not stopped. Its stopped line names the same port, pid and database.
The detached server writes its pid to ${XDG_STATE_HOME:-~/.local/state}/ccas/ccas-<port>.pid and its stdout/stderr to <log_dir>/server-<port>.log (log_dir from the config file, default ~/.local/state/ccas/logs). Both are named by the port, so two servers on one machine (one per worktree, say, each with its own SERVER_PORT and database) keep apart, and status and down act only on the server at this shell's port unless given --port <n>. ccas server list reads every pid file and probes every port at once, so a slow database delays the list by one probe's timeout, not one per server. It also lists this shell's port when a server answers there without a pid file, which is how a foreground server there shows; one on another port writes no pid file and isn't listed. With nothing to list, it prints no rows and says so on stderr. It only reads, leaving a stale pid file in place: ccas server down --port <n> clears it. Job logs are not named by the port: by default every server on the machine, whatever directory it was started from, writes them to ${XDG_STATE_HOME:-~/.local/state}/ccas/job-logs. Give one of two such servers its own JOB_LOGS_DIR when their databases' job_log_retention_days differ, since each server's sweep applies its own window to every log there. Reports, which the apps' own mains write (and a server only for a History job it stops midway), go to ${XDG_DATA_HOME:-~/.local/share}/ccas/output by default, and CCAS_OUTPUT_DIR moves them; runs against two databases that share a club otherwise archive each other's reports (ADR 0024). ccas server up --detach refuses a port where a server already answers: already running on 127.0.0.1:<port>, pid=<n> for one it started, and a note to stop it first for one it didn't. It waits 30s for the server to report ready before giving up and stopping it — raise that with --ready-timeout-seconds <n> or the config file's ready_timeout_seconds when the database is slow to wake (a suspended serverless compute can take longer than the default). For a supervised/long-lived server (systemd, Docker, Render) run the foreground ccas-server binary instead.
Two gotchas. The parser (zio-cli) expects options before positional arguments —
ccas blacklist add --club team-alpha alice bob, not… alice bob --club team-alpha(a misplaced flag is silently swallowed as a username). And the staged binary reads configuration from the process environment only — it does not load.env(that is auto-sourced forsbt run/sbt runMain).
ccas completion <bash|zsh|fish> prints a self-contained, pure-shell completion script. It boots the JVM once when you install it; afterwards every <TAB> runs entirely in the shell (no JVM, no network), so completion is instant. The scripts complete subcommands and flags, plus club slugs (after --club, and for the ccas use-club argument) and recent job ids (for ccas logs and ccas cancel) read from cache files, and the settings ccas config knows, with the values of those that take a fixed set (ccas config set CCAS_BODY_STORE_BACKEND <TAB> offers fs s3).
scripts/install-cli.sh installs it for you; the manual equivalents are:
# bash — install once (bash-completion auto-loads it)
ccas completion bash > ~/.local/share/bash-completion/completions/ccas
# zsh — add to ~/.zshrc, after `autoload -Uz compinit && compinit`
eval "$(ccas completion zsh)"
# fish
ccas completion fish > ~/.config/fish/completions/ccas.fishFor zsh, prefer generating the script once to a file and sourcing that (ccas completion zsh > ~/.config/ccas/completion.zsh, then source ~/.config/ccas/completion.zsh at the end of ~/.zshrc) — the eval form boots the JVM on every new shell, while a pre-generated file keeps shell startup JVM-free. That is what the install script does.
Dynamic candidates come from ${XDG_CACHE_HOME:-~/.cache}/ccas/clubs.txt and recent-jobs.txt, refreshed automatically as you run normal commands (the club list is fetched from GET /api/managed-clubs at most every few hours — falling back to GET /api/clubs only while you manage no clubs yet; each submitted job id is recorded). On a fresh install these are empty, so only subcommands and flags complete until the first command populates them — unless default_clubs is set in the config file, which seeds the club list so completion works immediately.
Jobs can be submitted via the REST API or run on a schedule.
| Job | Description |
|---|---|
| Recruitment | Scouts players from source clubs, filters by rating/activity/experience criteria, produces recruitment candidates |
| Membership | Reconciles the club member list against the Chess.com API, tracks joins and departures |
| History | Crawls match archives for club members, discovering new players wave by wave |
| MatchRef | Resolves player/club references to concrete match boards (runs automatically after other jobs) |
| Stats | Summarises club match results over a date range |
| ClubData | Refreshes club profiles and admin lists (scheduled only — no submit endpoint) |
GET /health 200 (liveness)
GET /health/ready 200 | 503 (checks DB connectivity); body {"database": "<host>[:port]/<db>"}, no credentials
POST /api/jobs/recruitment
{ club, alias?, target?, cumulative?, sourceClubs?, timeLimitMinutes?, explore? }
POST /api/jobs/membership
{ clubs, trustUsernames? }
POST /api/jobs/history
{ clubs, full?, refresh? }
POST /api/jobs/matchref
(no body)
POST /api/jobs/stats
{ club, since?, until? }
GET /api/jobs List recent jobs (last 50)
GET /api/jobs/:id Job status by ID
POST /api/jobs/:id/cancel Request cancellation of a running job
club — and each element of the batch endpoints' clubs — names one club, as either {"kind": "by_id", "clubId": 123} or {"kind": "by_slug", "slug": "team-alpha"}. One or the other, never both: an id resolves whatever the club has since been renamed to, a name is looked up (current names first, then former ones), and a request carrying both could disagree with itself. Every other route that names a club — blacklist, recruitment criteria, managed clubs, schedules, recruitment reports — takes it the same way, or in a URL as exactly one of ?clubId= / ?slug=, and answers with how it resolved.
Blacklist entries are not jobs — they are synchronous /api/blacklist routes. The full route surface, including those, the recruitment-criteria and managed-club routes and the per-run recruitment reporting, is in docs/architecture.md § Routes.
GET /api/schedules List all schedules
POST /api/schedules { kind, club?, params?, triggerType?, intervalHours?, cron?, timezone?, misfire? }
PUT /api/schedules/:id { intervalHours?, cron?, timezone?, misfire?, enabled?, params? }
DELETE /api/schedules/:id
The variables needed to boot are in .env.example; application.conf holds the full set. For the packaged binary the easiest way to set them is ccas config init (see Server config file below), which persists them to a local file the server reads at boot — no hand-exported env vars. Required variables:
| Variable | Description |
|---|---|
CCAS_CONTACT_EMAIL |
Contact in the User-Agent header of Chess.com API requests. Anything that calls Chess.com — the server included — refuses to start without it |
DATABASE_URL or DB_USER + DB_PASSWORD + DB_NAME + DB_PORT + DB_HOST + DB_SCHEMA |
PostgreSQL connection. DATABASE_URL (JDBC form, single-quoted to escape &) takes priority; the DB_* fields are only consulted when it's absent |
Provider connection strings work as-is. Neon, Heroku, Render, Supabase and friends hand out the libpq URI —
postgresql://user:pass@host/db?sslmode=require&channel_binding=require— which pgJDBC rejects outright: it accepts neither thepostgresql://scheme (it wantsjdbc:postgresql://) nor credentials in userinfo position (it wants?user=/?password=), and a verbatim paste used to fail at boot with the driver-levelRuntimeException: Failed to get driver instance for jdbcUrl=….DATABASE_URLnow accepts either form and converts, so paste whichever your provider gives you. Credentials are lifted out of the URL and handed to the pool separately either way, so they cannot leak through that message.channel_bindingbecomes pgJDBC'schannelBinding: withsslmode=requirepgJDBC checks no certificate, so channel binding is what proves the server is the one you meant; a JDBC-form URL asks for it withchannelBinding=require. The other libpq-only parameters providers include, such asconnect_timeout, are dropped.
Optional overrides with defaults:
| Variable | Default | Description |
|---|---|---|
SERVER_PORT |
8080 | HTTP server port |
SERVER_HOST |
127.0.0.1 | Bind address. Loopback by default (single-user, no-auth local model); set 0.0.0.0 for hosted deploys |
SCHEDULER_POLL_MINUTES |
15 | How often the scheduler checks for due jobs. Keep ≥ 15 against Neon so polls don't keep the compute always-warm (it auto-suspends after ~5 min idle, budget is 192 active-hr/mo on free tier) |
SCHEDULER_<JOB>_INTERVAL_HOURS / SCHEDULER_<JOB>_ENABLED |
— | Boot-seed cadence/enable, per job, defaults in application.conf. Two groups: the global sweeps MATCHREF and CLUBDATA, seeded once; and HISTORY and MEMBERSHIP, seeded once per managed club. Seed-only: at every boot, a schedule of that kind that does not exist yet (for each managed club, in the second group) is created with these values, and one that exists keeps its own. So a seeded schedule removed with ccas schedule remove returns at the next boot; to stop one, disable it (PUT /api/schedules/:id with {"enabled": false}) |
DB_POOL_MAX / DB_POOL_MIN_IDLE |
20 / 2 | HikariCP pool sizing (set MIN_IDLE=0 for Neon scale-to-zero) |
DB_POOL_CONNECTION_TIMEOUT / DB_POOL_IDLE_TIMEOUT / DB_POOL_MAX_LIFETIME / DB_POOL_KEEPALIVE_TIME |
30 000 / 600 000 / 1 800 000 / 120 000 ms | HikariCP timeouts |
CHESS_COM_API_COOLDOWN_SECONDS |
15 | Backoff cooldown after rate limiting (Cloudflare has its own, CHESS_COM_API_CF_COOLDOWN_SECONDS) |
API fan-out has no permit variable of its own: the in-flight ceiling is the last CHESS_COM_API_RECOVERY_TIERS entry, between which the gate moves with observed success (ADR 0012), and fan-out is then capped again against the DB pool (ADR 0004). See application.conf for the full set of tunable parameters.
Letting a Neon compute scale to zero. Neon suspends an idle compute after ~5 minutes, but any open or periodically-pinged connection counts as a live session and keeps it awake, burning the free tier's monthly compute hours. Three pool settings together let it suspend shortly after a run finishes: DB_POOL_MIN_IDLE=0 (drain the pool so the session actually closes), DB_POOL_KEEPALIVE_TIME=0 (stop Hikari's idle ping resetting Neon's timer), and DB_POOL_IDLE_TIMEOUT=30000 (retire idle connections in 30 s rather than 10 minutes). The cost is a cold-start delay on the next query and the occasional transient 08xxx reconnect, which the socket-timeout and transient-retry hardening absorbs. Expect benign "idle" warnings if a command sits at a confirmation prompt.
The packaged ccas / ccas-server binaries read the settings above from the process environment — they do not load a .env file (that's auto-sourced only for sbt run). Rather than export them by hand before every ccas server up, manage them with ccas config, which persists them to ${XDG_CONFIG_HOME:-~/.config}/ccas/ccas.env — an owner-only (0600) KEY=VALUE file:
ccas config init # interactive wizard: contact email, DB connection, port
ccas config set SERVER_PORT 9090 # set one value
ccas config get DATABASE_URL # print one value (raw)
ccas config list # show known settings (secrets shown as ****)
ccas config list --show-secrets # reveal secret values
ccas config unset SERVER_PORT # remove a value
ccas config path # print the file's path
At boot the server applies this file as a JVM overlay, so the resolution order is: process environment → ccas.env → built-in default. A real env var (or a systemd Environment= / container secret) always wins over the file, so hosted deployments keep injecting secrets through the environment. DATABASE_URL and DB_PASSWORD live in the 0600 file and are redacted by list/show unless --show-secrets. The file is plain env-var form, so it doubles as a systemd EnvironmentFile. ccas config set DATABASE_URL <url> stores the URL literally (no shell-quoting needed) — only single-quote the & yourself if you intend to source the file in a POSIX shell. This is the server-bootstrap file; it is separate from the CLI client's config.conf.
Some app-wide policy lives in the app_setting table (key TEXT PK, value TEXT) rather than HOCON/env, so it stays consistent across every process on one DB and is tunable without a redeploy. Each setting has a compiled-in default used when its row is absent; the DB row, once set, overrides it. The registry is AppSetting.all:
| Key | Default | Effect |
|---|---|---|
cache_retention_days |
60 | How long cached Chess.com responses are kept before the retention sweep drops them |
fetch_failure_retention_days |
30 | How long api_fetch_failure audit rows are kept |
job_log_retention_days |
14 | How long per-job log files are kept |
progress_refresh_interval_ms |
100 | How often a followed job's progress bar redraws |
Change one with SQL:
INSERT INTO app_setting (key, value) VALUES ('cache_retention_days', '14')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;cache_retention_days, fetch_failure_retention_days and job_log_retention_days are re-read by every pass of the server's daily retention sweep, so a change lands within a day; job_log_retention_days must be positive, and a non-positive value is rejected in favour of the compiled default rather than sweeping the whole log directory on every pass. progress_refresh_interval_ms is read per progress subscription, so it applies to the next followed job.
Cached Chess.com response bodies are content-addressed by SHA-256 and stored outside Postgres; only the metadata index (api_response_cache) stays in the database. The backend is chosen by CCAS_BODY_STORE_BACKEND:
| Backend | Notes |
|---|---|
fs (default) |
Local disk, no configuration. Root defaults to ${XDG_CACHE_HOME:-~/.cache}/ccas/bodies; override with CCAS_BODY_STORE_FS_ROOT. Give it an absolute path — a relative one resolves against whatever directory the process was launched from, and the server warns at startup when it sees one |
s3 |
Any S3-compatible endpoint. Used against Cloudflare R2, where egress is free. Requires CCAS_R2_ENDPOINT, CCAS_R2_BUCKET and the CCAS_R2_ACCESS_KEY / CCAS_R2_SECRET_KEY pair (CCAS_R2_REGION defaults to auto) |
Set the two R2 credentials with ccas config set rather than in .env — ccas config stores them 0600 and redacts them from list.
The store is deliberately non-critical: it is a cache, not a source of truth, so a lost or unreachable body just re-fetches from Chess.com. An outage degrades caching to off — reads fall through to the network, writes are skipped, requests keep succeeding — and the cache self-heals on the first successful write. The server logs one warning when the store first fails and one info line when it recovers, rather than one line per failed operation.
Because waiting on a cache is never obligatory, the same applies to a store that is merely slow: every operation has a deadline, and breaching it degrades exactly like an error.
| Key | Default | Effect |
|---|---|---|
CCAS_BODY_STORE_READ_TIMEOUT_MS |
5000 | Deadline for a cache read. Breaching it costs one Chess.com request |
CCAS_BODY_STORE_WRITE_TIMEOUT_MS |
10000 | Deadline for a cache write. Breaching it only means the response is not cached |
CCAS_R2_CONNECT_TIMEOUT_MS |
2000 | s3 transport connect timeout |
CCAS_R2_SOCKET_TIMEOUT_MS |
5000 | s3 transport socket timeout. Must not exceed the widest deadline above |
The defaults are deliberately loose, and tightening them is not free: a bypassed read costs a full Chess.com request, so a read deadline below the store's real p99 turns the cache into an amplifier of upstream load. Tighten from measurement — client_stats.cache_unserved counts entries that were counted as cache hits but could not actually be served — rather than by guessing.
One accepted wart: a write abandoned at its deadline can still land in the bucket afterwards, while CCAS has (correctly) skipped the database row that would reference it. Nothing can later find that object, because the cleanup sweep walks database rows. The same is true of an object whose delete fails during cleanup, which is not new. It costs storage only, and self-heals if the identical body is ever fetched again.
To collect them, set an age-based lifecycle rule on the bucket — S3/R2 rules cannot see CCAS's tables, so there is no "unreferenced" rule to write. The longest retention window (cache_retention_days, fetch_failure_retention_days) is a floor rather than a safe value: the rule expires objects by age while retention expires rows by age, and an entry that keeps revalidating holds a young row over an arbitrarily old object. Allow a wide margin — 180 days against the 60-day default — and scope the rule to a key prefix if the bucket ever holds anything besides cached bodies. Note that R2's default multipart abort rule is not an expiry rule and collects none of this. Too short degrades rather than corrupts, though not identically for both kinds of object: an early-expired cached response is simply refetched, at the cost of one request, while an early-expired fetch-failure body has nothing to refetch from — that audit row just loses its response body.
One CcasServer per database is the supported model. Running CLI commands alongside that server against the same DB is fine — the CLI has no JobRunner, so it never touches scheduled-job state; it only shares the Chess.com egress.
Running two or more servers against one DB is not supported and is at your own risk: a starting server marks all Running jobs as failed with no instance-ownership filter (it would kill another server's live jobs — #110), and the Chess.com rate limiter is per-process (N servers ≈ N× the per-IP request rate — #111), and each server's job-log sweep spares only the jobs running in its own process, so of two servers sharing a job-log directory, either deletes the log of a job the other is running once that job has written nothing for a whole retention window. Each server also runs its own daily retention sweep, so N servers sweep N times against one orphan set — harmless (row deletes are idempotent, object deletes best-effort) but wasteful, and it multiplies the mid-flight cache race described in ADR 0007. Multi-server hosting is tracked separately under #60 and is gated on fixing both.
The ccas CLI (the client, not the server) reads optional settings from ${XDG_CONFIG_HOME:-~/.config}/ccas/config.conf (HOCON):
api_url = "http://127.0.0.1:8080" # default server URL
default_clubs = ["team-alpha", "team-beta"] # seed shell-completion suggestions
log_dir = "~/.local/state/ccas/logs" # where `ccas server up --detach` writes server-<port>.log
current_club = "team-alpha" # default club when a command omits --club (via `ccas use-club`)
ready_timeout_seconds = 90 # readiness wait for `ccas server up --detach` (default 30)- Server URL resolves
--server <url>flag →api_url→ built-inhttp://127.0.0.1:8080. default_clubsseeds the completion club list on a fresh install (before anyGET /api/managed-clubsround-trip); real slugs replace it on the next command.current_clubis the club a slug-requiring command targets when neither--clubnor--allis given. Set it withccas use-club <slug>(which rewrites just this key, preserving your other keys and comments) rather than editing by hand.ready_timeout_secondsis the readiness wait forccas server up --detach, overridden per run by--ready-timeout-seconds <n>. The right value depends on the database, not on the code: 30s is generous for a local Postgres and tight for a provider that suspends idle computes.- A missing file is fine — the CLI falls back to built-in defaults. A malformed file fails fast with
error: invalid config file <path>: …(exit 2).
This is separate from the server's application.conf / environment configuration above.
Production data lives on Neon's free plan, which retains only 24h of point-in-time recovery. scripts/backup-neon.sh takes a weekly compressed logical dump to local disk as the disaster-recovery floor. It is read-only (pg_dump only) and reads the server's connection config the way the server does — DATABASE_URL in either form, otherwise the DB_* fields, each from the environment and otherwise from ccas.env — so it dumps the database the server uses, with nothing to export. The rebuildable cache / diagnostics tables (api_response_cache, api_response_body, api_fetch_failure) are dumped schema-only (--exclude-table-data) to keep dumps small.
| Variable | Default | Description |
|---|---|---|
CCAS_BACKUP_DIR |
${XDG_DATA_HOME:-~/.local/share}/ccas/backups |
Output directory for .dump files |
CCAS_BACKUP_RETAIN |
6 | Number of most-recent dumps to keep (older are pruned) |
Run it once manually, then schedule weekly. Sunday 04:00, cron's local time; cron's own PATH is /usr/bin:/bin, so add the directory holding the pg_dump that command -v pg_dump finds, and set any CCAS_BACKUP_* variable on the crontab line too:
PATH=/opt/homebrew/bin:/usr/bin:/bin
0 4 * * 0 /path/to/ccas/scripts/backup-neon.sh >> ~/.local/share/ccas/backups/backup.log 2>&1A weekly cadence is one Neon compute wake per week — negligible against the 192 active-hr/mo free-tier budget. Restore a dump with pg_restore (use a version ≥ the pg_dump that wrote the file — a custom-format archive can't be read by an older pg_restore), as the role the app connects with. Restored as anyone else, the objects belong to that user, and the app fails on them with permission errors. Into a fresh local database:
createdb -O ccas <db>
pg_restore --no-owner --no-privileges --role=ccas -d <db> ccas-<stamp>.dump # errors about pg_stat_statements are harmless--role needs a superuser or a member of ccas. Into any other database, connect as the app's role instead and drop --role: pg_restore --no-owner --no-privileges -d <conn-as-app-role> ccas-<stamp>.dump.
src/main/scala/ccas/
api/ Chess.com API models and client
analysis/ Domain tables and business logic (apps)
server/ HTTP server, job runner, scheduler
utils/ Shared infrastructure (HTTP client, JSON, SQL, logging)
docker compose up fails to bind port 5432. A host-native PostgreSQL is already listening. Either move the container to another port with DB_PORT (which also points the app and the test suite at it), or stop that service, or use it directly — Compose exists here only to supply the databases, so a local instance serves them once it has the role and both databases:
CREATE ROLE ccas LOGIN PASSWORD 'ccas';
CREATE DATABASE ccas OWNER ccas;
CREATE DATABASE ccas_test OWNER ccas; -- OWNER matters: Postgres 15+ blocks non-owner writes to public
\c ccas
CREATE SCHEMA IF NOT EXISTS test AUTHORIZATION ccas;
CREATE EXTENSION IF NOT EXISTS btree_gist;
\c ccas_test
CREATE EXTENSION IF NOT EXISTS btree_gist;Boot fails with club_name needs the btree_gist extension. Every database CCAS runs against needs CREATE EXTENSION btree_gist; once, run by hand — Neon included. The server deliberately never installs extensions itself; the message names the ADR that says why.
A run hangs with no progress. Read the client's progress bar first — it prints API: active/currentMax, and active is only above zero while a fiber holds an HTTP slot. A hang showing API: 0/N therefore means nothing is in flight over HTTP, so the stuck fiber is in a blocking JDBC call; API: N/N stuck at N ≥ 1 points at an HTTP read instead. The JDBC case was the symptom of pgjdbc's socketTimeout defaulting to infinite (a silently-dropped connection parks in socket.read() forever); that is now bounded by DB_SOCKET_TIMEOUT_SECONDS / DB_CONNECT_TIMEOUT_SECONDS / DB_TCP_KEEP_ALIVE, so raise those before suspecting application code.
The server is slow to stop, or doesn't. A stop logs each step as it starts ([shutdown] Stopping the database pool) and as it ends ([shutdown] Stopped the database pool in 2 ms), between Shutting down and a Stopped in <n> ms total. The step that hangs is the one with a Stopping line and no Stopped line; most steps run one at a time, but the database pool and the HTTP client stop together, so their lines interleave. A stop that outlasts CcasServer.gracefulShutdownTimeout gives up: ZIO prints **** WARNING **** Timed out waiting for ZIO application to shut down and the process exits, sooner than ccas server down would SIGKILL it. A process still alive after Stopped in … is waiting on fibers left running in the background, which ZIO interrupts and waits for once CCAS's own cleanup is done. kill -INFO <pid> (kill -USR1 <pid> on Linux) makes ZIO print every fiber and the source line it waits at to the server's stderr, which for a detached server is its log.
A scheduled job fires late, or not at all. The scheduler polls every SCHEDULER_POLL_MINUTES of awake time: its timer stops while the machine sleeps, so on a laptop a due job waits until the machine has been awake that long. On a Mac, pmset -g log shows the sleeps, and caffeinate -i -w <server pid> keeps the machine from idle-sleeping while the server runs (closing a laptop's lid still sleeps it). A server suspended with Ctrl-Z runs nothing until it is resumed; ps -o stat= -p <pid> starts with T.
The first poll runs at boot, and fires every schedule that came due while the server was down: an interval schedule past its interval, a cron schedule set to catch up, and one set to skip whose boundary passed only shortly before boot. To hold one back, disable it before the restart: PUT /api/schedules/:id with {"enabled": false} while the server runs, or UPDATE job_schedule SET enabled = false WHERE id = … once it is down. That postpones the run rather than skipping it: once re-enabled, the schedule is still overdue and fires at the next poll. The CLI cannot disable a schedule, and the SCHEDULER_*_ENABLED settings apply only when a schedule is seeded.
Any build fails with Unable to locate a Java Runtime. Check java -version before suspecting anything else. This has happened via a stale SDKMAN candidate: an entry installed from Homebrew (21.0.12-brew) is a symlink into the Homebrew keg, so removing or upgrading that formula leaves the candidate dangling and every java invocation fails. sdk env in the repo root selects the pinned Temurin build; sdk uninstall java <dangling-candidate> clears the stale entry.
Phantom X is not a member of ccas compile errors, or tests failing with error while loading …tasty or relation … does not exist. IntelliJ's sbt shell and a terminal sbt share target/, and concurrent use corrupts the Zinc incremental-compilation state; IntelliJ's own compile server and test runs share target/ and the ccas_test database too. Run one at a time; sbt clean recovers. A failure that repeats with IntelliJ closed is real.
- Architecture reference -- what each part is and how the pieces connect
- Architecture decision records -- why things are the way they are, one decision per file
- Documentation standard -- where each kind of knowledge lives, and how much of it to write
- CLAUDE.md -- conventions and build commands for AI-assisted development