import json
from pathlib import Path
import aiosqlite
from app.config import get_settings
_conn: aiosqlite.Connection | None = None
# У старой БД auto_vacuum=0: чтобы он заработал, нужен разовый VACUUM (см. vacuum_if_needed)
_vacuum_pending = False
async def init_db() -> None:
"""Открыть БД, включить WAL, применить schema.sql."""
global _conn, _vacuum_pending
settings = get_settings()
settings.database_path.parent.mkdir(parents=True, exist_ok=True)
_conn = await aiosqlite.connect(settings.database_path)
_conn.row_factory = aiosqlite.Row
# auto_vacuum=INCREMENTAL: удалённые страницы возвращаются файлу (иначе БД
# навсегда остаётся в пиковом размере). На пустой БД действует сразу; на
# существующей — только после VACUUM, поэтому запоминаем флаг.
cursor = await _conn.execute("PRAGMA auto_vacuum")
(auto_vacuum,) = await cursor.fetchone()
_vacuum_pending = auto_vacuum == 0
if _vacuum_pending:
await _conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
await _conn.execute("PRAGMA journal_mode=WAL")
await _conn.execute("PRAGMA foreign_keys=ON")
schema = (Path(__file__).with_name("schema.sql")).read_text(encoding="utf-8")
await _conn.executescript(schema)
# Колонки, которых нет в старых БД: CREATE TABLE IF NOT EXISTS их не добавит.
await _try_alter("ALTER TABLE service_samples ADD COLUMN report TEXT")
await _try_alter("ALTER TABLE users ADD COLUMN system_role TEXT")
await _try_alter("ALTER TABLE users ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
await _try_alter("ALTER TABLE services ADD COLUMN site_title TEXT")
await _try_alter("ALTER TABLE services ADD COLUMN icon TEXT")
await _try_alter("ALTER TABLE services ADD COLUMN meta_fetched_at TEXT")
await _try_alter("ALTER TABLE metrics ADD COLUMN agg_json TEXT NOT NULL DEFAULT '{}'")
await _conn.commit()
async def _try_alter(stmt: str) -> None:
"""ALTER для существующих таблиц; дубликат колонки — норма (уже добавлена)."""
try:
await _conn.execute(stmt)
except aiosqlite.OperationalError as exc:
if "duplicate column" not in str(exc).lower():
raise
async def vacuum_if_needed() -> bool:
"""Разовый VACUUM, если у БД был auto_vacuum=0 (см. init_db). Возвращает «делали ли»."""
global _vacuum_pending
if not _vacuum_pending or _conn is None:
return False
_vacuum_pending = False
await _conn.execute("VACUUM")
await _conn.commit()
return True
async def incremental_vacuum(pages: int = 1000) -> None:
"""Вернуть файлу до pages свободных страниц (работает при auto_vacuum=INCREMENTAL)."""
if _conn is not None:
await _conn.execute(f"PRAGMA incremental_vacuum({int(pages)})")
async def close_db() -> None:
global _conn
if _conn is not None:
await _conn.close()
_conn = None
def get_db() -> aiosqlite.Connection:
assert _conn is not None, "database not initialized"
return _conn
def j(value) -> str:
"""Сериализация JSON-колонок."""
return json.dumps(value, ensure_ascii=False)