The one map of the PostgreSQL schema: every table, what it is for, and what happens to it next.
Column-level truth: database/migrations/ — the baseline plus every
numbered migration after it (ADR-026). Audited 2026-10-26 for the
database cleanup (#926).
Update this page in the same PR as any schema change, in either database. A PR that adds, drops
or reshapes a table changes its row here: the V2 table below for textstack_v2, the inventory for the
legacy database.
A second database (ADR-030), created and migrated by
migrator-v2 (database/v2/). 24 permanent tables + 2 transitional, plus the ETL’s ledger migration_runs (0003/0004, dropped at M6.5);
column truth: database/v2/migrations/0000_baseline.up.sql
plus the migrations after it, read in order (0001_sync_protocol_deltas: ADR-031 “Amended 2026-11-01”;
0002_identity_and_indexes: “Amended 2026-11-02”; 0003_migration_runs + 0004_migration_runs_fixes: the etl-v2 run ledger, v2-plan §5.2; 0005_book_formats: “Amended 2026-11-07”).
What happens to each legacy table (the disposition column) lives in ADR-031, not here. The legacy
inventory below stays until M6, when this page becomes V2 only.
Module
Table
A row is
Origin
Identity
users
an account: reader, guest or staff (role)
users + admin_users
user_refresh_tokens
a session
user_refresh_tokens + admin_refresh_tokens
password_reset_tokens
a reset code
same
oauth_clients
a registered MCP client
same
oauth_authorization_requests
a pending OAuth authorization
same (+ user_id FK)
oauth_grants
an MCP OAuth token pair
same
device_authorizations
a device-flow login attempt
same
mcp_access_keys
a tsk_… connect key
same
Library
books
a library entry
user_books (same id) + catalog user_libraries pairs (no file)
book_files
an exact file version
user_book_files (same id)
Reading
reading_state
the accepted position + frontier for one book
user_books.progress_*
reading_sessions
a reading interval
reading_sessions
Annotations
annotations
a highlight + note, or an unanchored quotation
highlights
Vocabulary
vocabulary_words
one card per owner/language/word_key
vocabulary_words
vocabulary_examples
a source sentence for a card
vocabulary_words.sentence / book_title
vocabulary_reviews
one answer
vocabulary_reviews
Discussion
shared_chapters
an explicit chapter snapshot for MCP
new
discussion_checkpoints
one saved stage of a discussion
new
book_insight
an assistant conclusion or chapter review
book_insight (one book_id)
review_question
a self-check question
review_question
Ops
llm_traces
one LLM call
same
agent_run
one agent run
same (− edition_id)
eval_runs
one eval score
same
admin_settings
one runtime knob
same
Transitional
book_chapters
a server-extracted chapter of one file version
user_chapters — dropped at M6.4
ingestion_jobs
an extraction job for one uploaded file
user_ingestion_jobs — dropped at M6.4
Numbers (2026-10-26)
Tables
58 (57 DbSets + the edition_genres join); notes dropped by 0005, ssg_rebuild_results by 0006, shadow_runs by 0007, word_frequencies by 0009, lint_results by 0010
Migrations
SQL since 2026-10-26: 0000_baseline stands for the 147 EF Core migrations production had applied (20251217180704_Initial_WorkEdition_Admin → 20261024202404_TranslateRegistryOnGpt41Mini), which were then deleted. The 148th, 20260219000000_AddSearchPerformanceIndexes, had no .Designer.cs and never ran; its indexes turned out unusable by search, closed not-planned (#948)
23 tables carry site_id + an EF query filter (single site forever, ADR-007)
Objects outside the EF model
extensions pg_trgm, vector; get_fts_config() + chapters search-vector trigger; generated user_chapters.search_vector — in the baseline like everything else. The GiST trigram index on lower(editions.title) was dropped by 0002 (no query used it, #948)
content_quality_score read only by infra/scripts/quality-poll.sh (kept); (user_book_id) index dropped (0002)
2,710
35
user_book_files
Original upload file
Clean
(sha256) index dropped (0002; column is the ETag — kept)
96
36
user_ingestion_jobs
Upload extraction queue
Clean
units_count dropped (0011, #937)
100
37
user_book_bookmarks
Bookmarks in uploads
Decide
Same shape as bookmarks
18
38
user_libraries
Catalog books a user saved
Decide
A personal book row could be the library entry
246
39
collections
Personal shelves
Clean
(user_id) index dropped (0002)
1
40
book_collections
Book on a shelf
Clean
book_id points at two tables (book_type, CHECKed by 0012), no FK; orphans already repaired once
3
Reading — position, annotations, activity
#
Table
Purpose
Status
Notes
Rows
41
reading_progresses
Position in a catalog book
Decide
Uploads keep progress on user_books instead; legacy locator (scroll:<slug>:<px>) next to position_json
435
42
bookmarks
Bookmarks in catalog books
Decide
No unique key; twin of user_book_bookmarks
9
43
highlights
Highlights + their note text, catalog or upload
Clean
At most one of edition / upload (ck_highlights_owner, 0012, #939), its chapter id of the same kind (ck_highlights_chapter_owner, 0013, #989) and of the same book (composite FKs, 0016, #998); none after its book is deleted (SET NULL), still shown in All highlights as “<book_title> (deleted)” (0014, #982); re-attached when the same file is uploaded again (deleted_book_sha256, deleted_chapter_slug, 0015, #983)
39
44
notes
Standalone notes
Dropped
Dropped by 0005 (#932): no writer ever; highlights.note_text holds notes
—
45
reading_sessions
Reading time for stats and streaks
Clean
At most one of edition / upload (ck_reading_sessions_owner, 0012, #939); none after its book is deleted (SET NULL), still counted in stats; re-attached when the same file is uploaded again (deleted_book_sha256, 0015, #983)
Two parallel reading models exist side by side: catalog books (editions → reading_progresses,
bookmarks) and uploads (user_books with progress columns, user_book_bookmarks). highlights
and reading_sessions serve both through edition-or-upload keys, at most one set (0012); a deleted book leaves both NULL.
Traces carry no user id; their text goes after 30 d. Agent runs are deleted with the account. (shadow_runs held the same, was never written; dropped by 0007)
migration_runs (V2)
counts + validation report of an etl-v2 run: may name legacy row ids and user ids (orphan candidates, errors); the etl-v2 CLI must store errors as exception type + SQLSTATE only (the full message, which can quote a row’s values, goes to the operator’s console, never to the ledger; pinned by the M3.1 tests), so no email or content
No user_id, so an account delete does not reach it; kept only until M6.5, which drops the table
Guest users: users.is_guest = true. GuestCleanupWorker (every 2 h) deletes guests inactive
30 days that hold nothing durable. Account deletion: DELETE /me/account (AccountEndpoints.cs)
deletes the user’s agent runs, device codes and pending OAuth requests, then the users row, relying on FK cascades for the rest. Retention: RetentionCleanupWorker (daily).
Enums stored as integers
Raw SQL and infra/scripts hard-code these numbers — changing or reordering a value is a data
migration. Only 6 enums are stored as text (the house style for new enum columns): UserTier,
AuthorRole, AssetKind, SsgRebuildJobStatus, ModelStatus, PromotionAction. Everything below is an integer column.
EditionStatus { Draft=0, Published=1, Hidden=2 }
BookFormat { Epub=0, Pdf=1, Fb2=2/* legacy: one row still references it */, Html=3, Other=99 }
EF Core migrations, applied only by the migrator container
(ADR-021); a schema behind the Api’s build shows
as /health/ready 503. Being replaced by plain SQL up/down files under database/ — see #926.