From 3ab845798d5de03bc6e2a4046139a67421d2afc5 Mon Sep 17 00:00:00 2001 From: Cccc_ Date: Tue, 5 May 2026 01:36:58 +0800 Subject: [PATCH] feat(backend): add automatic DB migrations Add a lightweight migration runner with schema_migrations tracking, run pending migrations during backend startup before the scheduler, and keep a manual backend-migrate entrypoint. The change also moves the existing lockout and task-thread-ID schema steps into shared migration modules, updates docs, and archives the OpenSpec change. --- README.md | 4 + apps/backend/main.py | 9 + apps/backend/migration_steps/__init__.py | 1 + .../migration_steps/account_lockout.py | 29 +++ .../backend/migration_steps/task_thread_id.py | 98 ++++++++ apps/backend/migrations.py | 122 +++++++++ .../scripts/migrate_add_account_lockout.py | 63 +---- .../scripts/migrate_add_task_thread_id.py | 93 +------ apps/backend/scripts/run_migrations.py | 33 +++ docs/deployment.md | 10 +- docs/development.md | 12 +- main.py | 10 + .../.openspec.yaml | 2 + .../design.md | 95 +++++++ .../proposal.md | 30 +++ .../specs/backend-auto-migrations/spec.md | 72 ++++++ .../tasks.md | 19 ++ .../specs/backend-auto-migrations/spec.md | 75 ++++++ tests/test_backend_auto_migrations.py | 238 ++++++++++++++++++ tests/test_main_manager.py | 5 + tests/test_run_migrations_script.py | 36 +++ 21 files changed, 911 insertions(+), 145 deletions(-) create mode 100644 apps/backend/migration_steps/__init__.py create mode 100644 apps/backend/migration_steps/account_lockout.py create mode 100644 apps/backend/migration_steps/task_thread_id.py create mode 100644 apps/backend/migrations.py create mode 100644 apps/backend/scripts/run_migrations.py create mode 100644 openspec/changes/archive/2026-05-04-add-backend-auto-migrations/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-04-add-backend-auto-migrations/design.md create mode 100644 openspec/changes/archive/2026-05-04-add-backend-auto-migrations/proposal.md create mode 100644 openspec/changes/archive/2026-05-04-add-backend-auto-migrations/specs/backend-auto-migrations/spec.md create mode 100644 openspec/changes/archive/2026-05-04-add-backend-auto-migrations/tasks.md create mode 100644 openspec/specs/backend-auto-migrations/spec.md create mode 100644 tests/test_backend_auto_migrations.py create mode 100644 tests/test_run_migrations_script.py diff --git a/README.md b/README.md index 04b112d..418074d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ uv sync uv run playwright install chromium uv run python main.py backend +# 手动执行数据库迁移(后端启动时也会自动执行) +uv run python main.py backend-migrate + # 前端 cd apps/frontend pnpm install @@ -70,6 +73,7 @@ docker compose up -d --build ```bash uv run python main.py backend-daemon +uv run python main.py backend-migrate python main.py frontend-daemon python main.py status python main.py stop [all|backend|frontend] diff --git a/apps/backend/main.py b/apps/backend/main.py index b342e84..ac5286f 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -8,6 +8,7 @@ import logging from pathlib import Path from backend.config import settings +from backend.migrations import run_pending_migrations from backend.models import init_db from backend.exceptions import BaseAPIException from backend.schemas.response import ErrorResponse, ErrorDetail @@ -37,6 +38,14 @@ async def lifespan(app: FastAPI): init_db() logger.info("数据库初始化完成") + logger.info("正在执行数据库迁移...") + migration_result = run_pending_migrations() + logger.info( + "数据库迁移完成:applied=%s skipped=%s", + len(migration_result.applied), + len(migration_result.skipped), + ) + # 确保必要的目录存在 settings.SESSION_DIR.mkdir(parents=True, exist_ok=True) (settings.BASE_DIR / "data").mkdir(parents=True, exist_ok=True) diff --git a/apps/backend/migration_steps/__init__.py b/apps/backend/migration_steps/__init__.py new file mode 100644 index 0000000..beeadc5 --- /dev/null +++ b/apps/backend/migration_steps/__init__.py @@ -0,0 +1 @@ +"""Database migration step implementations.""" diff --git a/apps/backend/migration_steps/account_lockout.py b/apps/backend/migration_steps/account_lockout.py new file mode 100644 index 0000000..b4eae08 --- /dev/null +++ b/apps/backend/migration_steps/account_lockout.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from sqlalchemy.engine import Connection +from sqlalchemy import text + + +def _table_columns(conn: Connection, table_name: str) -> set[str]: + rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall() + return {str(row[1]) for row in rows} + + +def apply(conn: Connection) -> None: + columns = _table_columns(conn, "users") + + if "failed_login_attempts" not in columns: + conn.execute( + text("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL") + ) + conn.commit() + + columns = _table_columns(conn, "users") + if "locked_until" not in columns: + conn.execute(text("ALTER TABLE users ADD COLUMN locked_until DATETIME")) + conn.commit() + + columns = _table_columns(conn, "users") + if "last_failed_login" not in columns: + conn.execute(text("ALTER TABLE users ADD COLUMN last_failed_login DATETIME")) + conn.commit() diff --git a/apps/backend/migration_steps/task_thread_id.py b/apps/backend/migration_steps/task_thread_id.py new file mode 100644 index 0000000..2046c37 --- /dev/null +++ b/apps/backend/migration_steps/task_thread_id.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json + +from sqlalchemy import text +from sqlalchemy.engine import Connection + + +def _table_columns(conn: Connection, table_name: str) -> set[str]: + rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall() + return {str(row[1]) for row in rows} + + +def _table_indexes(conn: Connection, table_name: str) -> set[str]: + rows = conn.execute(text(f"PRAGMA index_list({table_name})")).fetchall() + return {str(row[1]) for row in rows} + + +def _has_thread_id_uniqueness(conn: Connection) -> bool: + indexes = conn.execute(text("PRAGMA index_list(check_in_tasks)")).fetchall() + for row in indexes: + is_unique = bool(row[2]) + if not is_unique: + continue + index_name = str(row[1]) + columns = conn.execute(text(f"PRAGMA index_info({index_name})")).fetchall() + column_names = [str(column[2]) for column in columns] + if column_names == ["user_id", "thread_id"]: + return True + return False + + +def _extract_thread_id(payload_config: str | None) -> str | None: + if not payload_config: + return None + try: + payload = json.loads(payload_config) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + thread_id = payload.get("ThreadId") + value = str(thread_id).strip() if thread_id is not None else "" + return value or None + + +def apply(conn: Connection) -> None: + columns = _table_columns(conn, "check_in_tasks") + + if "thread_id" not in columns: + conn.execute(text("ALTER TABLE check_in_tasks ADD COLUMN thread_id VARCHAR(100)")) + conn.commit() + + full_rows = conn.execute( + text("SELECT id, user_id, payload_config FROM check_in_tasks") + ).fetchall() + invalid_ids: list[int] = [] + seen: dict[tuple[int, str], int] = {} + duplicate_ids: list[int] = [] + + for row in full_rows: + thread_id = _extract_thread_id(row.payload_config) + if not thread_id: + invalid_ids.append(row.id) + continue + key = (row.user_id, thread_id) + if key in seen: + duplicate_ids.append(row.id) + else: + seen[key] = row.id + + if invalid_ids or duplicate_ids: + messages = [] + if invalid_ids: + messages.append(f"payload_config 缺少有效 ThreadId 的任务: {invalid_ids}") + if duplicate_ids: + messages.append(f"同用户 ThreadId 重复的任务: {duplicate_ids}") + raise RuntimeError(";".join(messages)) + + rows = conn.execute(text("SELECT id, payload_config FROM check_in_tasks")).fetchall() + for row in rows: + thread_id = _extract_thread_id(row.payload_config) + if thread_id: + conn.execute( + text("UPDATE check_in_tasks SET thread_id = :thread_id WHERE id = :id"), + {"thread_id": thread_id, "id": row.id}, + ) + conn.commit() + + indexes = _table_indexes(conn, "check_in_tasks") + if "ix_task_user_thread_id_unique" not in indexes and not _has_thread_id_uniqueness(conn): + conn.execute( + text( + "CREATE UNIQUE INDEX ix_task_user_thread_id_unique " + "ON check_in_tasks (user_id, thread_id)" + ) + ) + conn.commit() diff --git a/apps/backend/migrations.py b/apps/backend/migrations.py new file mode 100644 index 0000000..6f52d53 --- /dev/null +++ b/apps/backend/migrations.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy import Engine, text +from sqlalchemy.engine import Connection + +from backend.migration_steps.account_lockout import apply as apply_account_lockout +from backend.migration_steps.task_thread_id import apply as apply_task_thread_id +from backend.models.database import engine as default_engine + +logger = logging.getLogger(__name__) + +MIGRATION_TABLE_NAME = "schema_migrations" + + +@dataclass(frozen=True) +class Migration: + id: str + description: str + apply: Callable[[Connection], None] + + +@dataclass(frozen=True) +class MigrationRunResult: + applied: tuple[str, ...] + skipped: tuple[str, ...] + + +class MigrationExecutionError(RuntimeError): + def __init__(self, migration_id: str, original: Exception) -> None: + self.migration_id = migration_id + self.original = original + super().__init__(f"Migration {migration_id} failed: {original}") + + +def ensure_migration_table(conn: Connection) -> None: + conn.execute( + text( + f""" + CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE_NAME} ( + id VARCHAR(200) PRIMARY KEY, + description VARCHAR(500) NOT NULL, + applied_at DATETIME NOT NULL + ) + """ + ) + ) + conn.commit() + + +def get_applied_migration_ids(conn: Connection) -> set[str]: + ensure_migration_table(conn) + rows = conn.execute(text(f"SELECT id FROM {MIGRATION_TABLE_NAME}")).fetchall() + return {str(row.id) for row in rows} + + +def mark_migration_applied(conn: Connection, migration: Migration) -> None: + conn.execute( + text( + f""" + INSERT INTO {MIGRATION_TABLE_NAME} (id, description, applied_at) + VALUES (:id, :description, :applied_at) + """ + ), + { + "id": migration.id, + "description": migration.description, + "applied_at": datetime.now(timezone.utc).isoformat(), + }, + ) + conn.commit() + + +MIGRATIONS: tuple[Migration, ...] = ( + Migration( + id="2026050401_add_account_lockout", + description="Add account lockout columns to users.", + apply=apply_account_lockout, + ), + Migration( + id="2026050402_add_task_thread_id", + description="Add and backfill check-in task thread identity.", + apply=apply_task_thread_id, + ), +) + + +def run_pending_migrations( + *, + engine: Engine = default_engine, + migrations: Sequence[Migration] = MIGRATIONS, +) -> MigrationRunResult: + applied: list[str] = [] + skipped: list[str] = [] + + with engine.connect() as conn: + applied_ids = get_applied_migration_ids(conn) + + for migration in migrations: + if migration.id in applied_ids: + logger.info("Skipping applied migration %s", migration.id) + skipped.append(migration.id) + continue + + logger.info("Applying migration %s: %s", migration.id, migration.description) + try: + migration.apply(conn) + mark_migration_applied(conn, migration) + except Exception as exc: + conn.rollback() + logger.exception("Migration %s failed", migration.id) + raise MigrationExecutionError(migration.id, exc) from exc + + logger.info("Applied migration %s", migration.id) + applied.append(migration.id) + applied_ids.add(migration.id) + + return MigrationRunResult(applied=tuple(applied), skipped=tuple(skipped)) diff --git a/apps/backend/scripts/migrate_add_account_lockout.py b/apps/backend/scripts/migrate_add_account_lockout.py index 6588e50..8f93095 100644 --- a/apps/backend/scripts/migrate_add_account_lockout.py +++ b/apps/backend/scripts/migrate_add_account_lockout.py @@ -1,69 +1,26 @@ """ -数据库迁移脚本:添加账户锁定相关字段 +数据库迁移脚本:添加账户锁定相关字段。 -添加字段: -- failed_login_attempts: 连续登录失败次数 -- locked_until: 账户锁定到期时间 -- last_failed_login: 最后一次登录失败时间 - -运行方式: +通常无需手动运行,后端启动时会自动执行待迁移项。需要单独执行时: uv run python -m backend.scripts.migrate_add_account_lockout """ -import sys -from pathlib import Path +from __future__ import annotations -APPS_DIR = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(APPS_DIR)) - -from sqlalchemy import text -from backend.models.database import engine import logging +import sys + +from backend.migration_steps.account_lockout import apply as apply_account_lockout +from backend.models.database import engine logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def migrate(): - """执行迁移""" +def migrate() -> None: logger.info("开始迁移:添加账户锁定相关字段...") - with engine.connect() as conn: - # 检查字段是否已存在 - result = conn.execute(text("PRAGMA table_info(users)")) - columns = [row[1] for row in result] - - # 添加 failed_login_attempts 字段 - if "failed_login_attempts" not in columns: - logger.info("添加 failed_login_attempts 字段...") - conn.execute( - text( - "ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0 NOT NULL" - ) - ) - conn.commit() - logger.info("✓ failed_login_attempts 字段添加成功") - else: - logger.info("✓ failed_login_attempts 字段已存在,跳过") - - # 添加 locked_until 字段 - if "locked_until" not in columns: - logger.info("添加 locked_until 字段...") - conn.execute(text("ALTER TABLE users ADD COLUMN locked_until DATETIME")) - conn.commit() - logger.info("✓ locked_until 字段添加成功") - else: - logger.info("✓ locked_until 字段已存在,跳过") - - # 添加 last_failed_login 字段 - if "last_failed_login" not in columns: - logger.info("添加 last_failed_login 字段...") - conn.execute(text("ALTER TABLE users ADD COLUMN last_failed_login DATETIME")) - conn.commit() - logger.info("✓ last_failed_login 字段添加成功") - else: - logger.info("✓ last_failed_login 字段已存在,跳过") - + apply_account_lockout(conn) logger.info("✅ 迁移完成!账户锁定功能已启用") @@ -71,5 +28,5 @@ if __name__ == "__main__": try: migrate() except Exception as e: - logger.error(f"❌ 迁移失败: {e}") + logger.error("❌ 迁移失败: %s", e) sys.exit(1) diff --git a/apps/backend/scripts/migrate_add_task_thread_id.py b/apps/backend/scripts/migrate_add_task_thread_id.py index 8763ce9..294707e 100644 --- a/apps/backend/scripts/migrate_add_task_thread_id.py +++ b/apps/backend/scripts/migrate_add_task_thread_id.py @@ -1,107 +1,26 @@ """ 数据库迁移脚本:添加打卡任务 thread_id 字段并回填。 -运行方式: +通常无需手动运行,后端启动时会自动执行待迁移项。需要单独执行时: uv run python -m backend.scripts.migrate_add_task_thread_id """ -import json +from __future__ import annotations + import logging import sys -from pathlib import Path - -APPS_DIR = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(APPS_DIR)) - -from sqlalchemy import text +from backend.migration_steps.task_thread_id import apply as apply_task_thread_id from backend.models.database import engine logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def _extract_thread_id(payload_config: str | None) -> str | None: - if not payload_config: - return None - try: - payload = json.loads(payload_config) - except json.JSONDecodeError: - return None - if not isinstance(payload, dict): - return None - thread_id = payload.get("ThreadId") - value = str(thread_id).strip() if thread_id is not None else "" - return value or None - - def migrate() -> None: - """执行迁移。""" logger.info("开始迁移:添加 check_in_tasks.thread_id 字段...") - with engine.connect() as conn: - result = conn.execute(text("PRAGMA table_info(check_in_tasks)")) - columns = [row[1] for row in result] - - if "thread_id" not in columns: - logger.info("添加 thread_id 字段...") - conn.execute(text("ALTER TABLE check_in_tasks ADD COLUMN thread_id VARCHAR(100)")) - conn.commit() - logger.info("✓ thread_id 字段添加成功") - else: - logger.info("✓ thread_id 字段已存在,跳过") - - rows = conn.execute(text("SELECT id, payload_config FROM check_in_tasks")).fetchall() - invalid_ids: list[int] = [] - seen: dict[tuple[int, str], int] = {} - duplicate_ids: list[int] = [] - - full_rows = conn.execute( - text("SELECT id, user_id, payload_config FROM check_in_tasks") - ).fetchall() - for row in full_rows: - thread_id = _extract_thread_id(row.payload_config) - if not thread_id: - invalid_ids.append(row.id) - continue - key = (row.user_id, thread_id) - if key in seen: - duplicate_ids.append(row.id) - else: - seen[key] = row.id - - if invalid_ids or duplicate_ids: - messages = [] - if invalid_ids: - messages.append(f"payload_config 缺少有效 ThreadId 的任务: {invalid_ids}") - if duplicate_ids: - messages.append(f"同用户 ThreadId 重复的任务: {duplicate_ids}") - raise RuntimeError(";".join(messages)) - - for row in rows: - thread_id = _extract_thread_id(row.payload_config) - if thread_id: - conn.execute( - text("UPDATE check_in_tasks SET thread_id = :thread_id WHERE id = :id"), - {"thread_id": thread_id, "id": row.id}, - ) - conn.commit() - - indexes = conn.execute(text("PRAGMA index_list(check_in_tasks)")).fetchall() - index_names = [row[1] for row in indexes] - if "ix_task_user_thread_id_unique" not in index_names: - logger.info("添加用户级 thread_id 唯一索引...") - conn.execute( - text( - "CREATE UNIQUE INDEX ix_task_user_thread_id_unique " - "ON check_in_tasks (user_id, thread_id)" - ) - ) - conn.commit() - logger.info("✓ 用户级 thread_id 唯一索引添加成功") - else: - logger.info("✓ 用户级 thread_id 唯一索引已存在,跳过") - + apply_task_thread_id(conn) logger.info("✅ 迁移完成!任务 thread_id 身份字段已启用") @@ -109,5 +28,5 @@ if __name__ == "__main__": try: migrate() except Exception as e: - logger.error(f"❌ 迁移失败: {e}") + logger.error("❌ 迁移失败: %s", e) sys.exit(1) diff --git a/apps/backend/scripts/run_migrations.py b/apps/backend/scripts/run_migrations.py new file mode 100644 index 0000000..7aa8929 --- /dev/null +++ b/apps/backend/scripts/run_migrations.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +运行数据库迁移的脚本。 + +使用方法: + uv run python -m backend.scripts.run_migrations +""" + +from __future__ import annotations + +import logging + +from backend.migrations import MigrationExecutionError, run_pending_migrations +from backend.models import init_db + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def main() -> int: + try: + init_db() + result = run_pending_migrations() + except MigrationExecutionError as exc: + logger.error("❌ 迁移失败: %s", exc) + return 1 + + logger.info("✅ 迁移完成:applied=%s skipped=%s", len(result.applied), len(result.skipped)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/deployment.md b/docs/deployment.md index 81ba5b4..f9e320b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -90,6 +90,14 @@ git pull docker compose up -d --build ``` +后端启动时会自动执行待迁移数据库脚本,并在迁移完成后才启动调度器。如果迁移失败,backend 服务会启动失败并在日志中标出失败的迁移 ID。 + +需要手动执行迁移时: + +```bash +docker compose exec backend uv run python main.py backend-migrate +``` + 不要使用 `docker compose down -v`,否则会删除 Compose 管理的卷。当前默认使用项目目录 bind mount,仍应避免误删 `data/`、`sessions/`、`logs/`。 ### 备份与恢复 @@ -116,7 +124,7 @@ git checkout docker compose up -d --build ``` -回滚时复用同一份 `.env`、`data/`、`sessions/`、`logs/`。如果未来版本引入数据库迁移,按对应版本说明先确认迁移是否可逆。 +回滚时复用同一份 `.env`、`data/`、`sessions/`、`logs/`。回滚到旧版本前,先确认当前版本已经执行的数据库迁移是否可被旧版本兼容读取。 ### Compose 故障排查 diff --git a/docs/development.md b/docs/development.md index 06c4954..90f2461 100644 --- a/docs/development.md +++ b/docs/development.md @@ -190,13 +190,17 @@ export const tagApi = { ### 数据库迁移 ```bash -# 修改模型后生成迁移脚本 -# 手动创建脚本在 apps/backend/scripts/migrate_*.py +# 后端启动时会在调度器启动前自动执行待迁移项。 -# 执行迁移 -uv run python apps/backend/scripts/migrate_xxx.py +# 如需手动执行全部待迁移项 +uv run python main.py backend-migrate + +# 或直接调用脚本模块 +uv run python -m backend.scripts.run_migrations ``` +新增迁移时,将迁移函数注册到 `backend.migrations.MIGRATIONS`,并保持迁移逻辑幂等。迁移只有成功完成后才会写入 `schema_migrations`。 + ### 测试 #### 后端测试 diff --git a/main.py b/main.py index d984424..efcb4c3 100755 --- a/main.py +++ b/main.py @@ -112,6 +112,13 @@ def run_backend(args: argparse.Namespace) -> int: return 0 +def run_backend_migrations(_: argparse.Namespace) -> int: + ensure_import_path() + from backend.scripts.run_migrations import main as run_migrations_main + + return run_migrations_main() + + def start_backend_daemon(args: argparse.Namespace) -> int: ensure_runtime_dirs() if BACKEND_PID.exists(): @@ -260,6 +267,9 @@ def build_parser() -> argparse.ArgumentParser: add_backend_args(backend) backend.set_defaults(func=run_backend) + backend_migrate = sub.add_parser("backend-migrate", help="run backend database migrations") + backend_migrate.set_defaults(func=run_backend_migrations) + backend_daemon = sub.add_parser("backend-daemon", help="start backend in the background") backend_daemon.add_argument("--host", default="0.0.0.0") backend_daemon.add_argument("--port", type=int, default=BACKEND_PORT) diff --git a/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/.openspec.yaml b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/.openspec.yaml new file mode 100644 index 0000000..905325f --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-04 diff --git a/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/design.md b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/design.md new file mode 100644 index 0000000..20ba40f --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/design.md @@ -0,0 +1,95 @@ +## Context + +The backend boot path currently creates tables with `Base.metadata.create_all(bind=engine)` and then starts runtime services. Two schema evolution scripts already live under `apps/backend/scripts/`, but they must be run manually and are not coordinated with service startup. That means a fresh or upgraded database can drift out of sync until an operator remembers to run the right script. + +This change is scoped to the current backend stack and its SQLite-first runtime behavior. It should improve deployment safety without introducing a full migration framework or changing the database access layer. + +## Goals / Non-Goals + +**Goals:** + +- Apply pending backend schema migrations automatically before the service becomes ready. +- Keep an explicit record of applied migrations in the database. +- Preserve a manual command for running migrations outside normal startup. +- Reuse the existing migration logic for account lockout and task thread ID rather than duplicating it. +- Fail fast if migration execution fails. + +**Non-Goals:** + +- Introducing Alembic or database-version autogeneration. +- Designing a generic cross-project migration framework. +- Changing business API behavior beyond startup readiness and migration execution. +- Reworking unrelated schema definitions that are not part of the existing manual migrations. + +## Decisions + +### 1. Add a small migration runner with a metadata table + +Create a backend migration module that owns a fixed ordered registry of migration entries. Each entry should have a stable identifier, a short description, and a callable that receives a database connection or session-bound connection. The runner should create and consult a `schema_migrations` table before executing anything. + +Why this over relying on ad hoc script execution: the database itself becomes the source of truth for what has already been applied, so startup and manual execution use the same contract. This is lighter than Alembic and better aligned with the current hand-written SQL style. + +Alternatives considered: + +- Keep only standalone scripts. Rejected because there is no durable applied-state record and startup cannot know whether a migration still needs to run. +- Adopt Alembic now. Rejected because it is larger than the current need and would require a broader migration model than the repo currently uses. + +### 2. Run migrations during startup before the scheduler starts + +Move the migration runner into the FastAPI lifespan startup path after the database base tables are ensured and before `start_scheduler()` is called. + +Why this order: the app should not begin processing scheduled work against a partially migrated database. Running before the scheduler also keeps the failure surface small and makes startup failures explicit. + +Alternatives considered: + +- Run migrations lazily on first request. Rejected because it defers failure until user traffic arrives and allows the scheduler to start against the wrong schema. +- Run migrations after the scheduler starts. Rejected because scheduler jobs may read or write schema fields that are not yet present. + +### 3. Keep the existing scripts as thin wrappers or registered migration implementations + +The existing `migrate_add_account_lockout.py` and `migrate_add_task_thread_id.py` logic should be reused through the registry rather than remaining as separate one-off flows. A manual runner entrypoint can call the same registry used at startup. + +Why this choice: it avoids duplicate migration behavior and keeps the manual/operator path and the automatic path consistent. + +Alternatives considered: + +- Rewrite the scripts into a new CLI-only tool and leave startup separate. Rejected because the automatic path would still need a second implementation. +- Delete the scripts entirely. Rejected because operators still need a manual escape hatch and the scripts already document the schema history. + +### 4. Treat migration failure as a startup blocker + +If any migration raises, the runner should stop immediately and surface the failing migration ID and error. A migration should only be marked applied after its work succeeds. + +Why this choice: schema drift is a correctness problem, not a recoverable warning. Marking failure as applied would hide a broken database state and make recovery harder. + +### 5. Keep migrations idempotent and validation-first where needed + +The runner should skip already-applied migrations based on metadata. Individual migration functions should still check the current schema when they need to perform safe DDL or data backfills, because SQLite DDL and older databases can require defensive inspection. + +Why this choice: metadata protects the common case, but some migrations already need schema checks and data validation before altering tables or creating indexes. + +## Risks / Trade-offs + +- [Risk] Manual SQL migrations can still be database-specific and brittle. → Mitigation: keep the registry small, ordered, and explicit; avoid pretending this is a generic migration framework. +- [Risk] Startup failures may block the entire service when a migration encounters bad legacy data. → Mitigation: fail fast by design, surface the failing migration clearly, and keep validation in the migration itself so operators can fix the data before retrying. +- [Risk] SQLite DDL behavior can make transactional guarantees uneven. → Mitigation: use metadata updates only after successful execution and keep migration steps idempotent so reruns are safe. +- [Risk] The manual scripts and the registry could drift apart. → Mitigation: make the scripts call the shared migration functions or runner so there is one source of truth. + +## Migration Plan + +1. Add the migration metadata table and runner module. +2. Register the two existing migrations in execution order. +3. Wire the runner into backend startup before the scheduler begins. +4. Add a manual CLI entrypoint that invokes the same runner. +5. Add tests for first-run execution, repeat-run skipping, failure handling, and startup ordering. +6. Update the developer/deployment notes to mention automatic startup migrations and the manual command. + +Rollback strategy: + +- If a migration blocks startup, fix the underlying migration or data issue and rerun startup or the manual command. +- Because applied migrations are tracked explicitly, repeated runs should remain safe once the issue is resolved. + +## Open Questions + +- Should the manual runner live as a dedicated `backend.scripts.run_migrations` module, or should the startup helper be the only public entrypoint and scripts import it directly? +- Should migration IDs be semantic strings based on the change name, or timestamp-prefixed identifiers to make ordering obvious? diff --git a/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/proposal.md b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/proposal.md new file mode 100644 index 0000000..24ef733 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/proposal.md @@ -0,0 +1,30 @@ +## Why + +The backend currently relies on `Base.metadata.create_all()` plus manually executed SQL scripts, so existing databases do not reliably receive schema changes during deployment or restart. This is risky now because recent backend changes already added schema evolution scripts that are easy to forget before the scheduler starts using the database. + +## What Changes + +- Add a lightweight backend migration capability that tracks applied migrations in the database and runs pending migrations in deterministic order. +- Run backend migrations automatically during FastAPI startup after base table creation and before the scheduler starts. +- Preserve a manual migration entrypoint for operators and developers who want to apply or inspect migrations outside normal service startup. +- Adapt the existing account-lockout and task-thread-id migration scripts into the registered migration path while keeping them safe to skip after they have already run. +- Fail startup clearly when a migration fails, and never mark a failed migration as applied. +- Do not add Alembic or a broad migration framework in this change. + +## Capabilities + +### New Capabilities + +- `backend-auto-migrations`: automatic and manual execution contract for ordered backend database migrations. + +### Modified Capabilities + +None. + +## Impact + +- Affected backend startup path: `apps/backend/main.py`. +- Affected database code: `apps/backend/models/database.py` and a new backend migration module or service. +- Affected scripts: existing migration scripts under `apps/backend/scripts/` plus a consolidated manual runner. +- Affected tests: backend migration runner tests and startup-order coverage. +- Affected documentation: developer/deployment guidance for automatic migrations and the manual migration command. diff --git a/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/specs/backend-auto-migrations/spec.md b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/specs/backend-auto-migrations/spec.md new file mode 100644 index 0000000..6f1d2d8 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/specs/backend-auto-migrations/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Ordered backend migration registry +The backend SHALL define a deterministic registry of database migrations with stable identifiers and execution order. + +#### Scenario: Registry order is stable +- **WHEN** the migration runner loads available migrations +- **THEN** it SHALL evaluate them in the registered order using stable migration identifiers. + +#### Scenario: Existing migrations are registered +- **WHEN** the backend migration registry is built +- **THEN** it SHALL include migrations for the existing account-lockout fields and task thread ID schema changes. + +### Requirement: Applied migration tracking +The backend SHALL store applied migration records in the application database and use those records to skip completed migrations. + +#### Scenario: Migration metadata is initialized +- **WHEN** the migration runner starts against a database without migration metadata +- **THEN** it SHALL create the migration metadata table before checking pending migrations. + +#### Scenario: Pending migration is marked after success +- **WHEN** a pending migration completes successfully +- **THEN** the backend SHALL record that migration as applied with its stable identifier and applied timestamp. + +#### Scenario: Completed migration is skipped +- **WHEN** a migration identifier is already present in the applied migration metadata +- **THEN** the backend SHALL skip that migration instead of executing it again. + +#### Scenario: Failed migration is not marked +- **WHEN** a migration fails during execution +- **THEN** the backend SHALL NOT record that migration as applied. + +### Requirement: Automatic startup migration execution +The backend SHALL run pending database migrations automatically during API startup before runtime background work begins. + +#### Scenario: Startup applies migrations before scheduler +- **WHEN** the FastAPI lifespan startup runs +- **THEN** it SHALL initialize base database tables, run pending migrations, and only then start the scheduler. + +#### Scenario: Startup stops on migration failure +- **WHEN** any pending migration fails during startup +- **THEN** the backend SHALL fail startup and SHALL NOT start the scheduler. + +#### Scenario: Startup logs migration activity +- **WHEN** startup migration execution runs +- **THEN** the backend SHALL log whether migrations were applied, skipped, or failed with the relevant migration identifier. + +### Requirement: Manual migration execution +The backend SHALL provide a manual command path that runs the same registered migrations used by automatic startup. + +#### Scenario: Operator runs migrations manually +- **WHEN** an operator executes the documented backend migration command +- **THEN** the command SHALL apply pending registered migrations and skip already-applied migrations. + +#### Scenario: Manual failure exits unsuccessfully +- **WHEN** a migration fails during manual execution +- **THEN** the command SHALL exit unsuccessfully after reporting the failing migration. + +### Requirement: Existing migration behavior is preserved +The backend SHALL preserve the behavior of existing schema changes when they move into the automatic migration path. + +#### Scenario: Account lockout fields are added +- **WHEN** the account-lockout migration runs against a database missing its fields +- **THEN** it SHALL add `failed_login_attempts`, `locked_until`, and `last_failed_login` to the users table. + +#### Scenario: Task thread identity is backfilled +- **WHEN** the task thread ID migration runs against valid existing task payloads +- **THEN** it SHALL add or maintain `check_in_tasks.thread_id`, backfill it from `payload_config.ThreadId`, and ensure the per-user thread ID uniqueness index exists. + +#### Scenario: Invalid legacy task payload blocks migration +- **WHEN** the task thread ID migration finds missing or duplicate `ThreadId` values that would make the schema invalid +- **THEN** it SHALL fail with a clear validation error instead of silently creating inconsistent task identity data. diff --git a/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/tasks.md b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/tasks.md new file mode 100644 index 0000000..b30e315 --- /dev/null +++ b/openspec/changes/archive/2026-05-04-add-backend-auto-migrations/tasks.md @@ -0,0 +1,19 @@ +## 1. Migration Runner Foundation + +- [x] 1.1 Add a backend migration metadata table and helper utilities for reading and writing applied migration records. +- [x] 1.2 Implement a deterministic migration registry with stable identifiers and ordered execution. +- [x] 1.3 Extract the existing account-lockout and task-thread-id migration logic into shared callable migration units. +- [x] 1.4 Add a manual migration entrypoint that invokes the shared registry. + +## 2. Startup Integration + +- [x] 2.1 Wire the migration runner into backend FastAPI startup after base table creation and before scheduler startup. +- [x] 2.2 Make startup fail fast when a migration fails and ensure the scheduler does not start afterward. +- [x] 2.3 Add clear logs for applied, skipped, and failed migrations. + +## 3. Verification and Documentation + +- [x] 3.1 Add backend tests for first-run execution, repeat-run skipping, and failure-not-marked behavior. +- [x] 3.2 Add startup-order coverage to prove migrations run before the scheduler. +- [x] 3.3 Update developer/deployment documentation with automatic startup migration behavior and the manual migration command. +- [x] 3.4 Run the repository checks for backend code and OpenSpec validation before archiving or implementation handoff. diff --git a/openspec/specs/backend-auto-migrations/spec.md b/openspec/specs/backend-auto-migrations/spec.md new file mode 100644 index 0000000..2756e5c --- /dev/null +++ b/openspec/specs/backend-auto-migrations/spec.md @@ -0,0 +1,75 @@ +# backend-auto-migrations Specification + +## Purpose +Backend database migration contract for applying ordered schema changes during startup and through a manual operator command. +## Requirements +### Requirement: Ordered backend migration registry +The backend SHALL define a deterministic registry of database migrations with stable identifiers and execution order. + +#### Scenario: Registry order is stable +- **WHEN** the migration runner loads available migrations +- **THEN** it SHALL evaluate them in the registered order using stable migration identifiers. + +#### Scenario: Existing migrations are registered +- **WHEN** the backend migration registry is built +- **THEN** it SHALL include migrations for the existing account-lockout fields and task thread ID schema changes. + +### Requirement: Applied migration tracking +The backend SHALL store applied migration records in the application database and use those records to skip completed migrations. + +#### Scenario: Migration metadata is initialized +- **WHEN** the migration runner starts against a database without migration metadata +- **THEN** it SHALL create the migration metadata table before checking pending migrations. + +#### Scenario: Pending migration is marked after success +- **WHEN** a pending migration completes successfully +- **THEN** the backend SHALL record that migration as applied with its stable identifier and applied timestamp. + +#### Scenario: Completed migration is skipped +- **WHEN** a migration identifier is already present in the applied migration metadata +- **THEN** the backend SHALL skip that migration instead of executing it again. + +#### Scenario: Failed migration is not marked +- **WHEN** a migration fails during execution +- **THEN** the backend SHALL NOT record that migration as applied. + +### Requirement: Automatic startup migration execution +The backend SHALL run pending database migrations automatically during API startup before runtime background work begins. + +#### Scenario: Startup applies migrations before scheduler +- **WHEN** the FastAPI lifespan startup runs +- **THEN** it SHALL initialize base database tables, run pending migrations, and only then start the scheduler. + +#### Scenario: Startup stops on migration failure +- **WHEN** any pending migration fails during startup +- **THEN** the backend SHALL fail startup and SHALL NOT start the scheduler. + +#### Scenario: Startup logs migration activity +- **WHEN** startup migration execution runs +- **THEN** the backend SHALL log whether migrations were applied, skipped, or failed with the relevant migration identifier. + +### Requirement: Manual migration execution +The backend SHALL provide a manual command path that runs the same registered migrations used by automatic startup. + +#### Scenario: Operator runs migrations manually +- **WHEN** an operator executes the documented backend migration command +- **THEN** the command SHALL apply pending registered migrations and skip already-applied migrations. + +#### Scenario: Manual failure exits unsuccessfully +- **WHEN** a migration fails during manual execution +- **THEN** the command SHALL exit unsuccessfully after reporting the failing migration. + +### Requirement: Existing migration behavior is preserved +The backend SHALL preserve the behavior of existing schema changes when they move into the automatic migration path. + +#### Scenario: Account lockout fields are added +- **WHEN** the account-lockout migration runs against a database missing its fields +- **THEN** it SHALL add `failed_login_attempts`, `locked_until`, and `last_failed_login` to the users table. + +#### Scenario: Task thread identity is backfilled +- **WHEN** the task thread ID migration runs against valid existing task payloads +- **THEN** it SHALL add or maintain `check_in_tasks.thread_id`, backfill it from `payload_config.ThreadId`, and ensure the per-user thread ID uniqueness index exists. + +#### Scenario: Invalid legacy task payload blocks migration +- **WHEN** the task thread ID migration finds missing or duplicate `ThreadId` values that would make the schema invalid +- **THEN** it SHALL fail with a clear validation error instead of silently creating inconsistent task identity data. diff --git a/tests/test_backend_auto_migrations.py b/tests/test_backend_auto_migrations.py new file mode 100644 index 0000000..9f22f23 --- /dev/null +++ b/tests/test_backend_auto_migrations.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine, text + +from backend.migrations import ( + MIGRATIONS, + Migration, + MigrationExecutionError, + get_applied_migration_ids, + run_pending_migrations, +) +from backend.migration_steps.account_lockout import apply as apply_account_lockout +from backend.migration_steps.task_thread_id import apply as apply_task_thread_id + + +def test_pending_migration_is_recorded_and_skipped_on_next_run() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + calls: list[str] = [] + + def apply_test_migration(conn) -> None: + calls.append("001_test") + conn.execute(text("CREATE TABLE example (id INTEGER PRIMARY KEY)")) + + migrations = ( + Migration( + id="001_test", + description="create example table", + apply=apply_test_migration, + ), + ) + + first_result = run_pending_migrations(engine=engine, migrations=migrations) + second_result = run_pending_migrations(engine=engine, migrations=migrations) + + with engine.connect() as conn: + applied_ids = get_applied_migration_ids(conn) + + assert first_result.applied == ("001_test",) + assert first_result.skipped == () + assert second_result.applied == () + assert second_result.skipped == ("001_test",) + assert calls == ["001_test"] + assert applied_ids == {"001_test"} + + +def test_failed_migration_is_not_recorded_as_applied() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + def broken_migration(conn) -> None: + conn.execute(text("CREATE TABLE before_failure (id INTEGER PRIMARY KEY)")) + raise RuntimeError("boom") + + migrations = ( + Migration( + id="001_broken", + description="broken migration", + apply=broken_migration, + ), + ) + + with pytest.raises(MigrationExecutionError) as exc_info: + run_pending_migrations(engine=engine, migrations=migrations) + + with engine.connect() as conn: + applied_ids = get_applied_migration_ids(conn) + + assert exc_info.value.migration_id == "001_broken" + assert applied_ids == set() + + +def test_existing_migrations_are_registered_in_order() -> None: + assert [migration.id for migration in MIGRATIONS] == [ + "2026050401_add_account_lockout", + "2026050402_add_task_thread_id", + ] + assert [migration.apply.__module__ for migration in MIGRATIONS] == [ + "backend.migration_steps.account_lockout", + "backend.migration_steps.task_thread_id", + ] + + +def test_account_lockout_migration_adds_missing_user_fields() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + conn.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY, alias VARCHAR(50))")) + conn.commit() + + apply_account_lockout(conn) + + columns = {row[1] for row in conn.execute(text("PRAGMA table_info(users)"))} + + assert {"failed_login_attempts", "locked_until", "last_failed_login"} <= columns + + +def test_task_thread_id_migration_backfills_payload_thread_id() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + conn.execute( + text( + "CREATE TABLE check_in_tasks (" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER NOT NULL, " + "payload_config TEXT NOT NULL" + ")" + ) + ) + conn.execute( + text( + "INSERT INTO check_in_tasks (id, user_id, payload_config) " + 'VALUES (1, 10, \'{"ThreadId":"thread-1"}\')' + ) + ) + conn.commit() + + apply_task_thread_id(conn) + + row = conn.execute(text("SELECT thread_id FROM check_in_tasks WHERE id = 1")).one() + indexes = {row[1] for row in conn.execute(text("PRAGMA index_list(check_in_tasks)"))} + + assert row.thread_id == "thread-1" + assert "ix_task_user_thread_id_unique" in indexes + + +def test_task_thread_id_migration_rejects_invalid_payloads() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + conn.execute( + text( + "CREATE TABLE check_in_tasks (" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER NOT NULL, " + "payload_config TEXT NOT NULL" + ")" + ) + ) + conn.execute( + text("INSERT INTO check_in_tasks (id, user_id, payload_config) VALUES (1, 10, '{}')") + ) + conn.commit() + + with pytest.raises(RuntimeError, match="ThreadId"): + apply_task_thread_id(conn) + + +def test_task_thread_id_migration_does_not_recreate_existing_unique_index() -> None: + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + + with engine.connect() as conn: + conn.execute( + text( + "CREATE TABLE check_in_tasks (" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER NOT NULL, " + "payload_config TEXT NOT NULL, " + "thread_id VARCHAR(100)" + ")" + ) + ) + conn.execute( + text( + "INSERT INTO check_in_tasks (id, user_id, payload_config, thread_id) " + "VALUES (1, 10, '{\"ThreadId\":\"thread-1\"}', 'thread-1')" + ) + ) + conn.execute( + text( + "CREATE UNIQUE INDEX uq_task_user_thread_id ON check_in_tasks (user_id, thread_id)" + ) + ) + conn.commit() + + apply_task_thread_id(conn) + + indexes = {row[1] for row in conn.execute(text("PRAGMA index_list(check_in_tasks)"))} + + assert "uq_task_user_thread_id" in indexes + assert "ix_task_user_thread_id_unique" not in indexes + + +@pytest.mark.asyncio +async def test_lifespan_runs_migrations_before_scheduler(monkeypatch) -> None: + from backend import main as backend_main + from backend.services import scheduler_service + + order: list[str] = [] + + def fake_init_db() -> None: + order.append("init_db") + + def fake_run_pending_migrations() -> SimpleNamespace: + order.append("migrations") + return SimpleNamespace(applied=("001_test",), skipped=()) + + def fake_start_scheduler() -> None: + order.append("scheduler") + + monkeypatch.setattr(backend_main, "init_db", fake_init_db) + monkeypatch.setattr(backend_main, "run_pending_migrations", fake_run_pending_migrations) + monkeypatch.setattr(scheduler_service, "start_scheduler", fake_start_scheduler) + + async with backend_main.lifespan(object()): + pass + + assert order == ["init_db", "migrations", "scheduler"] + + +@pytest.mark.asyncio +async def test_lifespan_does_not_start_scheduler_when_migration_fails(monkeypatch) -> None: + from backend import main as backend_main + from backend.services import scheduler_service + + order: list[str] = [] + + def fake_init_db() -> None: + order.append("init_db") + + def fake_run_pending_migrations() -> None: + order.append("migrations") + raise RuntimeError("migration failed") + + def fake_start_scheduler() -> None: + order.append("scheduler") + + monkeypatch.setattr(backend_main, "init_db", fake_init_db) + monkeypatch.setattr(backend_main, "run_pending_migrations", fake_run_pending_migrations) + monkeypatch.setattr(scheduler_service, "start_scheduler", fake_start_scheduler) + + with pytest.raises(RuntimeError, match="migration failed"): + async with backend_main.lifespan(object()): + pass + + assert order == ["init_db", "migrations"] diff --git a/tests/test_main_manager.py b/tests/test_main_manager.py index 8eca5a9..e49da39 100644 --- a/tests/test_main_manager.py +++ b/tests/test_main_manager.py @@ -56,6 +56,11 @@ class BackendManagerUvTests(unittest.TestCase): self.assertEqual(cmd[1:3], ["run", "python"]) self.assertEqual(cmd[3:5], [str(main.REPO_ROOT / "main.py"), "backend"]) + def test_backend_migrate_command_is_registered(self) -> None: + args = main.build_parser().parse_args(["backend-migrate"]) + + self.assertIs(args.func, main.run_backend_migrations) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_run_migrations_script.py b/tests/test_run_migrations_script.py new file mode 100644 index 0000000..cc2a8ad --- /dev/null +++ b/tests/test_run_migrations_script.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[1] / "apps" / "backend" / "scripts" / "run_migrations.py" +) +SCRIPT_SPEC = importlib.util.spec_from_file_location("backend.scripts.run_migrations", SCRIPT_PATH) +assert SCRIPT_SPEC is not None and SCRIPT_SPEC.loader is not None +run_migrations = importlib.util.module_from_spec(SCRIPT_SPEC) +SCRIPT_SPEC.loader.exec_module(run_migrations) + + +def test_run_migrations_initializes_database_before_running_pending_migrations() -> None: + calls: list[str] = [] + + def fake_init_db() -> None: + calls.append("init_db") + + def fake_run_pending_migrations(): + calls.append("run_pending_migrations") + return SimpleNamespace(applied=("001",), skipped=()) + + with ( + patch.object(run_migrations, "init_db", side_effect=fake_init_db), + patch.object( + run_migrations, "run_pending_migrations", side_effect=fake_run_pending_migrations + ), + ): + exit_code = run_migrations.main() + + assert exit_code == 0 + assert calls == ["init_db", "run_pending_migrations"]