From 0ec75ab617958773607402e351913e9e388faf92 Mon Sep 17 00:00:00 2001 From: John Lightner Date: Tue, 7 Apr 2026 01:52:03 -0500 Subject: [PATCH] MAESTRO: Set up Alembic with initial migration for all 8 ORM models --- Auto Run Docs/01-scaffold.md | 3 +- alembic.ini | 39 ++++- alembic/env.py | 67 ++++++- alembic/script.py.mako | 26 +++ .../versions/e1909678e89e_initial_schema.py | 165 ++++++++++++++++++ backend/tests/test_alembic.py | 107 ++++++++++++ 6 files changed, 403 insertions(+), 4 deletions(-) create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/e1909678e89e_initial_schema.py create mode 100644 backend/tests/test_alembic.py diff --git a/Auto Run Docs/01-scaffold.md b/Auto Run Docs/01-scaffold.md index 9d5fdc2..c071af1 100644 --- a/Auto Run Docs/01-scaffold.md +++ b/Auto Run Docs/01-scaffold.md @@ -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. diff --git a/alembic.ini b/alembic.ini index 9f7e4dc..14cf73f 100644 --- a/alembic.ini +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py index 8b73b3a..1584fcb 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/e1909678e89e_initial_schema.py b/alembic/versions/e1909678e89e_initial_schema.py new file mode 100644 index 0000000..82efab4 --- /dev/null +++ b/alembic/versions/e1909678e89e_initial_schema.py @@ -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 ### diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py new file mode 100644 index 0000000..085b96a --- /dev/null +++ b/backend/tests/test_alembic.py @@ -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