Skip to content

Data model

Every table this project owns, what it holds, how it links, and what the database refuses to store. The tables below are generated from the models, so this page cannot fall behind a migration.

Where the rest lives

Columns, types and help text are in src/data/models/, one module per area, and each model carries a docstring explaining itself. The reasoning behind the shape is in the ADRs. The request path that reads these tables is in Architecture, and the payloads the app receives are in the API reference.

Regenerate this page with make data-model-doc after any model change. A test fails the suite if you forget.

How to read it

One app owns every table. They all live in src/data/, and nothing outside it defines a model. Reads go through src/readers/, writes through src/actions/. Framework tables that Wagtail, Django auth and django-taggit create are not listed here.

Publishable means Wagtail draft and live. A publishable model has a draft version and a live version, and readers only ever see live. One exception, on purpose: a piece unpublished after a reader saved it stays in that reader's bookmark list with is_available: false, so the save does not look lost. They get the card, never the body. A journey chapter is not publishable on its own: it is a child row captured inside the journey's revision, so publishing the journey publishes its chapters with it.

A row that points at "a piece" carries one nullable foreign key per piece type, plus a check constraint on how many may be set. Bookmarks and audio take exactly one. A reflection takes at most one, because its link is cleared when the piece is deleted and the reflection stays. A single content_type column would have been shorter but would lose the database-level integrity this keeps (ADR-0008).

A journey chapter owns its passage. Passage.journey_only marks a passage made for a chapter. A chapter accepts only those, and a unique constraint keeps each one with a single chapter. The discovery queries filter on the flag. Writings and articles stay shareable (ADR-0015).

Deleting follows three rules. A chapter PROTECTs the piece it uses, so an editor cannot delete a passage that a journey is built on. Everything a reader owns CASCADEs from the reader, so deleting an account takes the reader's data with it. A reflection's link to its piece is SET_NULL, because what the reader wrote outlives the piece it answered.

Some absences are deliberate. There is no current_chapter column: the next chapter is derived from what the reader has completed (ADR-0011). There is no audio staleness flag: a row exists only for approved audio, and staleness is a hash comparison at read time. There is no mid-chapter cursor: position inside a chapter stays on the device.

Every publishable model also carries latest_revision, live_revision and locked_by from Wagtail. They are the same on every such model, so they are left out of the tables below.

Join tables are drawn, not listed. A many-to-many field creates its own table, for example themes on a passage. The tables below list declared models only, so the link shows up as an edge in the diagram rather than as a row of its own.

Taxonomy

The vocabulary every other table points at. Themes are the worlds the app skins itself to and are loaded by sync_themes; tags are editor-made and grouped into categories.

