Skip to content

Commit 3035c25

Browse files
bokelleyclaude
andauthored
docs(examples): Alembic migration scaffold for v3 reference seller (#390)
* docs(examples): Alembic migration scaffold for v3 reference seller Refs #382. Adds production migration tooling while keeping create_all as the default fast-iteration boot path (per DX review). https://claude.ai/code/session_01SnEYzkwXDnJEFpj7zNVSY6 * fix(examples): address pre-PR review blockers in Alembic scaffold - env.py: narrow _db_url to str via os.environ[key] (no mypy Any) - migrate.py: redact DB password before printing URL - test_migrations.py: drop psycopg2 assumption; use sqlalchemy async engine for all schema inspection (asyncpg already installed) - test_migrations.py: ensure downgrade test starts from head state https://claude.ai/code/session_01SnEYzkwXDnJEFpj7zNVSY6 * fix(examples): add 0002 migration covering broadening-cycle additions PR #408 merged three schema additions that were not covered by the 0001 initial migration: `media_buys.invoice_recipient` (JSON, nullable), the `creatives` table (idempotency-keyed on tenant_id + creative_id), and the `performance_feedback` table (FK'd to media_buys.id). Without this migration, `alembic upgrade head` against a database created after #408 would produce drift that `alembic check` flags in CI and on every production deploy. Adds `0002_broadening_cycle.py` (revises 0001) covering all three additions, and updates the integration test to expect all eight tables. Refs #382 https://claude.ai/code/session_0121TgqHyzzjyZnQiGGmjrRu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8664ea3 commit 3035c25

8 files changed

Lines changed: 688 additions & 6 deletions

File tree

examples/v3_reference_seller/README.md

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,79 @@ exist; the reference seller wires the simpler defaults:
147147
in `src/app.py` for production durability. Both classes ship in
148148
the SDK; this seller's `app.py` uses the in-memory variants for
149149
fast iteration.
150-
- **Alembic migrations**`Base.metadata.create_all` runs at boot
151-
(idempotent on table existence — it does NOT detect column
152-
renames or type changes on existing tables). Adopters who
153-
prototyped against earlier branches and pulled new column
154-
changes should drop and recreate the dev database; production
155-
sellers wire Alembic and version their schema changes.
156150
- **Admin CRUD API** — separate Starlette app for tenant / agent
157151
CRUD. Patterns to come; for now use `seed.py` and direct SQL.
158152

153+
## Migrations
154+
155+
The app boots with `Base.metadata.create_all` — idempotent on table
156+
existence, but **blind to column renames, type changes, and new columns
157+
on existing tables**. For local fast-iteration this is fine. Once you
158+
have production data, use Alembic to evolve the schema safely.
159+
160+
> ⚠️ **`create_all` is unsafe for schema evolution once production data
161+
> exists.** Column renames and type changes applied after first boot
162+
> will not be detected and will silently leave the schema stale.
163+
164+
### Install Alembic
165+
166+
```bash
167+
pip install alembic
168+
# or, if using a requirements file:
169+
echo "alembic" >> requirements.txt && pip install -r requirements.txt
170+
```
171+
172+
### Apply migrations
173+
174+
```bash
175+
cd examples/v3_reference_seller
176+
177+
# Apply all pending migrations (run after every git pull that touches models).
178+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp python -m migrate
179+
180+
# Equivalent direct alembic invocation:
181+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic upgrade head
182+
```
183+
184+
### Generate a new migration after changing models
185+
186+
```bash
187+
cd examples/v3_reference_seller
188+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp \
189+
alembic revision --autogenerate -m "describe your change"
190+
```
191+
192+
Alembic compares the live database to `Base.metadata` and emits a
193+
migration file under `alembic/versions/`. **Always review the generated
194+
file before committing** — autogenerate misses some constructs (partial
195+
index predicates, custom CHECK constraints, server defaults).
196+
197+
> ⚙️ **Adding a new model file?** Import it in `alembic/env.py` alongside
198+
> `src.models` and `src.audit`, or autogenerate will silently omit its
199+
> tables from the migration.
200+
201+
### Roll back
202+
203+
```bash
204+
# Roll back one step.
205+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic downgrade -1
206+
207+
# Roll back to before any migrations (drops all tables defined in this schema).
208+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic downgrade base
209+
```
210+
211+
> ⚠️ **`downgrade` in production is irreversible without a data backup.**
212+
> Take a snapshot before running downgrade against any database that
213+
> holds real data.
214+
215+
### Run migration integration tests
216+
217+
```bash
218+
# Uses a throw-away database (adcp_test) so the migration run starts clean.
219+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp_test \
220+
pytest examples/v3_reference_seller/tests/test_migrations.py -m integration -v
221+
```
222+
159223
## Customization
160224

161225
Adopters typically change:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Alembic configuration for the v3 reference seller example.
2+
#
3+
# Run from the examples/v3_reference_seller/ directory:
4+
#
5+
# DATABASE_URL=postgresql+asyncpg://... alembic upgrade head
6+
#
7+
# When embedding this example inside a larger repo, update
8+
# script_location to an absolute path (e.g. /path/to/alembic) so
9+
# Alembic can find the migration scripts regardless of cwd.
10+
11+
[alembic]
12+
script_location = alembic
13+
14+
# DATABASE_URL is read from the environment in env.py — leave this
15+
# blank so it is never accidentally hardcoded in version control.
16+
sqlalchemy.url =
17+
18+
[loggers]
19+
keys = root,sqlalchemy,alembic
20+
21+
[handlers]
22+
keys = console
23+
24+
[formatters]
25+
keys = generic
26+
27+
[logger_root]
28+
level = WARN
29+
handlers = console
30+
qualname =
31+
32+
[logger_sqlalchemy]
33+
level = WARN
34+
handlers =
35+
qualname = sqlalchemy.engine
36+
37+
[logger_alembic]
38+
level = INFO
39+
handlers =
40+
qualname = alembic
41+
42+
[handler_console]
43+
class = StreamHandler
44+
args = (sys.stderr,)
45+
level = NOTSET
46+
formatter = generic
47+
48+
[formatter_generic]
49+
format = %(levelname)-5.5s [%(name)s] %(message)s
50+
datefmt = %H:%M:%S
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Alembic environment for the v3 reference seller.
2+
3+
Uses SQLAlchemy's async engine (asyncpg) via the standard Alembic
4+
async pattern. Run from the examples/v3_reference_seller/ directory:
5+
6+
DATABASE_URL=postgresql+asyncpg://... alembic upgrade head
7+
8+
For autogenerate to capture every table, both src.models and src.audit
9+
must be imported before target_metadata is read. Missing either import
10+
silently omits that module's tables from the generated migration.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import asyncio
16+
import os
17+
import sys
18+
from logging.config import fileConfig
19+
from pathlib import Path
20+
21+
from alembic import context
22+
from sqlalchemy.ext.asyncio import create_async_engine
23+
24+
# ---------------------------------------------------------------------------
25+
# Path wiring — make ``src.*`` importable when env.py is executed from the
26+
# examples/v3_reference_seller/ directory by the ``alembic`` CLI.
27+
# ---------------------------------------------------------------------------
28+
_HERE = Path(__file__).resolve().parent.parent # examples/v3_reference_seller/
29+
if str(_HERE) not in sys.path:
30+
sys.path.insert(0, str(_HERE))
31+
32+
# Import all ORM modules so their tables appear in Base.metadata.
33+
# Adding a new model file? Import it here or autogenerate will miss it.
34+
import src.audit # noqa: E402, F401 — registers AuditEventRow on Base.metadata
35+
from src.models import Base # noqa: E402
36+
37+
target_metadata = Base.metadata
38+
39+
# ---------------------------------------------------------------------------
40+
# Alembic config
41+
# ---------------------------------------------------------------------------
42+
config = context.config
43+
44+
if config.config_file_name is not None:
45+
fileConfig(config.config_file_name)
46+
47+
# DATABASE_URL comes from the environment; never hardcode it here.
48+
try:
49+
_db_url: str = os.environ["DATABASE_URL"]
50+
except KeyError:
51+
raise RuntimeError(
52+
"DATABASE_URL environment variable is not set. "
53+
"Example: DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic upgrade head"
54+
) from None
55+
config.set_main_option("sqlalchemy.url", _db_url)
56+
57+
58+
# ---------------------------------------------------------------------------
59+
# Migration helpers
60+
# ---------------------------------------------------------------------------
61+
62+
def run_migrations_offline() -> None:
63+
"""Emit SQL to stdout rather than connecting to the DB.
64+
65+
Useful for generating a migration script to review or apply manually.
66+
"""
67+
url = config.get_main_option("sqlalchemy.url")
68+
context.configure(
69+
url=url,
70+
target_metadata=target_metadata,
71+
literal_binds=True,
72+
dialect_opts={"paramstyle": "named"},
73+
compare_type=True,
74+
)
75+
with context.begin_transaction():
76+
context.run_migrations()
77+
78+
79+
def do_run_migrations(connection) -> None:
80+
context.configure(
81+
connection=connection,
82+
target_metadata=target_metadata,
83+
compare_type=True,
84+
)
85+
with context.begin_transaction():
86+
context.run_migrations()
87+
88+
89+
async def run_migrations_online() -> None:
90+
"""Create an async engine and run migrations inside a sync wrapper.
91+
92+
Alembic's migration functions are synchronous; ``run_sync`` bridges
93+
the gap so we can use an asyncpg engine end-to-end.
94+
"""
95+
connectable = create_async_engine(_db_url, echo=False)
96+
async with connectable.connect() as connection:
97+
await connection.run_sync(do_run_migrations)
98+
await connectable.dispose()
99+
100+
101+
if context.is_offline_mode():
102+
run_migrations_offline()
103+
else:
104+
asyncio.run(run_migrations_online())
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""${message}
2+
3+
Revision ID: ${up_revision}
4+
Revises: ${down_revision | comma,n}
5+
Create Date: ${create_date}
6+
7+
"""
8+
from __future__ import annotations
9+
10+
from typing import Sequence, Union
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
${imports if imports else ""}
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = ${repr(up_revision)}
18+
down_revision: Union[str, None] = ${repr(down_revision)}
19+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
20+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
21+
22+
23+
def upgrade() -> None:
24+
${upgrades if upgrades else "pass"}
25+
26+
27+
def downgrade() -> None:
28+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)