second-brain/src/second_brain/database.py
Travis Herbranson ceeae77e7d postgres migration: schema, models, embeddings, alembic
Swap the SQLite backing store for petalbrain Postgres + pgvector, modeled
on vault-mcp. All second-brain relational tables now live in the
`second_brain` schema (owned by the lovebug role); embeddings are written
to the shared public.embeddings table.

Locked design decisions (per Travis):
- DB: existing petalbrain Postgres, second_brain schema, lovebug role.
- Connection: containerized homelab-postgres:5432, plain psycopg_pool
  (min=1/max=10), no PgBouncer.
- ORM stays SQLAlchemy; int autoincrement PKs + naive UTC DateTime.
- Embeddings: reuse shared public.embeddings keyed by
  (source_schema='second_brain', source_table='extractions', source_id,
  model='nomic-embed-text'). Summaries only for this round.
- Pipeline: chunk_text → Ollama nomic-embed-text → delete-before-insert
  upsert, with graceful degradation (no DB / no Ollama → log + skip).
- Alembic stands up second-brain's own schema; public.embeddings stays
  out-of-band.
- File-based wiki compiler is unchanged.

No SQLite data import — starting clean.

This commit is the scaffolding only; `alembic upgrade head` and a smoke
test of the embedding path are the next checkpoint.
2026-05-24 22:46:48 -04:00

155 lines
4.8 KiB
Python

"""
Database engine and session management for second-brain.
Postgres + pgvector backend on the petalbrain instance.
Resolution order for the connection URL:
1. constructor arg passed to `get_database()`
2. env var `SECOND_BRAIN_DATABASE_URL`
3. env var `HERBYLAB_DATABASE_URL` (shared homelab convention)
4. `[database].url` in settings.toml
The URL is expected to use SQLAlchemy's `postgresql+psycopg://` driver
(psycopg v3). Plain `postgresql://` URLs are accepted and rewritten to use
psycopg; this matches the vault-mcp convention where one URL feeds both
Alembic (`+psycopg`) and raw psycopg (no prefix).
Connection mode: containerized (homelab-postgres:5432 on the homelab docker
network). For local-host dev runs, point at 127.0.0.1:5433 instead.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from second_brain.models import SCHEMA, Base
def _normalize_url(url: str) -> str:
"""Ensure the URL uses SQLAlchemy's psycopg-v3 driver."""
if url.startswith("postgresql+psycopg://"):
return url
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+psycopg://", 1)
return url
def _resolve_url(explicit: str | None) -> str:
if explicit:
return _normalize_url(explicit)
for var in ("SECOND_BRAIN_DATABASE_URL", "HERBYLAB_DATABASE_URL"):
v = os.environ.get(var)
if v:
return _normalize_url(v)
# Fall back to settings.toml [database].url
try:
from second_brain.config import load_config
cfg = load_config()
url = cfg.database.get("url")
if url:
return _normalize_url(url)
except Exception:
pass
raise RuntimeError(
"No Postgres connection URL found. Set SECOND_BRAIN_DATABASE_URL "
"(or HERBYLAB_DATABASE_URL), or fill `[database].url` in "
"config/settings.toml."
)
class Database:
"""Manages the SQLAlchemy engine and session factory for second-brain."""
def __init__(self, url: str | None = None) -> None:
self.url = _resolve_url(url)
# Pool sizing matches vault-mcp (min=1/max=10). No PgBouncer; a normal
# QueuePool is fine for the CLI + scheduler + web workers we run.
self.engine: Engine = create_engine(
self.url,
echo=False,
pool_size=int(os.environ.get("SECOND_BRAIN_DB_POOL_MIN", "1")),
max_overflow=int(os.environ.get("SECOND_BRAIN_DB_POOL_MAX", "9")),
pool_pre_ping=True,
future=True,
)
self.SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=self.engine,
future=True,
)
def init_db(self) -> None:
"""Create the schema and tables if missing.
Schema creation is idempotent and only relevant for dev/test setups
where Alembic hasn't been applied yet. In production, prefer
`alembic upgrade head` — this method just defensively ensures the
schema exists before `create_all` issues unqualified DDL.
"""
with self.engine.begin() as conn:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}"))
Base.metadata.create_all(bind=self.engine)
def get_session(self) -> Session:
"""Return a new bare session (caller must close it)."""
return self.SessionLocal()
@contextmanager
def session(self) -> Generator[Session, None, None]:
"""Context-manager that commits on exit and rolls back on error."""
sess = self.SessionLocal()
try:
yield sess
sess.commit()
except Exception:
sess.rollback()
raise
finally:
sess.close()
def close(self) -> None:
self.engine.dispose()
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
_db_instance: Database | None = None
def get_database(url: str | None = None) -> Database:
"""Return the singleton Database, creating it lazily on first call.
Schema/tables are *not* auto-created here — run `alembic upgrade head`
once during setup. Tests may explicitly call `Database(url).init_db()`
against a scratch database.
"""
global _db_instance
if _db_instance is None:
_db_instance = Database(url)
return _db_instance
def reset_database_singleton() -> None:
"""Test hook — drop the cached singleton so a new URL can take effect."""
global _db_instance
if _db_instance is not None:
_db_instance.close()
_db_instance = None
__all__ = ["Database", "get_database", "reset_database_singleton"]