A small tool for one youth team coordinator: games from the federation's game list, attendance,
duties, and messages to copy into the team chat. Product direction, plans and decisions are in
docs/ (start with docs/vision.md).
The backend is Rust (crates/), the front end is Svelte (web/), the data is in PostgreSQL. See
docs/decisions/ for why.
Enter the pinned Nix development shell. With direnv, run direnv allow once:
the shell and the settings in .env then load when you enter this directory. Without direnv, run
nix develop.
Make your settings file:
cp .env.example .env
head -c 32 /dev/urandom | base64 # put the output in .env as the value of PLAYER_NAME_KEYThen install the front-end packages, start the local database, and apply the migrations:
just web-install
just db-up
just migrateThen:
just dev # run the server and the front end with live reload on http://localhost:5173
just serve # run the server on http://localhost:3000, with no login
just check # every check (as CI): workflow and script lint, API types, Rust fmt/clippy/tests,
# front-end check/lint/build, browser tests, sqlx query dataThe local database is team_v2 (.env.example, and CI); the cluster database is team (see the
cluster contract below). The database tests (#[sqlx::test]) create their own databases on the
server in DATABASE_URL.
After changing an SQL query, run just prepare and commit .sqlx/.
scripts/bench.sh times a clean build, small rebuilds, the core tests and the front-end check, to
compare development machines. Run it in the Nix shell, on the same commit on each machine, after
just web-install; no database is necessary. It writes benchmarks/<host>-<date>.txt: commit
that file.
Migrations are plain SQL files in migrations/, compiled into the binary
(decision 0004). team migrate applies the missing ones under a lock. They only go forward:
- never edit a migration that is applied anywhere; a fix is a new file;
- a breaking change goes in two releases (add the new, later remove the old);
- the app does not migrate; its readiness check fails while a migration is missing.
The pipeline (decision 0010) has four stages. sha-<commit> exists only for commits that passed everything:
| Stage | When | What |
|---|---|---|
| Check | every pull request and push to main |
the same recipes as just check |
| Build | push to main, and pull requests that change Dockerfile, .dockerignore, Cargo.lock, rust-toolchain.toml, web/package-lock.json, scripts/smoke-test.sh or .github/workflows/ci.yml |
builds both images, runs just smoke; on main pushes build-<commit> and build-<commit>-migration |
| Publish | push to main, after Check and Build passed |
names those images sha-<commit> and latest (migration first) |
| Release | tag v* |
names the sha-<commit> images of the tagged commit <tag> and <tag>-migration; nothing is built |
The cluster runs latest with pullPolicy: Always, so a pod restart deploys the newest image:
kubectl rollout restart deployment/team -n productivity- Merge into
mainand wait until the CI run onmainis green. - Restart the pod and check the app on the cluster.
- Tag that commit and push the tag:
The release run takes seconds. It fails with "No image for commit …" when
git tag -a v0.1.2 -m "v0.1.2: <what changed>" git push origin v0.1.2mainhas not passed for that commit.
Tag only commits on main. GitHub runs the workflow files of the tagged commit, so a tag on an
old commit can run an old workflow.
The deployment lives in gitops-lab/applications/productivity/team/ (Argo CD, Helmfile, Vault).
Never apply it by hand. The image keeps this contract:
| Item | Value |
|---|---|
| Ports and probes | 3000; GET /api/health/liveness, GET /api/health/readiness |
| Commands | runtime: team serve; migration (init container): team migrate (needs DATABASE_URL and PLAYER_NAME_KEY) |
| Security | user and group 1000, read-only root file system |
| Database | team on the server team-postgresql (namespace productivity): DATABASE_URL in Vault ends in /team. The nightly backup dumps this database. team_v2 is only the local name; never copy the local value to Vault (from 08:09 to 11:29 UTC on 2026-10-18 the app used an empty team_v2 that way) |
| Settings from Vault | DATABASE_URL, OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, SESSION_SECRET, PLAYER_NAME_KEY (32 random bytes in base64, the key of the encrypted player names; a copy is kept outside the platform, because without it the names in the database and in every backup are lost) |
| Other settings | ORIGIN, SESSION_TTL_SECONDS, DEV_AUTH_BYPASS and DEV_NOW (a start moment for the clock, for example 2026-10-12T10:00:00Z; both ignored by release builds), optional TEAM_ACCESS_ROLE (default team_coordinator), GAME_LIST_URL (default https://db.basketball.nl; the cluster uses the default), HOST, PORT, WEB_DIR |
From v0.3.2 the player names are only stored encrypted, and team migrate no longer encrypts
plain names: migration 0008 refuses a database that still has them, team migrate stops, and the
pod waits in Init. Backups made before v0.3.1 went live (2026-10-18 11:29 UTC, for example
team-20261018T102433Z.dump.age) have plain names; the nightly job keeps the newest 7 backups, so
they are gone after about a week.
To restore suc
5C8B
h a backup: follow the recovery steps in gitops-lab (the app scaled to 0), then,
before the app starts again, run the migration image of v0.3.1 once with the same settings. It
applies 0007 and encrypts the names with PLAYER_NAME_KEY; the next normal start applies 0008
and 0009. The labels let the pod through the database's NetworkPolicy. Not tested on the cluster.
kubectl run team-migrate-v031 -n productivity --rm -i --restart=Never \
--labels=app.kubernetes.io/name=team,app.kubernetes.io/instance=team \
--image=ghcr.io/quidome/team:v0.3.1-migration \
--overrides='{"spec":{"securityContext":{"runAsUser":1000,"runAsGroup":1000,"runAsNonRoot":true},
"containers":[{"name":"team-migrate-v031","image":"ghcr.io/quidome/team:v0.3.1-migration",
"envFrom":[{"secretRef":{"name":"team"}}]}]}}'A backup made during the update to v0.3.1 itself (about a minute) can hold a player with both an encrypted name and a newer plain name (a rename by the old pod); this path keeps the newer one.
The new app uses its own, empty database (decision 0006). On the cluster this was done on
2026-10-17, when the first migration of the new app clashed with a table of the old app
(players): the database team of the old app was dropped and made again, empty, with the same
name, so DATABASE_URL in Vault did not change. The new app's migrations then ran from the start.
kubectl exec -n productivity team-postgresql-0 -- \
psql -U team -d postgres -c 'DROP DATABASE team WITH (FORCE)' -c 'CREATE DATABASE team OWNER team'Pocket ID, client team-coordinator: the groups scope is allowed, and the coordinator is in the
group team_coordinator (decision 0007).