smriti/backend/app/db/database.py
Himanshu Dongre f22056418d Add optional local-first SQLite mode (Phase A core)
Smriti can now run against a file-backed SQLite database with no Docker
and no Postgres, removing the operate-a-backend burden for solo builders.
Postgres remains the stronger shared/team mode, unchanged.

- Portable column types (app/db/types.py): JSON renders as JSONB on
  PostgreSQL and generic JSON on SQLite; the pgvector embedding column
  renders as JSON on SQLite. Same models and create_all on both backends.
- Mode resolution (config.py): SMRITI_DB_MODE=local|postgres, defaulting
  to local when unconfigured. An explicitly-set Postgres DATABASE_URL
  preserves Postgres behavior, so existing setups are unaffected. Local
  DB defaults to ~/.smriti/smriti.db; SMRITI_LOCAL_DB_PATH overrides.
- SQLite engine setup (database.py): check_same_thread plus foreign_keys
  / WAL / busy_timeout pragmas; a lazy first-run create_all bootstrap on
  first DB use, which keeps the integration test suite insulated.
- Removed the integration-test JSONB/VECTOR DDL substitution hack — the
  models are genuinely portable now, so conftest needs no type patching.
- Alembic resolves its URL through the same logic (Postgres mode only).
- New persistent file-backed SQLite smoke test, plus mode-resolution and
  per-dialect type-rendering tests.

Local mode uses create_all, not Alembic. No schema changes.
2026-05-16 22:26:05 +05:30

92 lines
2.8 KiB
Python

"""SQLAlchemy database engine and session management."""
import logging
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.config import settings
logger = logging.getLogger("uvicorn.error")
def _build_engine():
"""Create the SQLAlchemy engine for the resolved database URL.
Local mode runs against a file-backed SQLite database and needs
cross-thread access plus durability/concurrency pragmas. Postgres
mode is unchanged from prior behavior.
"""
url = settings.resolved_database_url
if url.startswith("sqlite"):
sqlite_engine = create_engine(
url,
echo=settings.debug,
connect_args={"check_same_thread": False},
)
@event.listens_for(sqlite_engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
return sqlite_engine
return create_engine(url, echo=settings.debug)
engine = _build_engine()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
_local_db_ready = False
def bootstrap_local_db() -> None:
"""Ensure the local SQLite database exists with the current schema.
Idempotent. No-op in Postgres mode — Alembic owns that schema. In
local mode, creates the parent directory (e.g. ~/.smriti) if needed
and runs create_all, which creates any missing tables and skips
existing ones.
Must run after every ORM model module is imported, so that
Base.metadata is fully populated before create_all.
"""
global _local_db_ready
if _local_db_ready or settings.db_mode != "local":
return
db_path = settings.local_db_path
first_run = not db_path.exists()
db_path.parent.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine)
if first_run:
logger.info("Smriti local SQLite database created at %s", db_path)
_local_db_ready = True
def get_db():
"""Dependency that yields a database session.
In local mode the SQLite schema is bootstrapped lazily on first use.
Running the bootstrap here — rather than at import or app startup —
keeps the integration test suite, which overrides this dependency,
fully insulated from the real local database file.
"""
bootstrap_local_db()
logger.info("get_db: Creating new session...")
db = SessionLocal()
logger.info("get_db: Session created.")
try:
logger.info("get_db: Yielding session...")
yield db
finally:
logger.info("get_db: Closing session...")
db.close()