a117fbb521
Lands the table rebuilds migration 026 deferred until a second project exists, shipped separately from per-project serving for migration hygiene. No behavior change (deployments stay single-project 'default'). - migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed PK/UNIQUE of 13 tables (cached_rfcs PK (slug)->(project_id,slug); the UNIQUE/PK on cached_branches, branch_visibility, branch_contribute_grants, stars, watches, pr_seen, branch_chat_seen, funder_consents, rfc_collaborators, contribution_requests, proposed_use_cases gain project_id) and makes the FKs to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug). - db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild procedure) + foreign_key_check after, failing loudly on dangling refs. - ON CONFLICT upsert targets for the rebuilt tables gain project_id so they match the new composite indexes (value still defaults to 'default'). - test_migration_028_project_scoped_keys.py: proves two-project same-slug coexistence, within-project uniqueness, composite-FK enforcement. 442 pass. - design doc §2 marked shipped. Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.9 KiB
Python
107 lines
3.9 KiB
Python
"""SQLite connection and migration runner.
|
|
|
|
The schema lives in backend/migrations/ as numbered .sql files; this module
|
|
runs them in order against the configured database file and exposes a
|
|
connection factory that the rest of the app uses. WAL is enabled because
|
|
the SSE handlers and the reconciler can read while a webhook writes; FK
|
|
enforcement is on because §5's cascade rules depend on it.
|
|
|
|
Per §4.2, single SQLite file colocated with the FastAPI process. If we
|
|
outgrow this, the spec calls for a planned migration to Postgres on a
|
|
separate host — not for a clever sharding scheme bolted on here.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Iterator
|
|
|
|
from .config import Config
|
|
|
|
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"
|
|
|
|
|
|
def connect(path: Path) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(path, isolation_level=None, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA synchronous = NORMAL")
|
|
return conn
|
|
|
|
|
|
def run_migrations(config: Config) -> None:
|
|
conn = connect(config.database_path)
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
"""
|
|
)
|
|
applied = {row["version"] for row in conn.execute("SELECT version FROM schema_migrations")}
|
|
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
|
|
version = path.stem
|
|
if version in applied:
|
|
continue
|
|
sql = path.read_text()
|
|
if "-- migrate:no-foreign-keys" in sql:
|
|
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
|
|
# folding project_id into a composite key) follow the official
|
|
# 12-step ALTER procedure, which requires FK enforcement OFF —
|
|
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
|
|
# it must be toggled here, around the script. The connection is
|
|
# in autocommit mode (isolation_level=None), so the PRAGMA takes
|
|
# effect immediately. We re-enable and run foreign_key_check
|
|
# after, failing the migration loudly if the rebuild left any
|
|
# dangling reference.
|
|
conn.execute("PRAGMA foreign_keys = OFF")
|
|
try:
|
|
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
|
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
|
|
if violations:
|
|
raise RuntimeError(
|
|
f"migration {version} left foreign-key violations: "
|
|
f"{[tuple(v) for v in violations]}"
|
|
)
|
|
finally:
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
else:
|
|
conn.executescript("BEGIN; " + sql + "; COMMIT;")
|
|
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
_CONN: sqlite3.Connection | None = None
|
|
|
|
|
|
def init(config: Config) -> None:
|
|
"""Open the long-lived connection. Call once at startup, after migrations."""
|
|
global _CONN
|
|
if _CONN is not None:
|
|
return
|
|
_CONN = connect(config.database_path)
|
|
|
|
|
|
def conn() -> sqlite3.Connection:
|
|
if _CONN is None:
|
|
raise RuntimeError("db.init() has not been called")
|
|
return _CONN
|
|
|
|
|
|
@contextmanager
|
|
def tx() -> Iterator[sqlite3.Connection]:
|
|
"""Wrap a block in a transaction. Rolls back on exception."""
|
|
c = conn()
|
|
c.execute("BEGIN")
|
|
try:
|
|
yield c
|
|
c.execute("COMMIT")
|
|
except Exception:
|
|
c.execute("ROLLBACK")
|
|
raise
|