MAESTRO: Set up Alembic with initial migration for all 8 ORM models

This commit is contained in:
John Lightner 2026-04-07 01:52:03 -05:00
parent 7ef116e2f9
commit 0ec75ab617
6 changed files with 403 additions and 4 deletions

View file

@ -23,7 +23,8 @@ Set up the PromptLooper repository, Docker infrastructure, and basic project ske
- [x] Create backend/models.py with all SQLAlchemy ORM models from the spec's Data Model section: User, Project, Experiment, Run, StageResult, Score, ResponseCache, and WebhookConfig. Include all fields, types, relationships, and indexes. Use UUID primary keys and JSONB for flexible fields.
> Created all 8 ORM models with UUID PKs, JSON columns (using sqlalchemy.JSON for SQLite compatibility — maps to JSONB on PostgreSQL), enum types (ExperimentStatus, RunStatus), full relationship definitions with cascade deletes, and indexes on foreign keys and commonly filtered columns. Score.metadata mapped as `scorer_metadata` Python attribute (column name stays "metadata") to avoid SQLAlchemy reserved name conflict. 16 tests in tests/test_models.py all passing.
- [ ] Set up Alembic: create alembic.ini and alembic/env.py configured to read DATABASE_URL from the config. Generate and apply the initial migration from the models.
- [x] Set up Alembic: create alembic.ini and alembic/env.py configured to read DATABASE_URL from the config. Generate and apply the initial migration from the models.
> Created alembic.ini with logging config and script_location pointing to alembic/. env.py reads DATABASE_URL from backend.config.settings (with override support for tests). Added script.py.mako template. Generated initial migration (e1909678e89e) with all 8 tables, indexes, foreign keys, and enums. Migration applies cleanly on SQLite (render_as_batch=True for SQLite compatibility). 5 tests in tests/test_alembic.py covering upgrade/downgrade/columns/indexes/FKs. All 34 backend tests pass.
- [ ] Create backend/schemas.py with Pydantic request/response schemas for all API endpoints. Include create/update/response schemas for Project, Experiment, Run, Endpoint, and Webhook. Include the Score input schema and export format schemas.

View file

