db: auto-add missing model columns on startup (fix backupschedule.agent_id) (0.38.3)

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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-24 11:43:17 +00:00
co-authored by Claude Opus 4.8
parent d399caadc9
commit e651029ab2
3 changed files with 41 additions and 2 deletions
+39
View File
@@ -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]:
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version."""
APP_VERSION = "0.38.2"
APP_VERSION = "0.38.3"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.38.2",
"version": "0.38.3",
"type": "module",
"scripts": {
"dev": "vite",