Table Model Kind What it holds
data_tag Tag Table A value within a Tag Category.
data_tagcategory TagCategory Table An editor-created container for tags, e.g. 'Emotional Register', 'Depth'.
data_theme Theme Table A world a user enters (not a tag).
erDiagram
    data_tagcategory ||--o{ data_tag : "category"

What the database refuses to store

Table Rule
data_tag unique on name, category
data_tagcategory name is unique
data_theme slug is unique
data_theme name is unique

Content

The four readable pieces. A Passage is a quote, a Writing is a letter, essay or book chapter, an Article is editorial by a named contributor, and a Book is the physical work a Writing came out of.

Table Model Kind What it holds
data_article Article Publishable Editorial (non-canon) writing by a named contributor — distinct from Lewis's corpus (Passage/Writing) and its source Works (Book).
data_book Book Publishable The Work: reference/purchase object — not readable in-app (discovery + buy handoff).
data_passage Passage Publishable The atom: a standalone, consumable Lewis passage.
data_passage_related RelatedPassage Child of data_passage One editor-curated related quote on a Passage, in drag order.
data_writing Writing Publishable A full readable Lewis unit — letter, essay, or book chapter — discriminated by kind (ADR-0006: one model, not three).
erDiagram
    users |o--o{ data_article : "author"
    data_theme |o--o{ data_article : "primary_theme"
    data_theme }o--o{ data_article : "themes"
    data_tag }o--o{ data_article : "tags"
    data_theme }o--o{ data_book : "themes"
    data_tag }o--o{ data_book : "tags"
    data_book |o--o{ data_passage : "source"
    data_writing |o--o{ data_passage : "source_writing"
    data_theme |o--o{ data_passage : "primary_theme"
    data_theme }o--o{ data_passage : "themes"
    data_tag }o--o{ data_passage : "tags"
    data_passage ||--o{ data_passage_related : "passage"
    data_passage ||--o{ data_passage_related : "related"
    data_book |o--o{ data_writing : "source"
    data_theme |o--o{ data_writing : "primary_theme"
    data_theme }o--o{ data_writing : "themes"
    data_tag }o--o{ data_writing : "tags"

What the database refuses to store

Table Rule
data_article slug is unique
data_article source_url is unique
data_book slug is unique
data_passage slug is unique
data_writing slug is unique

Curation

What an editor arranges: journeys, their chapters, and the Home screen. Every row here points at content rather than holding any.

Table Model Kind What it holds
data_home Home Singleton Singleton settings record for the Home page — one row, one CMS screen.
data_home_featured_book HomeFeaturedBook Child of data_home One Book featured on Home, in editor drag order.
data_home_featured_journey HomeFeaturedJourney Child of data_home One Journey featured on Home, in editor drag order.
data_home_featured_passage HomeFeaturedPassage Child of data_home One Passage in the Home hero queue, in editor drag order.
data_journey Journey Publishable A guided course through Lewis's work: an editor-curated, ordered set of chapters built around one central question, so a reader meets the pieces in a considered order instead of searching for them.
data_journey_chapter JourneyChapter Child of data_journey One chapter of a Journey.
data_related_journey RelatedJourney Child of data_journey One editor-curated 'try this next' journey, in drag order — surfaces on the finished-state landing only.
erDiagram
    data_theme |o--o{ data_home : "featured_theme"
    data_home ||--o{ data_home_featured_book : "home"
    data_book ||--o{ data_home_featured_book : "book"
    data_home ||--o{ data_home_featured_journey : "home"
    data_journey ||--o{ data_home_featured_journey : "journey"
    data_home ||--o{ data_home_featured_passage : "home"
    data_passage ||--o{ data_home_featured_passage : "passage"
    data_theme |o--o{ data_journey : "primary_theme"
    data_theme }o--o{ data_journey : "themes"
    data_journey ||--o{ data_journey_chapter : "journey"
    data_passage |o--o{ data_journey_chapter : "passage"
    data_writing |o--o{ data_journey_chapter : "writing"
    data_article |o--o{ data_journey_chapter : "article"
    data_journey ||--o{ data_related_journey : "from_journey"
    data_journey ||--o{ data_related_journey : "to_journey"

What the database refuses to store

Table Rule
data_journey slug is unique
data_journey_chapter slug is unique
data_journey_chapter journey_chapter_exactly_one_piece: check
data_journey_chapter journey_chapter_unique_passage: unique on passage
data_related_journey related_journey_unique_pair: unique on from_journey, to_journey

Narration

Audio for one field of one piece. AudioAsset is what the app plays; AudioTake and AudioSegment are studio-only candidates the reader never sees.

Table Model Kind What it holds
data_audio_asset AudioAsset Table The approved narration of one CMS field — the row the app serves.
data_audio_segment AudioSegment Table One chunk of a long-form take (CSL-367).
data_audio_take AudioTake Table A candidate recording in the Audio Studio.
erDiagram
    data_passage |o--o{ data_audio_asset : "passage"
    data_writing |o--o{ data_audio_asset : "writing"
    data_article |o--o{ data_audio_asset : "article"
    data_journey_chapter |o--o{ data_audio_asset : "journey_chapter"
    data_journey |o--o{ data_audio_asset : "journey"
    users |o--o{ data_audio_asset : "approved_by"
    data_audio_take ||--o{ data_audio_segment : "take"
    data_passage |o--o{ data_audio_take : "passage"
    data_writing |o--o{ data_audio_take : "writing"
    data_article |o--o{ data_audio_take : "article"
    data_journey_chapter |o--o{ data_audio_take : "journey_chapter"
    data_journey |o--o{ data_audio_take : "journey"
    users |o--o{ data_audio_take : "created_by"

What the database refuses to store

Table Rule
data_audio_asset audio_asset_exactly_one_target: check
data_audio_asset audio_asset_unique_passage_section: unique on passage, field, block_id, when passage is set
data_audio_asset audio_asset_unique_writing_section: unique on writing, field, block_id, when writing is set
data_audio_asset audio_asset_unique_article_section: unique on article, field, block_id, when article is set
data_audio_asset audio_asset_unique_journey_chapter_section: unique on journey_chapter, field, block_id, when journey_chapter is set
data_audio_asset audio_asset_unique_journey_section: unique on journey, field, block_id, when journey is set
data_audio_segment audio_segment_unique_index: unique on take, index
data_audio_take audio_take_exactly_one_target: check

Reader data

Everything owned by a reader rather than an editor. None of it reaches the Wagtail CMS. Reflections, bookmarks, entitlements and the event log are visible read-only in the Django admin for support; users is the one table staff can change there. processor_deletions outlives its reader: it holds a deleted account's UID until RevenueCat confirms the delete.

Table Model Kind What it holds
data_bookmark Bookmark Table One piece, saved once by one reader.
data_journey_chapter_completion ChapterCompletion Table One chapter, finished once by one reader.
data_journey_progress JourneyProgress Table One reader's run at one journey.
data_reader_free_journey ReaderFreeJourney Table The journey a reader read first, which is theirs for good.
data_reflection Reflection Table One reader's answer to one piece, kept as written.
processor_deletions ProcessorDeletion Table ProcessorDeletion(id, processor, external_uid, attempts, last_error, created_at, updated_at)
reader_entitlements ReaderEntitlement Table One reader's grant of one RevenueCat entitlement, mirrored verbatim.
subscription_charges SubscriptionCharge Table One store charge, from a RevenueCat purchase or renewal webhook (CSL-383/425).
subscription_events SubscriptionEvent Table The RevenueCat webhook ledger: idempotency, audit, and support inspection (CSL-355 AC5).
subscription_notices SubscriptionNotice Table One compliance email to a subscriber, and the record that it went (CSL-383/425).
users User Table Email-keyed user for Firebase auth (ADR-0003).
erDiagram
    users ||--o{ data_bookmark : "user"
    data_passage |o--o{ data_bookmark : "passage"
    data_writing |o--o{ data_bookmark : "writing"
    data_article |o--o{ data_bookmark : "article"
    users ||--o{ data_journey_chapter_completion : "user"
    data_journey_chapter ||--o{ data_journey_chapter_completion : "chapter"
    users ||--o{ data_journey_progress : "user"
    data_journey ||--o{ data_journey_progress : "journey"
    users ||--o{ data_reader_free_journey : "user"
    data_journey ||--o{ data_reader_free_journey : "journey"
    users ||--o{ data_reflection : "user"
    data_passage |o--o{ data_reflection : "passage"
    data_writing |o--o{ data_reflection : "writing"
    data_article |o--o{ data_reflection : "article"
    users ||--o{ reader_entitlements : "user"
    users |o--o{ subscription_charges : "user"
    users |o--o{ subscription_notices : "user"
    subscription_charges |o--o{ subscription_notices : "charge"

What the database refuses to store

Table Rule
data_bookmark bookmark_exactly_one_target: check
data_bookmark bookmark_unique_reader_passage: unique on user, passage, when passage is set
data_bookmark bookmark_unique_reader_writing: unique on user, writing, when writing is set
data_bookmark bookmark_unique_reader_article: unique on user, article, when article is set
data_journey_chapter_completion chapter_completion_unique_reader: unique on user, chapter
data_journey_progress journey_progress_unique_reader: unique on user, journey
data_journey_progress journey_progress_claim_needs_completion: check: emblem_claimed_at is empty or completed_at is set
data_reader_free_journey reader_free_journey_one_per_reader: unique on user
data_reflection reflection_at_most_one_target: check
data_reflection reflection_unique_reader_passage: unique on user, passage, when passage is set
data_reflection reflection_unique_reader_writing: unique on user, writing, when writing is set
data_reflection reflection_unique_reader_article: unique on user, article, when article is set
processor_deletions unique_processor_deletion: unique on processor, external_uid
reader_entitlements unique_reader_entitlement: unique on user, entitlement_key
subscription_charges transaction_id is unique
subscription_events rc_event_id is unique
subscription_notices dedupe_key is unique
users email is unique
users external_uid is unique

Support pages

Editorial copy for the standalone legal and support pages.

Table Model Kind What it holds
data_supportinfosettings SupportInfoSettings Singleton Editorial copy for the standalone Legal and Support pages (CSL-282, CSL-334): About, Privacy Policy, Terms of Service, and Support.

Paywall

Editorial copy for the Journey Premium paywall — headline, benefit lines, and the renewal-reminder window, the one number this and the CSL-383 reminder job share.

Table Model Kind What it holds
data_paywallsettings PaywallSettings Singleton Editorial copy for the Journey Premium paywall.