Database Schema — Journeys, Home & Content¶
Draft 2 · 2026-08-12 · Companion to journeys-and-home.md — review/optimize before implementation
Design draft — target state, not all built
Content tables (Passage, Writing, Book, Article, Theme, Tag) are implemented. Home, progress, bookmark, and reflection tables are designed but not yet migrated. The Journey tables below are being built now (models + CMS only; no API).
Draft 2 — the Journey spine flattened
Draft 1 modelled a Journey as chapters that each contained ~4 pieces
(data_chapter_piece, one is_primary). The updated designs drop that level: a chapter is
one piece. data_chapter_piece is gone, its content refs moved onto data_journey_chapter,
and a chapter can now reference an Article as well as a Passage or Writing.
Column-level schema for every table the Journeys + Home design creates or reshapes. Wagtail's own
tables (revisions, images, documents) are referenced, not redefined. All content tables also get
Wagtail DraftStateMixin columns (live, has_unpublished_changes, first_published_at, …) —
shown once here, implied elsewhere.
Conventions: PK = bigint identity primary key (implied on every table). FKs are bigint with
ON DELETE noted. ❖ = indexed beyond the automatic FK/unique indexes.
ER overview¶
erDiagram
THEME ||--o{ PASSAGE : "primary_theme"
THEME ||--o{ WRITING : "primary_theme"
THEME ||--o{ JOURNEY : "primary_theme"
BOOK ||--o{ WRITING : "source"
BOOK ||--o{ PASSAGE : "source"
WRITING ||--o{ PASSAGE : "source_writing"
JOURNEY ||--o{ JOURNEY_CHAPTER : "has ordered"
JOURNEY_CHAPTER }o--|| PASSAGE : "XOR ref"
JOURNEY_CHAPTER }o--|| WRITING : "XOR ref"
JOURNEY_CHAPTER }o--|| ARTICLE : "XOR ref"
JOURNEY ||--o{ RELATED_JOURNEY : "none or 2-4 ordered"
HOME ||--o{ HOME_JOURNEY_PIN : "≤5"
HOME ||--o{ HOME_WHATS_NEW_PIN : ""
HOME }o--|| THEME : "theme_of_week"
USER ||--o{ JOURNEY_PROGRESS : ""
USER ||--o{ CHAPTER_COMPLETION : ""
USER ||--o{ BOOKMARK : ""
USER ||--o{ REFLECTION : ""
JOURNEY ||--o{ JOURNEY_PROGRESS : ""
JOURNEY_CHAPTER ||--o{ CHAPTER_COMPLETION : ""
Taxonomy¶
data_theme¶
| Column | Type | Constraints / notes |
|---|---|---|
| name | varchar(200) | unique |
| framing_line | varchar(255) | blank — default display copy above the theme name |
| colour | varchar(7) | HEX, validated #RRGGBB |
| icon | FK → wagtailimages_image | SET_NULL, null — SVG |
| background | FK → wagtailimages_image | SET_NULL, null — main background SVG |
| home_background | FK → wagtailimages_image | SET_NULL, null — Home blurb, SVG or raster |
| lottie | FK → wagtaildocs_document | SET_NULL, null — Lottie JSON |
| design_extra | jsonb | default {} — future scalar FE knobs, merged into the API design object |
| description | richtext | blank |
The API projects colour/icon/background/home_background/lottie + design_extra as one
design JSON object; assets stay individual FK columns so the CMS captures and validates each
one properly (choosers, hex validation, referential integrity).
data_tagcategory and data_tag are unchanged from the current build.
Content (evergreen + editorial)¶
Shared columns on all four: review_status varchar(16) (ai_draft | reviewed, default
ai_draft) ❖, is_ai_generated boolean (CMS-only, permanent), DraftState/Revision columns.
How primary theme is tracked. Each content model (and Journey) has a
themes M2M for the full, unordered set (powers the graph/discovery) plus a scalar
primary_theme_id FK column naming the one theme that drives visuals (reading-screen colour,
card tint, theme assets). Primary is a dedicated column, not a flag on the M2M rows — the
editor picks it in the CMS. The intended rule is primary_theme ∈ themes (it shouldn't point
outside the set), though that is not yet enforced in clean(). The API returns primary_theme as a scalar theme_id (nullable — the FE
resolves it against its client-cached theme set and falls back to the active theme when null); the
theme object is never re-embedded per card. (Alternative
considered: an is_primary boolean on a custom M2M through-table with a partial-unique
"one-per-content" — rejected as heavier given the existing ParentalManyToManyField themes.)
data_passage¶
| Column | Type | Constraints / notes |
|---|---|---|
| title | varchar(255) | |
| slug | varchar(255) | unique — stable identifier for API payloads and importer |
| body | text | RichTextField (rich text; restricted marks, served as Markdown) |
| interpretation | richtext | blank |
| reflection_prompt | text | blank |
| audio | FK → wagtaildocs_document | SET_NULL, null |
| source | FK → data_book | SET_NULL, null |
| source_writing | FK → data_writing | SET_NULL, null |
| primary_theme | FK → data_theme | SET_NULL, null |
CHECK at most one of (source, source_writing) |
(No is_new_eligible — Passage owns the hero and isn't part of What's New.)
M2M joins: data_passage_themes, data_passage_tags. Intended app-level rule primary_theme ∈ themes — not yet enforced (Passage.clean() validates only the source/source_writing XOR).
data_writing¶
| Column | Type | Constraints / notes |
|---|---|---|
| kind | varchar(12) | CHECK kind IN ('letter','essay','chapter') ❖ |
| title | varchar(255) | |
| slug | varchar(255) | unique — stable identifier for API payloads and importer |
| body | richtext | |
| source | FK → data_book | PROTECT, null — single source per ADR-0006 |
| recipient | varchar(255) | blank — required for letter (validation) |
| original_date | date | null — letter/essay |
| order | smallint | null — position within the source Work; required for chapter (validation) |
| reflection_prompt | text | blank |
| audio | FK → wagtaildocs_document | SET_NULL, null — MVP populates Passage audio only |
| is_new_eligible | boolean | default false — not indexed yet (see optimization notes) |
| primary_theme | FK → data_theme | SET_NULL, null — mirrors Passage |
M2M joins: data_writing_themes, data_writing_tags.
data_book¶
| Column | Type | Constraints / notes |
|---|---|---|
| title | varchar(255) | |
| slug | varchar(255) | unique — stable identifier for API payloads and importer |
| cover | FK → wagtailimages_image | SET_NULL, null |
| publication_year | smallint | null |
| synopsis | text | blank |
| primary_theme | FK → data_theme | PROTECT, null |
M2M joins: data_book_themes, data_book_tags. (Future: buy_link.)
data_article¶
| Column | Type | Constraints / notes |
|---|---|---|
| title / subtitle | varchar(255) | subtitle blank |
| slug | varchar(255) | unique |
| author | varchar(255) | contributor name; becomes an FK if authors get pages later |
| featured_image | FK → wagtailimages_image | SET_NULL, null |
| publication_date | date | null |
| body | richtext | |
| primary_theme | FK → data_theme | SET_NULL, null |
| is_new_eligible | boolean | default false — Article + Writing feed What's New; not indexed yet (see optimization notes) |
Curation¶
data_journey¶
| Column | Type | Constraints / notes |
|---|---|---|
| name | varchar(255) | |
| slug | varchar(255) | unique |
| central_question | text | blank — the question the journey exists to answer |
| editorial_framing | text | the card pitch; expands behind "see more" — a paragraph, not one line |
| colour | varchar(7) | HEX, blank until design assigns one |
| emblem | FK → wagtailimages_image | SET_NULL, null — one image: the journey-card artwork and the collectible. Replaces Draft 1's icon |
| emblem_title | varchar(255) | blank — e.g. "Journey walked" |
| emblem_line | text | blank — why this emblem, for this journey |
| primary_theme | FK → data_theme | SET_NULL, null |
| ingest_meta | jsonb | default {} — importer provenance; never served |
M2M join: data_journey_themes. DraftState/Revision columns (publishable) — a Journey is the
single publishable unit; its chapters are captured in the Journey's revision.
The emblem is one image, not two. It is the journey-card artwork, and it is the collectible the reader earns. The FE blurs it and lifts the blur as chapters complete (the "journey becomes a little clearer" beat), revealing it fully at the end. The backend stores the finished artwork once and serves no reveal states; progressive disclosure is a front-end effect driven by the reader's completion count.
data_related_journey¶
| Column | Type | Constraints / notes |
|---|---|---|
| from_journey | FK → data_journey | CASCADE |
| to_journey | FK → data_journey | CASCADE |
| sort_order | smallint | unique (from_journey, to_journey); none, or 2–4 — enforced at publish, not on save. Zero is what lets the first journeys publish, so the rule is never a dead end; exactly one is refused. Ordering falls back to pk when sort_order ties or is null |
data_journey_chapter¶
| Column | Type | Constraints / notes |
|---|---|---|
| journey | FK → data_journey | CASCADE |
| sort_order | smallint | unique (journey, sort_order) ❖ |
| slug | varchar(255) | unique — the importer's idempotency key |
| title | varchar(255) | editorial title; need not match the piece's own |
| passage | FK → data_passage | PROTECT, null |
| writing | FK → data_writing | PROTECT, null |
| article | FK → data_article | PROTECT, null |
| opening_question | text | |
| intro_pages | jsonb | StreamField — ordered rich-text pages the reader moves through before the piece; blank, no cap |
| pause_line | text | blank — the interstitial beat after the meaning |
| practice | text | blank — "A small practice" the reader commits to |
| reveal_line | text | blank — shown with the emblem reveal at chapter end |
CHECK exactly one of (passage, writing, article) — collectively NOT NULL |
No taxonomy columns and no M2M join: a chapter is its piece, so its themes and primary theme are the piece's, read through at query time. Tagging the chapter as well would be the same answer entered twice, free to drift. The Journey carries the taxonomy for the course as a whole.
The closing reflection question is likewise the piece's own reflection_prompt — not duplicated
here. Chapters have no independent publish state; they live and die with the Journey revision.
Featured lists — data_featured_journeys · data_featured_themes · data_featured_books¶
Same shape ×3:
| Column | Type | Constraints / notes |
|---|---|---|
| journey / theme / book | FK | CASCADE, unique — an entity appears once per list |
| sort_order | smallint | unique per list |
data_home (singleton)¶
| Column | Type | Constraints / notes |
|---|---|---|
| theme_of_week | FK → data_theme | SET_NULL, null |
| framing_line_override | varchar(255) | blank — wins over the theme's default when set |
data_home_journey_pin¶
| Column | Type | Constraints / notes |
|---|---|---|
| home | FK → data_home | CASCADE |
| journey | FK → data_journey | CASCADE, unique with home |
| sort_order | smallint | unique (home, sort_order); ≤5 enforced in validation |
data_home_whats_new_pin¶
| Column | Type | Constraints / notes |
|---|---|---|
| home | FK → data_home | CASCADE |
| article / writing | FKs | CASCADE, CHECK exactly one set (Passage excluded — hero-only) |
| sort_order | smallint | unique (home, sort_order) |
User data¶
All FKs to data_user are CASCADE (account deletion is soft-delete at the app layer; hard
cleanup cascades).
data_user gains one column (the account model is owned by CSL-30/auth, listed here for
completeness): preferred_theme_id FK → data_theme, nullable, on_delete=SET_NULL — the user's
chosen active theme, null = fall back to Theme-of-the-Week. Read via GET /api/account/, set
via PATCH /api/account/.
data_journey_progress¶
| Column | Type | Constraints / notes |
|---|---|---|
| user | FK → data_user | unique with journey ❖ |
| journey | FK → data_journey | PROTECT |
| current_chapter | FK → data_journey_chapter | SET_NULL, null |
| status | varchar(12) | active | completed |
| started_at / completed_at | timestamptz | completed_at null until finished |
data_chapter_completion¶
| Column | Type | Constraints / notes |
|---|---|---|
| user | FK → data_user | |
| chapter | FK → data_journey_chapter | PROTECT; unique (user, chapter) |
| completed_at | timestamptz |
There is no per-piece completion table: a chapter is one piece, so chapter completion is the only granularity. Position within a chapter's arc is presentation state, not a row per screen.
data_bookmark¶
| Column | Type | Constraints / notes |
|---|---|---|
| user | FK → data_user | |
| passage / writing / article / journey / journey_chapter | FKs | CASCADE, all null |
| created_at | timestamptz | |
CHECK exactly one target set · partial unique (user, target) per FK |
data_reflection¶
| Column | Type | Constraints / notes |
|---|---|---|
| user | FK → data_user | ❖ (user, created_at) |
| prompt_snapshot | text | what the user actually saw (the piece's reflection_prompt at save time) |
| response | text | |
| chapter | FK → data_journey_chapter | SET_NULL, null |
| passage / writing | FKs | SET_NULL, null — at most one set |
| created_at | timestamptz |
Optimization notes (pre-implementation review)¶
- Home is per-section endpoints, not one aggregate query — hero + Further Reading random
pools are
ORDER BY random()over small tables at launch corpus size (fine now; swap toTABLESAMPLEpast ~10k rows); every other section is a PK/list lookup. Splitting means each section caches independently (Cache-Controlper endpoint) and no request waits on the slowest. - What's New — the auto-fill query (
filter(live=True, is_new_eligible=True).order_by( first_published_at desc)over Article + Writing) would benefit from a partial index on (is_new_eligible,live) +first_published_at DESC. Not yet created (migration0009addsis_new_eligibleas a plain boolean) — deferred while the corpus is small; add the composite partial index when volume warrants. - Journey landing + progress joins
data_journey_chapter→ completions per user: the unique (user, chapter) index doubles as the lookup index. - XOR refs (chapters, pins, bookmarks, reflections) are nullable FKs + CHECK, chosen
over GenericForeignKey so
select_related/prefetch_relatedwork and FK integrity is real. PROTECTon content FKs from curation (chapters, progress) — an editor cannot delete a piece that a Journey references; they must unlink it first. Deliberate friction.- The display ordinal is computed, never stored. "Chapter N" is derived at read time over the
journey's authored chapters — like read time, computed so it can't drift.
sort_orderis nullable and can tie, so it orders the list but never numbers it. A live journey cannot lose a chapter (the CMS blocks unpublishing a piece it uses), so the numbering is stable for a reader whose progress is saved against particular chapters.