Skip to content

Commit 80cc753

Browse files
nanotaboadaclaude
andcommitted
docs(adr): update docs and ADRs for Alembic adoption (#2)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3477a9b commit 80cc753

File tree

6 files changed

+151
-45
lines changed

6 files changed

+151
-45
lines changed

.github/copilot-instructions.md

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ and in-memory caching.
1111
- **Language**: Python 3.13
1212
- **Framework**: FastAPI + Uvicorn
1313
- **ORM**: SQLAlchemy 2.0 (async) + aiosqlite
14-
- **Database**: SQLite
14+
- **Database**: SQLite (local/test), PostgreSQL-compatible
15+
- **Migrations**: Alembic (async, `render_as_batch=True`)
1516
- **Validation**: Pydantic
1617
- **Caching**: aiocache (in-memory, 10-minute TTL)
1718
- **Testing**: pytest + pytest-cov + httpx
@@ -22,14 +23,15 @@ and in-memory caching.
2223

2324
```text
2425
main.py — application entry point: FastAPI setup, router registration
26+
alembic.ini — Alembic configuration (sqlalchemy.url set dynamically)
27+
alembic/ — Alembic migration environment and version scripts
2528
routes/ — HTTP route definitions + dependency injection [HTTP layer]
2629
services/ — async business logic + cache management [business layer]
2730
schemas/ — SQLAlchemy ORM models (database schema) [data layer]
28-
databases/ — async SQLAlchemy session setup
31+
databases/ — async SQLAlchemy session setup + get_database_url()
2932
models/ — Pydantic models for request/response validation
30-
storage/ — SQLite database file (players-sqlite3.db, pre-seeded)
3133
scripts/ — shell scripts for Docker (entrypoint.sh, healthcheck.sh)
32-
tools/ — standalone seed scripts (run manually, not via Alembic)
34+
tools/ — legacy standalone seed scripts (superseded by Alembic migrations)
3335
tests/ — pytest integration tests
3436
```
3537

@@ -65,12 +67,13 @@ concerns only; business logic belongs in services. Never skip a layer.
6567
- **Logging**: `logging` module only; never `print()`
6668
- **Line length**: 88; complexity ≤ 10
6769
- **Import order**: stdlib → third-party → local
68-
- **Tests**: integration tests against the real pre-seeded SQLite DB via
69-
`TestClient` — no mocking. Naming pattern
70+
- **Tests**: integration tests against the real SQLite DB (seeded via
71+
Alembic migrations) via `TestClient` — no mocking. Naming pattern
7072
`test_request_{method}_{resource}_{context}_response_{outcome}`;
71-
docstrings single-line, concise; `tests/player_stub.py` for test data;
73+
docstrings single-line, concise; `tests/player_fake.py` for test data;
7274
`tests/conftest.py` provides a `function`-scoped `client` fixture for
73-
isolation; `tests/test_main.py` excluded from Black
75+
isolation; `tests/test_main.py` excluded from Black;
76+
`tests/test_migrations.py` covers Alembic downgrade paths
7477
- **Decisions**: justify every decision on its own technical merits; never use
7578
"another project does it this way" as a reason — that explains nothing and
7679
may mean replicating a mistake
@@ -87,6 +90,9 @@ uv venv
8790
source .venv/bin/activate # Linux/macOS; use .venv\Scripts\activate on Windows
8891
uv pip install --group dev
8992

93+
# Apply migrations (required once before first run, and after down -v)
94+
uv run alembic upgrade head
95+
9096
# Run application
9197
uv run uvicorn main:app --reload --port 9000 # http://localhost:9000/docs
9298

@@ -98,6 +104,11 @@ uv run pytest --cov=./ --cov-report=term # with coverage (target >=80%
98104
uv run flake8 .
99105
uv run black --check .
100106

107+
# Migration workflow
108+
uv run alembic upgrade head # apply all pending migrations
109+
uv run alembic downgrade -1 # roll back last migration
110+
uv run alembic revision --autogenerate -m "desc" # generate migration from schema
111+
101112
# Docker
102113
docker compose up
103114
docker compose down -v
@@ -149,9 +160,10 @@ Never suggest a release tag with a coach name not on this list.
149160

150161
### Ask before changing
151162

152-
- Database schema (`schemas/player_schema.py` — no Alembic; changes require
153-
manually updating `storage/players-sqlite3.db` and the seed scripts in
154-
`tools/`)
163+
- Database schema (`schemas/player_schema.py`) and Alembic migrations
164+
(`alembic/versions/`) — schema changes require a new migration file;
165+
seed data changes require updating the relevant migration and any test
166+
fixtures that reference specific UUIDs
155167
- `models/player_model.py` design decisions — especially splitting or merging
156168
request/response models; discuss the rationale before restructuring
157169
- Dependencies (`pyproject.toml` with PEP 735 dependency groups)
@@ -164,8 +176,8 @@ Never suggest a release tag with a coach name not on this list.
164176
### Never modify
165177

166178
- `.env` files (secrets)
167-
- `storage/players-sqlite3.db` directly — schema changes go through
168-
`schemas/player_schema.py` and `tools/` seed scripts
179+
- `alembic/versions/` migration files once merged to `master` — migrations
180+
are append-only; fix forward with a new migration, never edit history
169181
- Production configurations
170182

171183
### Creating Issues
@@ -194,10 +206,11 @@ shape is new → add async service method in `services/` with error handling and
194206
rollback → add route in `routes/` with `Depends(generate_async_session)`
195207
add tests following the naming pattern → run pre-commit checks.
196208

197-
**Modify schema**: Update `schemas/player_schema.py` → manually update
198-
`storage/players-sqlite3.db` (preserve all 26 seeded players) → update
199-
`models/player_model.py` if the API shape changes → update services and tests
200-
→ run `pytest`.
209+
**Modify schema**: Update `schemas/player_schema.py` → run
210+
`uv run alembic revision --autogenerate -m "description"` to generate a
211+
migration → review and adjust the generated file in `alembic/versions/`
212+
run `uv run alembic upgrade head` → update `models/player_model.py` if the
213+
API shape changes → update services and tests → run `pytest`.
201214

202215
**After completing work**: Propose a branch name and commit message for user
203216
approval. Do not create a branch, commit, or push until the user explicitly

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Proof of Concept for a RESTful Web Service built with **FastAPI** and **Python 3
2121
- 📚 **Interactive Documentation** - Auto-generated Swagger UI with VS Code and JetBrains REST Client support
2222
-**Performance Caching** - In-memory caching with aiocache and async SQLite operations
2323
-**Input Validation** - Pydantic models enforce request/response schemas with automatic error responses
24-
- 🐳 **Containerized Deployment** - Production-ready Docker setup with pre-seeded database
24+
- 🐳 **Containerized Deployment** - Production-ready Docker setup with migration-based database initialization
2525
- 🔄 **Automated Pipeline** - Continuous integration with Black, Flake8, and automated testing
2626

2727
## Tech Stack
@@ -171,6 +171,10 @@ uv pip install --group dev
171171
### Run
172172

173173
```bash
174+
# Apply database migrations (required once before the first run, and after
175+
# docker compose down -v)
176+
uv run alembic upgrade head
177+
174178
uv run uvicorn main:app --reload --port 9000
175179
```
176180

@@ -190,7 +194,7 @@ Once the application is running, you can access:
190194
docker compose up
191195
```
192196

193-
> 💡 **Note:** On first run, the container copies a pre-seeded SQLite database into a persistent volume. On subsequent runs, that volume is reused and the data is preserved.
197+
> 💡 **Note:** On first run, the entrypoint applies Alembic migrations (`alembic upgrade head`), which creates the database and seeds all 26 players. On subsequent runs, migrations are a no-op and the volume data is preserved.
194198
195199
### Stop
196200

@@ -200,7 +204,7 @@ docker compose down
200204

201205
### Reset Database
202206

203-
To remove the volume and reinitialize the database from the built-in seed file:
207+
To remove the volume and re-apply migrations from scratch on next start:
204208

205209
```bash
206210
docker compose down -v
@@ -224,8 +228,14 @@ docker pull ghcr.io/nanotaboada/python-samples-fastapi-restful:latest
224228
## Environment Variables
225229

226230
```bash
227-
# Database storage path (default: ./storage/players-sqlite3.db)
228-
STORAGE_PATH=./storage/players-sqlite3.db
231+
# Full async database URL (SQLite default, PostgreSQL compatible)
232+
# SQLite (local/test):
233+
DATABASE_URL=sqlite+aiosqlite:///./players-sqlite3.db
234+
# PostgreSQL (Docker/production):
235+
DATABASE_URL=postgresql+asyncpg://postgres:password@localhost:5432/playersdb
236+
237+
# Legacy: SQLite file path — used only when DATABASE_URL is not set
238+
STORAGE_PATH=./players-sqlite3.db
229239

230240
# Python output buffering: set to 1 for real-time logs in Docker
231241
PYTHONUNBUFFERED=1

docs/adr/0002-no-alembic-manual-seed-scripts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Date: 2026-03-21
44

55
## Status
66

7-
Accepted. Migration to Alembic is under consideration — tracked in
8-
issue #2.
7+
Superseded by [ADR-0010](0010-alembic-migration-based-schema-management.md).
8+
Alembic was adopted in issue #2.
99

1010
## Context
1111

docs/adr/0009-docker-and-compose-strategy.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,13 @@ locally.
4949
(for the health check); copies the pre-built wheels from the builder;
5050
installs them with `--no-index --find-links` (no network access, no
5151
build tools required); removes the wheelhouse after installation.
52-
- **Entrypoint script**: on first start, copies the pre-seeded database
53-
from the image's read-only `hold/` directory to the writable named
54-
volume at `/storage/`, then runs both seed scripts to ensure the schema
55-
and data are up to date. On subsequent starts, the volume file is
56-
preserved and seed scripts run again (they are idempotent).
52+
- **Entrypoint script**: runs `alembic upgrade head` before launching
53+
Uvicorn. Alembic creates the database and seeds all 26 players on first
54+
start; on subsequent starts it is a no-op (already at head). No
55+
pre-seeded file is bundled in the image.
5756
- **Compose (`compose.yaml`)**: defines a single service with port
5857
mapping (`9000`), a named volume (`storage`), and environment variables
59-
(`STORAGE_PATH`, `PYTHONUNBUFFERED=1`). Health checks are declared in
58+
(`DATABASE_URL`, `PYTHONUNBUFFERED=1`). Health checks are declared in
6059
the Dockerfile (`GET /health`); Compose relies on that declaration.
6160
- A non-root `fastapi` user is created in the runtime stage following the
6261
principle of least privilege.
@@ -81,10 +80,11 @@ locally.
8180
- The wheelhouse is an intermediate artifact: if a wheel cannot be
8281
pre-built (e.g. binary-only distributions without a source distribution),
8382
the builder stage will fail.
84-
- The seed scripts run on every container start. They are idempotent but
85-
add latency to startup and must remain so as the project evolves.
86-
- The SQLite database file is versioned and bundled, meaning schema changes
87-
require a Docker image rebuild.
83+
- `alembic upgrade head` runs on every container start. It is a no-op
84+
when no migrations are pending but adds a small DB round-trip to startup
85+
time.
86+
- Schema changes now require an Alembic migration rather than a Docker
87+
image rebuild; see ADR-0010.
8888

8989
**When to revisit:**
9090

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# ADR-0010: Alembic — Migration-Based Schema Management
2+
3+
Date: 2026-04-09
4+
5+
## Status
6+
7+
Accepted. Supersedes [ADR-0002](0002-no-alembic-manual-seed-scripts.md).
8+
9+
## Context
10+
11+
ADR-0002 decided against Alembic in favour of standalone seed scripts in
12+
`tools/`, citing low schema churn and the simplicity of a single committed
13+
SQLite file. Issue #2 was opened to revisit this decision when the project
14+
matured.
15+
16+
Several factors made the status quo unsustainable:
17+
18+
- Committing a binary `.db` file to the repository means every schema
19+
change produces an opaque, unreviewable diff. Reviewers cannot tell what
20+
data changed or why.
21+
- The Docker entrypoint copied a pre-seeded file from the image into the
22+
container volume — coupling the data to the image build and requiring a
23+
full rebuild whenever the dataset changed.
24+
- The project targets future PostgreSQL support (issue #542), which a
25+
SQLite-only committed file cannot accommodate.
26+
- As a cross-language educational reference, the project should demonstrate
27+
production-grade database lifecycle practices, and Alembic is the standard
28+
tool for SQLAlchemy projects.
29+
30+
## Alternatives Considered
31+
32+
- **Keep the committed `.db` file + seed scripts**: Already rejected in
33+
issue #2; the coupling to the binary file blocks PostgreSQL support and
34+
produces unreadable diffs.
35+
- **Prisma Client Python**: Requires Node.js alongside Python, uses its own
36+
schema DSL instead of SQLAlchemy models, and has less mature Python
37+
support. Rejected because it would introduce a second source of truth for
38+
the schema.
39+
40+
## Decision
41+
42+
We will use Alembic as the schema and seed data migration tool.
43+
44+
Three migration files replace the committed database and the standalone
45+
seed scripts:
46+
47+
- `001_create_players_table.py` — autogenerated from the SQLAlchemy `Player`
48+
schema; creates the `players` table.
49+
- `002_seed_starting11.py` — inserts the 11 Starting XI players using
50+
deterministic UUID v5 values.
51+
- `003_seed_substitutes.py` — inserts the 15 Substitute players using
52+
deterministic UUID v5 values.
53+
54+
`alembic/env.py` is configured for async SQLAlchemy (`asyncio.run` via
55+
thread executor), reads `DATABASE_URL` from the environment (SQLite default,
56+
PostgreSQL compatible), and sets `render_as_batch=True` for SQLite `ALTER
57+
TABLE` compatibility. Each migration's `downgrade()` function deletes only
58+
the rows it inserted (by UUID), so migrations are independently reversible.
59+
60+
`alembic upgrade head` is run by `entrypoint.sh` before Uvicorn starts
61+
(Docker). For local development, it is run once manually:
62+
`uv run alembic upgrade head`.
63+
64+
## Consequences
65+
66+
**Positive:**
67+
- Schema and seed data changes are versioned, reviewable, and reversible.
68+
- The committed `.db` file is removed from the repository; `*.db` is
69+
added to `.gitignore`.
70+
- `DATABASE_URL` environment variable enables the same codebase to target
71+
both SQLite (local/test) and PostgreSQL (Docker/production) without code
72+
changes — a prerequisite for issue #542.
73+
- UUID v5 seed values are deterministic across environments, so test
74+
fixtures that reference player IDs remain stable.
75+
76+
**Negative:**
77+
- Local development requires `uv run alembic upgrade head` before the first
78+
server start (or after a `docker compose down -v`).
79+
- Alembic's offline mode (`alembic --sql`) is not exercised by integration
80+
tests; `alembic/env.py` offline path is excluded from coverage.
81+
- `render_as_batch=True` means SQLite schema changes use a copy-transform-
82+
replace strategy rather than `ALTER TABLE` — this is invisible to callers
83+
but must be understood when debugging migration failures.

tests/test_migrations.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,48 +18,48 @@
1818
from alembic import command
1919
from alembic.config import Config
2020

21-
_DB_PATH = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
22-
_alembic_config = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini"))
21+
DB_PATH = os.getenv("STORAGE_PATH", "./players-sqlite3.db")
22+
ALEMBIC_CONFIG = Config(str(Path(__file__).resolve().parent.parent / "alembic.ini"))
2323

2424

2525
def test_migration_downgrade_003_removes_substitutes_only():
2626
"""Downgrade 003→002 removes the 15 seeded substitutes, leaves Starting XI."""
27-
command.downgrade(_alembic_config, "-1")
27+
command.downgrade(ALEMBIC_CONFIG, "-1")
2828

29-
conn = sqlite3.connect(_DB_PATH)
29+
conn = sqlite3.connect(DB_PATH)
3030
total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0]
3131
subs = conn.execute("SELECT COUNT(*) FROM players WHERE starting11=0").fetchone()[0]
3232
conn.close()
3333

34-
command.upgrade(_alembic_config, "head")
34+
command.upgrade(ALEMBIC_CONFIG, "head")
3535

3636
assert total == 11
3737
assert subs == 0
3838

3939

4040
def test_migration_downgrade_002_removes_starting11_only():
4141
"""Downgrade 002→001 removes the 11 seeded Starting XI, leaves table empty."""
42-
command.downgrade(_alembic_config, "-2")
42+
command.downgrade(ALEMBIC_CONFIG, "-2")
4343

44-
conn = sqlite3.connect(_DB_PATH)
44+
conn = sqlite3.connect(DB_PATH)
4545
total = conn.execute("SELECT COUNT(*) FROM players").fetchone()[0]
4646
conn.close()
4747

48-
command.upgrade(_alembic_config, "head")
48+
command.upgrade(ALEMBIC_CONFIG, "head")
4949

5050
assert total == 0
5151

5252

5353
def test_migration_downgrade_001_drops_players_table():
5454
"""Downgrade 001→base drops the players table entirely."""
55-
command.downgrade(_alembic_config, "base")
55+
command.downgrade(ALEMBIC_CONFIG, "base")
5656

57-
conn = sqlite3.connect(_DB_PATH)
57+
conn = sqlite3.connect(DB_PATH)
5858
table = conn.execute(
5959
"SELECT name FROM sqlite_master WHERE type='table' AND name='players'"
6060
).fetchone()
6161
conn.close()
6262

63-
command.upgrade(_alembic_config, "head")
63+
command.upgrade(ALEMBIC_CONFIG, "head")
6464

6565
assert table is None

0 commit comments

Comments
 (0)