From e651029ab2db779e88a9825fead3ccd89e399b44 Mon Sep 17 00:00:00 2001 From: menzelj Date: Wed, 24 Jun 2026 11:43:17 +0000 Subject: [PATCH] db: auto-add missing model columns on startup (fix backupschedule.agent_id) (0.38.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_all never ALTERs an existing table, so installs predating the backupschedule.agent_id column kept the old schema and any ORM query naming it failed with "no such column" — which the new fleet dashboard (and the schedules list / scheduler loop) hit. init_db now diffs each mapped table against the live schema and ADD COLUMNs the missing nullable/defaulted ones. Idempotent and self-healing for similar drift. Co-Authored-By: Claude Opus 4.8 --- backend/database.py | 39 +++++++++++++++++++++++++++++++++++++++ backend/version.py | 2 +- frontend/package.json | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/backend/database.py b/backend/database.py index bd7b731..93d0e09 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,13 +1,17 @@ """SQLModel database setup.""" from __future__ import annotations +import logging import os from collections.abc import Generator +from sqlalchemy import inspect, text from sqlmodel import Session, SQLModel, create_engine from config import settings +logger = logging.getLogger("stackpilot.database") + os.makedirs(settings.DATA_DIR, exist_ok=True) _DB_PATH = os.path.join(settings.DATA_DIR, "stackpilot.db") _DB_URL = f"sqlite:///{_DB_PATH}" @@ -19,11 +23,46 @@ engine = create_engine( ) +def _ensure_model_columns() -> None: + """Add columns that models define but a pre-existing table is missing. + + ``SQLModel.create_all`` creates missing *tables* but never ALTERs an + existing one, so installs that predate a newly-added column keep the old + schema — and every ORM query that names the column fails with + ``OperationalError: no such column``. For each mapped table we diff the + model's columns against the live table and ``ADD COLUMN`` the safe + (nullable, or defaulted) ones. Idempotent: on a fresh DB create_all already + made every column, so this is a no-op. + """ + insp = inspect(engine) + live_tables = set(insp.get_table_names()) + with engine.begin() as conn: + for table_name, table in SQLModel.metadata.tables.items(): + if table_name not in live_tables: + continue + existing = {c["name"] for c in insp.get_columns(table_name)} + for col in table.columns: + if col.name in existing: + continue + # SQLite can only ADD a NOT NULL column if it has a default to + # backfill existing rows; skip the rest rather than crash. + if not col.nullable and col.default is None and col.server_default is None: + logger.warning( + "Cannot auto-add non-nullable column %s.%s (no default); " + "manual migration needed", table_name, col.name + ) + continue + ddl_type = col.type.compile(dialect=engine.dialect) + conn.execute(text(f'ALTER TABLE "{table_name}" ADD COLUMN "{col.name}" {ddl_type}')) + logger.info("Schema migration: added column %s.%s", table_name, col.name) + + def init_db() -> None: # Import models so they are registered on SQLModel.metadata. import models # noqa: F401 SQLModel.metadata.create_all(engine) + _ensure_model_columns() def get_session() -> Generator[Session, None, None]: diff --git a/backend/version.py b/backend/version.py index 7833fbb..68f2105 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.38.2" +APP_VERSION = "0.38.3" diff --git a/frontend/package.json b/frontend/package.json index 28acc4b..20fbb40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.38.2", + "version": "0.38.3", "type": "module", "scripts": { "dev": "vite",