"""Database connection management. Production: the schema is owned by Alembic (`alembic upgrade head`). The `run_migrations()` helper below calls Alembic programmatically from the app lifespan so the first boot auto-migrates on SQLite (dev convenience). Tests: `ensure_schema()` applies the full DDL directly to an in-memory connection so test fixtures stay fast and do not depend on Alembic. """ from __future__ import annotations import aiosqlite # Full schema used by test fixtures directly (avoids a full Alembic run in tests). # This MUST stay in sync with migrations/versions/001_initial_schema.py. _FULL_SCHEMA = """ CREATE TABLE IF NOT EXISTS workspace ( id TEXT PRIMARY KEY, name TEXT NOT NULL, planning_goal TEXT NOT NULL DEFAULT 'balanced' CHECK (planning_goal IN ('max_time','max_money','balanced')), created_at TIMESTAMP NOT NULL ); CREATE TABLE IF NOT EXISTS personal_data ( uuid TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, salary_type TEXT NOT NULL DEFAULT 'monthly' CHECK (salary_type IN ('monthly','hourly')), employment_status TEXT NOT NULL DEFAULT 'employed' CHECK (employment_status IN ('employed','jobseeking','none')), personnummer_enc TEXT, created_at TIMESTAMP NOT NULL, deleted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS "user" ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, hashed_password TEXT NOT NULL, created_at TIMESTAMP NOT NULL, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS workspace_invitation ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, invited_by_user_id TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending','accepted','revoked','expired')), expires_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL, accepted_at TIMESTAMP, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (invited_by_user_id) REFERENCES "user"(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS child ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, birth_date DATE NOT NULL, preschool_start_date DATE, created_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS break_period ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, target_weeks INTEGER NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS parental_leave_fetch ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, source TEXT NOT NULL CHECK (source IN ('scraped','manual')), fetched_at TIMESTAMP NOT NULL, total_days INTEGER NOT NULL, days_used INTEGER NOT NULL, days_left INTEGER NOT NULL, reserved_days_total INTEGER NOT NULL DEFAULT 90, reserved_days_left INTEGER NOT NULL, transferable_days_left INTEGER NOT NULL, dubbeldagar_left INTEGER NOT NULL, tier_data TEXT NOT NULL, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS vacation_days ( id TEXT PRIMARY KEY, personal_data_uuid TEXT NOT NULL, workspace_id TEXT NOT NULL, year INTEGER NOT NULL, total_days INTEGER NOT NULL, saved_days_in INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(personal_data_uuid, year), FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE, FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS planned_leave_day ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, child_id TEXT NOT NULL, parent_personal_data_uuid TEXT NOT NULL, date DATE NOT NULL, day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')), is_dubbeldag INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid), FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE, FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE, FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE ); """ async def connect(path: str) -> aiosqlite.Connection: """Open a connection and enable row-by-name access + FK enforcement. Schema is applied via Alembic in the app lifespan (`run_migrations`). For test fixtures, call `ensure_schema` directly. """ conn = await aiosqlite.connect(path) conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys = ON;") return conn async def ensure_schema(conn: aiosqlite.Connection) -> None: """Apply the full DDL directly. Used by test fixtures only.""" await conn.executescript(_FULL_SCHEMA) await conn.commit() def run_migrations(database_url: str | None = None) -> None: """Run any pending Alembic migrations (called from app lifespan). `database_url` overrides alembic.ini's sqlalchemy.url, allowing the app config's `sqlite_path` to take precedence over the ini default. """ from alembic import command from alembic.config import Config cfg = Config("alembic.ini") if database_url: cfg.set_main_option("sqlalchemy.url", database_url) command.upgrade(cfg, "head")