Development
Tests and coverage
The suite runs against a real PostgreSQL instance, started as a throwaway container by testcontainers, so a reachable Docker daemon is required. One container is shared by the whole session and the schema is recreated around each test. To reuse an already-running Postgres instead (and skip the container), point TEST_DATABASE_URL at it — be aware the suite drops and recreates its tables there.
A test never reaches the app's own database (DATABASE_URL, else the dev stack's Postgres on port 5452): its connections are refused, as in CI, unless it asks for the per-test database with the datasets_db fixture (apps/server/tests/conftest.py). A service that opens its own sessions imports get_scoped_db from server.core.database, which a test enforces.
# Run tests with coverage (HTML report in htmlcov/index.html)
make test
# Run tests for CI (XML report, enforces 70% minimum)
make test-ci
# Run the git hooks (includes the tests on push)
uv run prek run --all-filesCI runs the suite with coverage on every push, printed in the job log; the job fails below the threshold, 70%.
Git hooks
Hooks are managed by prek and declared in a single prek.toml at the repo root — there is no .pre-commit-config.yaml. The shims are installed by make setup (uv run prek install).
Two things keep the hooks honest with CI: the linters run through uv run / bun run, so they use the versions already pinned in pyproject.toml and apps/next/package.json; and everything prek ships natively (whitespace, EOF, YAML/JSON/TOML syntax, private keys, …) uses repo = "builtin" — offline, no environment to build, no rev to bump.
Three security hooks run too: gitleaks on the staged changes (the version CI pins, reading .gitleaks.toml), and zizmor and actionlint on the GitHub workflows.
Every remote hook is pinned to a commit, with its tag in a # frozen: comment — a tag can be moved to other code, a commit can't — and hadolint's image by digest. uv run prek update keeps them that way: it moves each one to its newest tag at least a week old ([update] in prek.toml). It leaves the hadolint image alone: bump its digest with the rev.
uv run prek run --all-files # every hook, every file
uv run prek run # staged files only
uv run prek run ruff # a single hook
uv run prek run --stage pre-push # includes the test suite
uv run prek update # move the remote hooks to newer pinned commits
uv run prek util list-builtins # what "builtin" provideshadolint-docker needs a running Docker daemon, and the pytest hook (pre-push only) needs one too — the suite starts a throwaway Postgres.
API schema and client
apps/next/openapi.json is the committed contract between the two apps. The TypeScript client is generated from it, never committed: apps/next/api/gen/ is gitignored and rebuilt before every bun run dev, build, typecheck and test (pre* scripts), like a dependency. The hand-written wrappers the app actually imports (api/sdk.ts, api/types.ts) sit one level up.
# After changing an endpoint or schema: dump the contract (no server needed),
# regenerate the client, then commit openapi.json
make api-client
# What CI runs (`API schema drift` job): fails when openapi.json is stale
make api-check
# Optional, while working on the API: regenerate the client whenever the
# running FastAPI server's schema changes (OPENAPI_INPUT overrides the URL)
cd apps/next && bun run api:watchA few details keep generation deterministic:
- The dump preserves the app's own key order rather than sorting it — the generator emits operations in schema order, so sorting would churn the diff.
- The generator runs from an isolated install in
apps/next/openapi-codegen: it needs the TypeScript 5 compiler API, which the app's typescript@7 (native tsgo) no longer ships. openapi-ts.config.tssetsbaseUrl: falseon the client plugin, so no base URL is baked intoclient.gen.tsfrom whatever input was used;api/sdk.tssupplies the real one at runtime fromNEXT_PUBLIC_API_BASE_URL.
Pull requests
.github/workflows/pr-description.yml keeps the description of every pull request opened from a branch of this repository up to date: on each push, Claude Code reads the commits and the diff and rewrites a managed block at the top of the description, between <!-- pr-description:start --> and <!-- pr-description:end -->. Write your own notes outside that block: they are kept. Bot and fork pull requests are skipped, and the workflow sits outside ci-cd.yml, so a failure never blocks a merge.
It runs the shared pr-description workflow on the owner's Claude subscription, through the CLAUDE_CODE_OAUTH_TOKEN repository secret (claude setup-token). Without the secret the job only prints a warning.
Dependencies
make upgrade rewrites both lockfiles within the declared constraints, skipping releases younger than a week (exclude-newer in pyproject.toml, minimumReleaseAge in apps/next/bunfig.toml — the same cooldown as dependabot); review the diff, then make lint test. Each cap or skipped release in pyproject.toml says why next to it: huggingface-hub stays below 2 until the Hub code is checked against 2.x, and PyMuPDF skips 2.0, whose import costs seconds and hundreds of megabytes in every process.