@ -1,4 +1,39 @@
# Alembic Configuration (placeholder — will be configured in the Alembic setup task)
[alembic]
script_location = alembic
sqlalchemy.url = sqlite:///data/promptlooper.db
# sqlalchemy.url is set programmatically in env.py from backend.config
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[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

View file

@ -1 +1,66 @@
# Placeholder — will be implemented in the Alembic setup task
"""Alembic environment configuration for PromptLooper."""
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
# Ensure the backend package is importable
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from backend.config import settings
from backend.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Use sqlalchemy.url from alembic config if already set (e.g. by tests),
# otherwise fall back to application settings.
if not config.get_main_option("sqlalchemy.url"):
config.set_main_option("sqlalchemy.url", settings.effective_database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL to stdout."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live database connection."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
alembic/script.py.mako Normal file
View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
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"}

View file

@ -0,0 +1,165 @@
"""initial_schema
Revision ID: e1909678e89e
Revises:
Create Date: 2026-04-07 01:50:18.571150
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e1909678e89e'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('response_cache',
sa.Column('config_hash', sa.String(length=64), nullable=False),
sa.Column('response', sa.Text(), nullable=False),
sa.Column('model', sa.String(length=255), nullable=False),
sa.Column('tokens_in', sa.Integer(), nullable=True),
sa.Column('tokens_out', sa.Integer(), nullable=True),
sa.Column('latency_ms', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('config_hash')
)
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('webhook_configs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('event_type', sa.String(length=255), nullable=False),
sa.Column('url', sa.String(length=2048), nullable=False),
sa.Column('headers', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('webhook_configs', schema=None) as batch_op:
batch_op.create_index('ix_webhook_configs_event_type', ['event_type'], unique=False)
op.create_table('projects',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('experiments',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('project_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('sample_data', sa.JSON(), nullable=True),
sa.Column('pipeline_stages', sa.JSON(), nullable=True),
sa.Column('scoring_config', sa.JSON(), nullable=True),
sa.Column('parameter_space', sa.JSON(), nullable=True),
sa.Column('status', sa.Enum('draft', 'running', 'paused', 'completed', name='experiment_status'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('experiments', schema=None) as batch_op:
batch_op.create_index('ix_experiments_project_id', ['project_id'], unique=False)
batch_op.create_index('ix_experiments_status', ['status'], unique=False)
op.create_table('runs',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('experiment_id', sa.Uuid(), nullable=False),
sa.Column('config_hash', sa.String(length=64), nullable=False),
sa.Column('config', sa.JSON(), nullable=False),
sa.Column('status', sa.Enum('pending', 'running', 'completed', 'failed', 'cached', name='run_status'), nullable=False),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('tokens_in', sa.Integer(), nullable=True),
sa.Column('tokens_out', sa.Integer(), nullable=True),
sa.Column('cost_estimate', sa.Numeric(precision=12, scale=6), nullable=True),
sa.ForeignKeyConstraint(['experiment_id'], ['experiments.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('runs', schema=None) as batch_op:
batch_op.create_index('ix_runs_config_hash', ['config_hash'], unique=False)
batch_op.create_index('ix_runs_experiment_id', ['experiment_id'], unique=False)
batch_op.create_index('ix_runs_status', ['status'], unique=False)
op.create_table('scores',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('run_id', sa.Uuid(), nullable=False),
sa.Column('scorer_name', sa.String(length=255), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('scores', schema=None) as batch_op:
batch_op.create_index('ix_scores_run_id', ['run_id'], unique=False)
batch_op.create_index('ix_scores_scorer_name', ['scorer_name'], unique=False)
op.create_table('stage_results',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('run_id', sa.Uuid(), nullable=False),
sa.Column('stage_index', sa.Integer(), nullable=False),
sa.Column('prompt_sent', sa.Text(), nullable=False),
sa.Column('response_raw', sa.Text(), nullable=False),
sa.Column('model_used', sa.String(length=255), nullable=False),
sa.Column('parameters', sa.JSON(), nullable=True),
sa.Column('tokens_in', sa.Integer(), nullable=True),
sa.Column('tokens_out', sa.Integer(), nullable=True),
sa.Column('latency_ms', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('stage_results', schema=None) as batch_op:
batch_op.create_index('ix_stage_results_run_id', ['run_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('stage_results', schema=None) as batch_op:
batch_op.drop_index('ix_stage_results_run_id')
op.drop_table('stage_results')
with op.batch_alter_table('scores', schema=None) as batch_op:
batch_op.drop_index('ix_scores_scorer_name')
batch_op.drop_index('ix_scores_run_id')
op.drop_table('scores')
with op.batch_alter_table('runs', schema=None) as batch_op:
batch_op.drop_index('ix_runs_status')
batch_op.drop_index('ix_runs_experiment_id')
batch_op.drop_index('ix_runs_config_hash')
op.drop_table('runs')
with op.batch_alter_table('experiments', schema=None) as batch_op:
batch_op.drop_index('ix_experiments_status')
batch_op.drop_index('ix_experiments_project_id')
op.drop_table('experiments')
op.drop_table('projects')
with op.batch_alter_table('webhook_configs', schema=None) as batch_op:
batch_op.drop_index('ix_webhook_configs_event_type')
op.drop_table('webhook_configs')
op.drop_table('users')
op.drop_table('response_cache')
# ### end Alembic commands ###

View file

@ -0,0 +1,107 @@
"""Tests for Alembic migration setup."""
import os
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
# Resolve the repo root regardless of where pytest is invoked from.
_REPO_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture()
def alembic_cfg(tmp_path):
"""Create an Alembic config pointing at a temporary SQLite database."""
db_path = tmp_path / "test.db"
db_url = f"sqlite:///{db_path}"
cfg = Config(str(_REPO_ROOT / "alembic.ini"))
cfg.set_main_option("script_location", str(_REPO_ROOT / "alembic"))
cfg.set_main_option("sqlalchemy.url", db_url)
return cfg, db_url
def test_upgrade_head_creates_all_tables(alembic_cfg):
"""Running 'upgrade head' should create all expected tables."""
cfg, db_url = alembic_cfg
command.upgrade(cfg, "head")
engine = create_engine(db_url)
inspector = inspect(engine)
tables = set(inspector.get_table_names())
expected = {
"alembic_version",
"users",
"projects",
"experiments",
"runs",
"stage_results",
"scores",
"response_cache",
"webhook_configs",
}
assert expected == tables
def test_downgrade_base_removes_all_tables(alembic_cfg):
"""Running 'downgrade base' should remove all application tables."""
cfg, db_url = alembic_cfg
command.upgrade(cfg, "head")
command.downgrade(cfg, "base")
engine = create_engine(db_url)
inspector = inspect(engine)
tables = set(inspector.get_table_names())
# Only alembic_version should remain
assert tables == {"alembic_version"}
def test_runs_table_has_expected_columns(alembic_cfg):
"""Spot-check that the runs table has key columns."""
cfg, db_url = alembic_cfg
command.upgrade(cfg, "head")
engine = create_engine(db_url)
inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("runs")}
assert "id" in columns
assert "experiment_id" in columns
assert "config_hash" in columns
assert "status" in columns
assert "cost_estimate" in columns
def test_indexes_created(alembic_cfg):
"""Verify key indexes exist after migration."""
cfg, db_url = alembic_cfg
command.upgrade(cfg, "head")
engine = create_engine(db_url)
inspector = inspect(engine)
run_indexes = {idx["name"] for idx in inspector.get_indexes("runs")}
assert "ix_runs_config_hash" in run_indexes
assert "ix_runs_experiment_id" in run_indexes
score_indexes = {idx["name"] for idx in inspector.get_indexes("scores")}
assert "ix_scores_run_id" in score_indexes
assert "ix_scores_scorer_name" in score_indexes
def test_foreign_keys_on_experiments(alembic_cfg):
"""Verify experiments table has FK to projects."""
cfg, db_url = alembic_cfg
command.upgrade(cfg, "head")
engine = create_engine(db_url)
inspector = inspect(engine)
fks = inspector.get_foreign_keys("experiments")
referred_tables = {fk["referred_table"] for fk in fks}
assert "projects" in referred_tables