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:
Jaroslav Beneš
2026-07-21 10:34:48 +02:00
parent 0665027bc6
commit 5ef2af6a9f
32 changed files with 2041 additions and 0 deletions
+102
View File
@@ -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