Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions examples/v3_reference_seller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,79 @@ exist; the reference seller wires the simpler defaults:
in `src/app.py` for production durability. Both classes ship in
the SDK; this seller's `app.py` uses the in-memory variants for
fast iteration.
- **Alembic migrations** — `Base.metadata.create_all` runs at boot
(idempotent on table existence — it does NOT detect column
renames or type changes on existing tables). Adopters who
prototyped against earlier branches and pulled new column
changes should drop and recreate the dev database; production
sellers wire Alembic and version their schema changes.
- **Admin CRUD API** — separate Starlette app for tenant / agent
CRUD. Patterns to come; for now use `seed.py` and direct SQL.

## Migrations

The app boots with `Base.metadata.create_all` — idempotent on table
existence, but **blind to column renames, type changes, and new columns
on existing tables**. For local fast-iteration this is fine. Once you
have production data, use Alembic to evolve the schema safely.

> ⚠️ **`create_all` is unsafe for schema evolution once production data
> exists.** Column renames and type changes applied after first boot
> will not be detected and will silently leave the schema stale.

### Install Alembic

```bash
pip install alembic
# or, if using a requirements file:
echo "alembic" >> requirements.txt && pip install -r requirements.txt
```

### Apply migrations

```bash
cd examples/v3_reference_seller

# Apply all pending migrations (run after every git pull that touches models).
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp python -m migrate

# Equivalent direct alembic invocation:
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic upgrade head
```

### Generate a new migration after changing models

```bash
cd examples/v3_reference_seller
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp \
alembic revision --autogenerate -m "describe your change"
```

Alembic compares the live database to `Base.metadata` and emits a
migration file under `alembic/versions/`. **Always review the generated
file before committing** — autogenerate misses some constructs (partial
index predicates, custom CHECK constraints, server defaults).

> ⚙️ **Adding a new model file?** Import it in `alembic/env.py` alongside
> `src.models` and `src.audit`, or autogenerate will silently omit its
> tables from the migration.

### Roll back

```bash
# Roll back one step.
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic downgrade -1

# Roll back to before any migrations (drops all tables defined in this schema).
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic downgrade base
```

> ⚠️ **`downgrade` in production is irreversible without a data backup.**
> Take a snapshot before running downgrade against any database that
> holds real data.

### Run migration integration tests

```bash
# Uses a throw-away database (adcp_test) so the migration run starts clean.
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp_test \
pytest examples/v3_reference_seller/tests/test_migrations.py -m integration -v
```

## Customization

Adopters typically change:
Expand Down
50 changes: 50 additions & 0 deletions examples/v3_reference_seller/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Alembic configuration for the v3 reference seller example.
#
# Run from the examples/v3_reference_seller/ directory:
#
# DATABASE_URL=postgresql+asyncpg://... alembic upgrade head
#
# When embedding this example inside a larger repo, update
# script_location to an absolute path (e.g. /path/to/alembic) so
# Alembic can find the migration scripts regardless of cwd.

[alembic]
script_location = alembic

# DATABASE_URL is read from the environment in env.py — leave this
# blank so it is never accidentally hardcoded in version control.
sqlalchemy.url =

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
104 changes: 104 additions & 0 deletions examples/v3_reference_seller/alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Alembic environment for the v3 reference seller.

Uses SQLAlchemy's async engine (asyncpg) via the standard Alembic
async pattern. Run from the examples/v3_reference_seller/ directory:

DATABASE_URL=postgresql+asyncpg://... alembic upgrade head

For autogenerate to capture every table, both src.models and src.audit
must be imported before target_metadata is read. Missing either import
silently omits that module's tables from the generated migration.
"""

from __future__ import annotations

import asyncio
import os
import sys
from logging.config import fileConfig
from pathlib import Path

from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine

# ---------------------------------------------------------------------------
# Path wiring — make ``src.*`` importable when env.py is executed from the
# examples/v3_reference_seller/ directory by the ``alembic`` CLI.
# ---------------------------------------------------------------------------
_HERE = Path(__file__).resolve().parent.parent # examples/v3_reference_seller/
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))

# Import all ORM modules so their tables appear in Base.metadata.
# Adding a new model file? Import it here or autogenerate will miss it.
import src.audit # noqa: E402, F401 — registers AuditEventRow on Base.metadata
from src.models import Base # noqa: E402

target_metadata = Base.metadata

# ---------------------------------------------------------------------------
# Alembic config
# ---------------------------------------------------------------------------
config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)

# DATABASE_URL comes from the environment; never hardcode it here.
try:
_db_url: str = os.environ["DATABASE_URL"]
except KeyError:
raise RuntimeError(
"DATABASE_URL environment variable is not set. "
"Example: DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp alembic upgrade head"
) from None
config.set_main_option("sqlalchemy.url", _db_url)


# ---------------------------------------------------------------------------
# Migration helpers
# ---------------------------------------------------------------------------

def run_migrations_offline() -> None:
"""Emit SQL to stdout rather than connecting to the DB.

Useful for generating a migration script to review or apply manually.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()


def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()


async def run_migrations_online() -> None:
"""Create an async engine and run migrations inside a sync wrapper.

Alembic's migration functions are synchronous; ``run_sync`` bridges
the gap so we can use an asyncpg engine end-to-end.
"""
connectable = create_async_engine(_db_url, echo=False)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()


if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
28 changes: 28 additions & 0 deletions examples/v3_reference_seller/alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from __future__ import annotations

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Loading
Loading