Slice 1: scaffolding + propose-to-super-draft vertical
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""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()
|
||||
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
|
||||
Reference in New Issue
Block a user