feat: phase 2 content model + cache — SQLite schema, markdown, TTL

Stand up the full SQLite content layer: all 7 tables from the authoritative
schema with WAL + foreign-keys enforced per-connection, entity dataclasses
plus row mappers, hand-rolled versioned migrations tracked in
schema_migrations, and an idempotent Python seed (system user + welcome
post + About page).

Add a Markdown->HTML service using markdown-it-py with a strict bleach
allowlist (tables intentionally omitted on both sides). Add a typed
in-process TTLCache[K,V] and wire it into real DB-backed PostService and
PageService, both exposing invalidate_all() for Phase 4 admin writes.

Rewire / and /about to read from the DB; homepage renders the seeded
welcome post, About renders page.title + sanitized body_html_cached.
Update the Phase 1 route tests accordingly.

Mark Phase 2 complete in docs/ROADMAP.md.
This commit is contained in:
2026-04-21 15:40:35 -05:00
parent 28168f57b6
commit 0306f71763
21 changed files with 2055 additions and 108 deletions

View File

@@ -76,13 +76,42 @@ High-level phased plan. Each phase ends in a mergeable `dev` state and a passing
- **No new packages.** Pillow / Jinja2 / Starlette StaticFiles were already in `requirements.txt` from Phase 0.
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 8 passed ✓ · uvicorn smoke: `/`, `/about`, `/contact`, `/shop`, `/healthz`, `/static/css/site.css`, `/static/img/logo.webp` all 200 with correct content-types ✓ · homepage body contains "No posts yet" + logo paths ✓ · contact page has `disabled` inputs and no `method` attribute ✓ · `docker compose config` exit 0 ✓.
## Phase 2 — Content Model + Cache
## Phase 2 — Content Model + Cache
- SQLite schema (below) with `PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`.
- Dataclasses (below) as the in-app model; SQL → dataclass mapper lives in `app/models/`.
- Row-level rendered-HTML cache (`body_html_cached`) regenerated on write.
- In-process TTL cache (60 s) over *hot query results* (published posts list, page-by-slug); invalidated on admin writes.
- Initial migration seeds one welcome blog post + an About page so the site is not blank before admin exists.
**Completed:** 2026-04-21
**Summary:** Stood up the full SQLite content layer: all 7 tables from the authoritative schema, entity dataclasses + row mappers, hand-rolled versioned migrations with a `schema_migrations` tracker, idempotent Python seed (system user + welcome post + About page), a Markdown→HTML service with a strict bleach allowlist, a typed in-process TTL cache, and DB-backed `PostService` / `PageService`. `/` and `/about` now read from the DB.
**Key files:**
- `app/db.py``create_engine()` factory, per-connection PRAGMA listener (WAL + `foreign_keys=ON`), `run_migrations(engine)` runner that scans `app/models/migrations/*.sql` in lex order and records applications in `schema_migrations`.
- `app/models/entities.py` — all 8 dataclasses (User, MagicLinkToken, Session, Page, Post, Media, ContactSubmission, AuthEvent) + `PostStatus(str, Enum)` matching roadmap 1:1. NOT frozen — Phase 3+ mutate `last_login_at`, `used_at`, etc.
- `app/models/mappers.py``row_to_user/post/page/...` converters, `_parse_datetime` / `_parse_bool` helpers.
- `app/models/migrations/001_init.sql` — verbatim roadmap schema: 7 tables, `idx_magic_email_created`, `idx_posts_status_pub`, `idx_auth_events_created`, `CHECK (status IN ('draft','published'))`.
- `app/models/seed.py` — idempotent: marker `seed_001` in `schema_migrations` + `INSERT OR IGNORE` belt-and-braces. Seeds user id=1 (`seed@chickenbabies.local`, "Head Hen", `active=0` — not a real admin, cannot log in), post slug `welcome-to-the-farm`, page slug `about`.
- `app/services/cache.py``TTLCache[K, V]` generic (~50 lines). `get/set/invalidate_all()`. Monotonic clock. 60s default TTL.
- `app/services/markdown.py``MarkdownService.render(md) -> str`: `MarkdownIt("commonmark")` (tables disabled — allowlist doesn't include `<table>`) → `bleach.clean(..., strip=True)` with tags `{p br strong em a ul ol li h1..h4 blockquote code pre img hr}`, attrs `{a:[href,title,rel], img:[src,alt,title,width,height]}`, protocols `{http https mailto}`. No `style`, no `class`, no raw HTML pass-through.
- `app/services/posts.py` — rewritten: `PostService(engine)` runs parameterized `SELECT ... FROM posts WHERE status='published' ORDER BY published_at DESC LIMIT :limit`, converts rows to `PostSummary`. Excerpt derived from `body_html_cached`. TTL-cached. `invalidate_all()` exposed for Phase 4.
- `app/services/pages.py``PageService(engine).get_by_slug(slug) -> Page | None`, TTL-cached.
- `app/main.py` — wires engine, runs migrations, runs seed, instantiates services onto `app.state.{engine,post_service,page_service}`.
- `app/routes/public.py``/about` now pulls the seeded `Page` from `PageService`; renders `{{ page.title }}` + `{{ page.body_html_cached | safe }}`. Logs an anomaly and returns 500 with a generic message if the page is unexpectedly missing.
- `app/templates/public/about.html` — replaced static body with the dynamic page; layout kept.
- `tests/conftest.py``db_engine` (session, seeded) + `clean_db_engine` (function, migrated-only) fixtures, both on temp SQLite files.
- `tests/test_db_migrations.py`, `test_markdown.py`, `test_cache.py`, `test_post_service.py`, `test_page_service.py` — service + schema coverage.
- `tests/test_public_routes.py` — updated: homepage now asserts "Welcome to the Farm"; `/about` asserts seeded Markdown substring.
**Endpoints created:** none new; `/` and `/about` were rewired to DB-backed services (same URLs, same response shapes).
**Key details:**
- **Migration pattern:** every SQL file under `app/models/migrations/` gets its own transaction; already-applied files are skipped by checking `schema_migrations`. Adding a new phase = add `NNN_description.sql`. The bootstrap for `schema_migrations` itself is baked into the runner (creates the table before querying it).
- **PRAGMAs are per-connection** via `@event.listens_for(Engine, "connect")` — every pooled connection gets WAL + FK-on, not just the first. There's an explicit test covering this.
- **Seed idempotency is double-guarded:** `schema_migrations` marker `seed_001` + `INSERT OR IGNORE` on every row. Second boot logs `seed_skipped`; counts stay 1/1/1 (users/pages/posts).
- **PostSummary excerpt is derived from `body_html_cached`** (HTML-stripped + truncated), not re-rendered from `body_md`. Phase 4 writers store both; readers never touch Markdown.
- **No Markdown tables yet.** `MarkdownIt.enable("table")` was deliberately NOT called — the bleach allowlist doesn't pass `<table>`. Future tables require widening both layers together; a test documents this invariant.
- **Address still not rendered.** Seeded About Markdown mentions Morrison, TN only (no street address, per CLAUDE.md).
- **Phase 3 hooks ready:** `users` / `magic_link_tokens` / `sessions` / `auth_events` tables exist with their indexes; `User` dataclass + `PostStatus` enum + row-mapper helpers available.
- **Phase 4 hooks ready:** `PostService.invalidate_all()` + `PageService.invalidate_all()` exist (no-op callers today). Admin writes will call these after each mutation.
- **No new packages.** All deps were already pinned in Phase 0's `requirements.txt`.
- **Verification run:** `python -c "from app.main import app"` ✓ · `pytest -q` 36 passed ✓ · fresh-boot smoke: `/` shows welcome title, `/about` shows seeded Markdown, `/healthz` 200 ✓ · `PRAGMA journal_mode=wal` ✓ · second boot logs `migrations_up_to_date` + `seed_skipped`, table counts stay `users=1 pages=1 posts=1` ✓ · `docker compose config` exit 0 ✓.
## Phase 3 — Admin Auth (Magic Link)