88bdf6f34b
- Split monolithic app.py into server/api/ routers - Add Pydantic schemas for input validation - Add /agent/* API with separate AGENT_TOKEN auth - Replace static schema.sql with versioned migrations (datenerhaltend) - Add tools/backup_db.py for SQLite backups - Add Dockerfile HEALTHCHECK and compose.yml AGENT_TOKEN env - Add pytest tests for user and agent API - Update README with API docs and deployment safety notes - Bump version to 1.2.0
80 lines
2.4 KiB
Python
80 lines
2.4 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import pathlib
|
|
import re
|
|
|
|
import aiosqlite
|
|
|
|
from server.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MIGRATION_RE = re.compile(r"^(\d+)_.*\.sql$")
|
|
|
|
|
|
def _migrations_dir() -> pathlib.Path:
|
|
path = pathlib.Path(settings.migrations_dir)
|
|
if path.is_absolute():
|
|
return path
|
|
return pathlib.Path(__file__).resolve().parents[1] / path
|
|
|
|
|
|
def _available_migrations() -> list[tuple[int, pathlib.Path]]:
|
|
directory = _migrations_dir()
|
|
if not directory.exists():
|
|
return []
|
|
migrations: list[tuple[int, pathlib.Path]] = []
|
|
for item in directory.iterdir():
|
|
match = _MIGRATION_RE.match(item.name)
|
|
if match and item.is_file():
|
|
migrations.append((int(match.group(1)), item))
|
|
migrations.sort(key=lambda x: x[0])
|
|
return migrations
|
|
|
|
|
|
async def _ensure_meta_table(db: aiosqlite.Connection) -> None:
|
|
await db.execute(
|
|
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
|
|
)
|
|
|
|
|
|
async def current_version(db: aiosqlite.Connection) -> int:
|
|
await _ensure_meta_table(db)
|
|
cur = await db.execute("SELECT MAX(version) FROM schema_version")
|
|
row = await cur.fetchone()
|
|
await cur.close()
|
|
return row[0] or 0 if row else 0
|
|
|
|
|
|
async def _record_version(db: aiosqlite.Connection, version: int) -> None:
|
|
from server.db import utc_now_iso
|
|
|
|
await db.execute(
|
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
(version, utc_now_iso()),
|
|
)
|
|
|
|
|
|
async def run_migrations(db: aiosqlite.Connection) -> None:
|
|
"""Apply pending SQL migrations safely. Idempotent, data-preserving."""
|
|
await _ensure_meta_table(db)
|
|
current = await current_version(db)
|
|
available = _available_migrations()
|
|
|
|
pending = [(v, p) for v, p in available if v > current]
|
|
if not pending:
|
|
logger.info("Database schema up to date (version %d)", current)
|
|
return
|
|
|
|
logger.info("Applying %d pending migration(s) (current version %d)", len(pending), current)
|
|
for version, path in pending:
|
|
sql = path.read_text(encoding="utf-8")
|
|
logger.info("Applying migration %d: %s", version, path.name)
|
|
await db.executescript(sql)
|
|
await _record_version(db, version)
|
|
await db.commit()
|
|
logger.info("Migration %d applied", version)
|
|
|
|
logger.info("Database migrations complete (version %d)", await current_version(db))
|