Scaffold project, data model and artwork
Establish the LLeMbas foundation: FastAPI/Jinja/SQLite layout, the ORM schema, and the original SVG identity. Notable decisions, all recorded in comments at the point they matter: - No Alembic. SQLite only, schema created at startup, so models carry a few columns nothing reads yet (Message.parent_id for branching, content_parts_json for multimodal turns). Adding them later to a live database without migrations is the painful path. - Sessions are server-side rows keyed by a SHA-256 of the cookie value, not JWTs, so logout and bans revoke access immediately. - Upstream API keys are Fernet-encrypted with a key derived from LEMBAS_SECRET_KEY. decrypt() fails soft to "" so rotating the secret degrades to re-entering keys rather than crashing the admin UI. - Artwork is generated by scripts/build_artwork.py rather than hand-drawn per file: the mallorn leaf appears in the icon, favicon, lockup and banner, and one source is the only way those stay in sync. The wordmark is Source Serif 4 (OFL) converted to outlines, because a README banner cannot load a webfont and <text> would render in whatever serif the viewer happens to have. - Icons live in a template partial, not assets/, because same-document <use href="#id"> is universally supported and the cross-document form is not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""Engine, session factory and startup schema creation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.base import Base
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_engine: Engine | None = None
|
||||
_SessionFactory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
@event.listens_for(Engine, "connect")
|
||||
def _configure_sqlite(dbapi_connection, connection_record) -> None: # noqa: ANN001
|
||||
"""Apply the pragmas SQLite needs to behave under a concurrent web server.
|
||||
|
||||
- WAL lets readers proceed while a write is in flight, which matters because
|
||||
a streaming reply holds a write open for the length of the generation.
|
||||
- foreign_keys is OFF by default in SQLite, so every ondelete= in the models
|
||||
would be decoration without this.
|
||||
- busy_timeout makes concurrent writers wait rather than fail instantly.
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings.ensure_dirs()
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{settings.db_path}",
|
||||
# FastAPI runs sync endpoints in a threadpool, so a connection can
|
||||
# legitimately be used from a thread other than the one that made it.
|
||||
connect_args={"check_same_thread": False},
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
global _SessionFactory
|
||||
if _SessionFactory is None:
|
||||
_SessionFactory = sessionmaker(
|
||||
bind=get_engine(),
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _SessionFactory
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create any missing tables.
|
||||
|
||||
This is ``CREATE TABLE IF NOT EXISTS`` only -- it never alters an existing
|
||||
table. There is no migration tool in this project by design, so changing a
|
||||
column on a model requires migrating the database by hand.
|
||||
"""
|
||||
import lembas.db.models # noqa: F401 (registers tables on the metadata)
|
||||
|
||||
Base.metadata.create_all(bind=get_engine())
|
||||
log.debug("schema ensured at %s", settings.db_path)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope() -> Iterator[Session]:
|
||||
"""Transactional scope for background work and CLI commands.
|
||||
|
||||
Request handlers should use the `db` dependency in lembas.api.deps instead.
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
session = factory()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def reset_engine() -> None:
|
||||
"""Drop cached engine/factory. Used by tests to rebind to a temp database."""
|
||||
global _engine, _SessionFactory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_SessionFactory = None
|
||||
Reference in New Issue
Block a user