Data model reference¶
This is the reference for TomeTrove's data model: the tables, fields, types, and constraints. It is a reference, not a schema definition — the actual schema is defined in migrations (ADR 0009) and may differ in implementation details. For the design rationale and data-flow descriptions, see the data model explanation.
User¶
Table: user
| Field | Type | Notes |
|---|---|---|
| user_id | PK | |
| user_github_id | string | From the Access JWT |
| user_last_login_at | DATETIME | UTC (ADR 0020). Updated on login |
| user_created_at | DATETIME | UTC (ADR 0020). Set once |
User preferences¶
Table: user_preference
| Field | Type | Notes |
|---|---|---|
| user_id | FK → User | |
| user_format_preference | json | Ordered list of accepted formats, best-to-worst (e.g. ["used", "new", "ebook"]). Formats not listed are excluded. |
| user_next_fetch_hour | int (0-23) | Hour at which monitored books are fetched daily (ADR 0014). Assigned round-robin when user elects first monitored book. |
| user_alert_threshold_percentage | int (0-100) | Minimum percentage drop below baseline to trigger external notification (ADR 0014). Default 5. Example: 10 means alert when price drops 10% below baseline. |
| user_currency | string | ISO 4217 code (e.g. EUR). Chosen by the user on first login from the union of all store_currencies. Price quotes are filtered to this currency. |
| user_country | string | ISO 3166-1 alpha-2 country code (e.g. IT). Chosen by the user on first login. Determines which stores ship to the user; drives the user_store junction. |
User stores¶
Pre-computed junction of which stores are applicable for a user. Built by intersecting the user's preferences (currency, country, format) against store capabilities (store_currencies, store_ships_to, store_sells_used, store_sells_ebooks). Rebuilt for a single user whenever their user_preference row changes; rebuilt for all users when a store row is added or modified. The per-edition language filter (store_languages vs the edition's language) is still applied at fetch time — this table captures only the user-level criteria.
Table: user_store
| Field | Type | Notes |
|---|---|---|
| user_id | FK → User | |
| store_id | FK → Store |
Primary key: (user_id, store_id).
User languages¶
One row per (user, language). user_language_allowed_type_id lists the Types the user can read in this language (null or all 9 = all Types). Exactly one row per user has user_language_preferred = true.
Table: user_language
| Field | Type | Notes |
|---|---|---|
| user_id | FK → User | |
| language_id | FK → Language | |
| user_language_preferred | boolean | True for the user's primary reading language. Only one row per user. |
| user_language_allowed_type_id | json | Array of Type IDs the user can read in this language. Null = all Types. Example: [3, 5] |
Book¶
Table: book
| Field | Type | Notes |
|---|---|---|
| book_id | PK | |
| book_original_title | string | Title in the original language |
| book_original_language_id | FK → Language | |
| book_type_id | FK → Type | The editorial Type (one of 9 — see ontology and ADR 0018). Required. |
| book_genre_id | FK → Genre | The Genre / discipline. Required. The (book_type_id, book_genre_id) pair must be valid (exists in type_genre). Provided by the metadata source (ADR 0004), normalization pipeline (ADR 0016), or OpenLibrary mapping. |
| book_openlibrary_id | string (null) | OpenLibrary work ID (e.g. OL27448W). Populated when the book is imported from OpenLibrary. |
| book_googlebooks_id | string (null) | Google Books volume ID (e.g. zyTCAlFjzC). Populated when the book is imported from Google Books. |
The book's additional classification levels (beyond Type and Genre) are stored as tags in the book_ontology junction — see Classification ontology below.
Book ↔ Author / Curator¶
Table: book_author
| Field | Type | Notes |
|---|---|---|
| book_id | FK → Book | |
| author_id | FK → Author | |
| book_author_role | enum | author or curator |
| book_author_position | int | Order within the role (first author is position 0) |
Constraint: a book must have at least one row with book_author_role = author OR at least one row with book_author_role = curator.
Edition¶
Table: edition
| Field | Type | Notes |
|---|---|---|
| edition_id | PK | |
| book_id | FK → Book | |
| language_id | FK → Language | The language of this edition |
| publishing_house_id | FK → Publishing House | |
| edition_isbn | string (null) | ISBN-10 or ISBN-13 |
| edition_title | string | Title in this edition's language (may differ from original) |
| edition_published_year | YEAR (null) | Year of publication. Used for edition recency filtering (most recent edition per language). |
| edition_url | string (null) | URL to the edition (e.g. on the publisher's site or a store). The store page shows the cover — no cover image is stored or referenced (ADR 0005). |
| edition_openlibrary_id | string (null) | OpenLibrary edition ID (e.g. OL3404981M). Populated when imported from OpenLibrary. |
| edition_googlebooks_id | string (null) | Google Books volume ID (e.g. zyTCAlFjzC). Populated when imported from Google Books. |
Filtering rule: for each book, list the most recent edition per language where: (a) the language is the book's original language, OR (b) the language is in the user's readable languages matrix AND the matrix allows it for the book's Type (ADR 0018). If multiple editions exist in the same language, show only the most recent (by edition_published_year).
Edition TOC¶
Table: edition_toc
| Field | Type | Notes |
|---|---|---|
| edition_toc_id | PK | |
| edition_id | FK → Edition | |
| author_id | FK → Author | The contributor (author or curator) |
| edition_toc_role | enum | author or curator |
| edition_toc_position | int | Order within the TOC (first entry is position 0) |
| edition_toc_title | string (null) | Title of the contribution in this edition's language (e.g. "The Yellow Wallpaper") |
Publishing house¶
Table: publishing_house
| Field | Type | Notes |
|---|---|---|
| publishing_house_id | PK | |
| publishing_house_name | string | Display name (e.g. "Einaudi", "Penguin") |
| publishing_house_url | string (null) | Publisher's website (e.g. "https://www.einaudi.it") |
Price quote¶
Table: price_quote
| Field | Type | Notes |
|---|---|---|
| price_quote_id | PK | |
| edition_id | FK → Edition | |
| user_id | FK → User | The user whose fetch triggered this quote (null for system/scheduled fetches) |
| store_id | FK → Store | |
| price_quote_datetime | DATETIME | UTC (ADR 0020). When the quote was fetched. Also used for the 1-hour cooldown on on-demand fetches. |
| price_quote_currency | string | ISO 4217 (e.g. EUR, USD) |
| price_quote_price | int | Price in cents (integer, avoids float precision issues) |
| price_quote_url | string | Direct link to the product page at this price |
| price_quote_is_used | boolean | Whether this is a used copy |
| price_quote_is_ebook | boolean | Whether this is an ebook edition |
| price_quote_is_baseline | boolean | Marks the baseline quote (first fetch when book is marked as monitored, ADR 0014) |
| price_quote_fetch_status | enum | success or error — if error, the cooldown is bypassed for retry |
A quote is per (edition, date, store) — one price per store per day per edition. On-demand fetches store only the latest price per (edition, store); if a fetch fails, the previous price is kept and the failure is recorded via price_quote_fetch_status. For monitored books, all price attempts are saved during the month and consolidated into price_quote_historic at month-end (min, max, mean). Raw price_quote rows for the consolidated month are deleted after consolidation. Baseline quotes (price_quote_is_baseline = true) are preserved — they are used for alert notifications, not for historic trends.
Price quote historic¶
Table: price_quote_historic
| Field | Type | Notes |
|---|---|---|
| price_quote_historic_id | PK | |
| edition_id | FK → Edition | |
| store_id | FK → Store | |
| price_quote_historic_month | DATE | First day of the month (e.g. 2026-01-01) |
| price_quote_historic_type | enum | min, max, mean |
| price_quote_historic_currency | string | ISO 4217 (e.g. EUR, USD) |
| price_quote_historic_price | int | Price in cents |
Unique constraint: (edition_id, store_id, price_quote_historic_month, price_quote_historic_type).
Wish¶
Table: wish
| Field | Type | Notes |
|---|---|---|
| wish_id | PK | |
| user_id | FK → User | |
| book_id | FK → Book | |
| wish_created_at | DATETIME | UTC (ADR 0020). When the user added the book to their wish list. |
| wish_is_monitored | boolean | If true, this book is included in scheduled price fetching. |
| wish_baseline_refreshed_at | DATETIME (null) | UTC (ADR 0020). When the baseline was last refreshed. Set at election time; refreshed by the month-end consolidation job after 12 months (ADR 0014). |
Constraint: maximum 5 rows per user_id where wish_is_monitored = true. The baseline price quote is fetched when a book is first marked as monitored.
Unique constraint: (user_id, book_id) — a book can appear only once per user's wish list.
List¶
Table: list
| Field | Type | Notes |
|---|---|---|
| list_id | PK | |
| user_id | FK → User | |
| list_name | string | User-facing label (e.g. "Birthday list", "Christmas list") |
| list_token | string | Random unguessable token (nanoid) — used in the share URL |
| list_filter_types | json | Array of Type IDs to include (null/empty = all Types) |
| list_filter_genres | json | Array of Genre IDs to include (null/empty = all genres) |
| list_filter_languages | json | Array of language IDs to include (null/empty = all languages) |
| list_expiration_date | DATE (null) | If set, the list is auto-deleted after this date. Checked on user access and during scheduled fetches. |
| list_created_at | DATETIME | UTC (ADR 0020) |
Unique constraints: (user_id, list_name) and list_token.
Classification ontology¶
The classification system is defined by ADR 0018 and documented in the ontology reference. Translations are stored in the translation table (ADR 0019).
Type¶
Table: type
| Field | Type | Notes |
|---|---|---|
| type_id | PK | |
| type_slug | string | Canonical English identifier (e.g. fiction) |
| type_name_en | string | English display name (e.g. Fiction) |
Unique constraint: type_slug.
Genre¶
Table: genre
| Field | Type | Notes |
|---|---|---|
| genre_id | PK | |
| genre_slug | string | Canonical English identifier (e.g. mystery) |
| genre_name_en | string | English display name (e.g. Mystery) |
Unique constraint: genre_slug.
type_genre¶
Table: type_genre
| Field | Type | Notes |
|---|---|---|
| type_id | FK → Type | |
| genre_id | FK → Genre |
Primary key: (type_id, genre_id).
Ontology¶
Table: ontology
| Field | Type | Notes |
|---|---|---|
| ontology_id | PK | |
| ontology_parent_id | FK → Ontology (null) | Null for root nodes scoped by (type_id, genre_id) |
| ontology_scope_type_id | FK → Type (null) | Required for root nodes; null for child nodes (inherits from parent) |
| ontology_scope_genre_id | FK → Genre (null) | Required for root nodes; null for child nodes (inherits from parent) |
| ontology_slug | string | Canonical English identifier |
| ontology_name_en | string | English display name |
| ontology_is_open | boolean | True = children created on demand by the classifier. Default false. |
| ontology_is_placeholder | boolean | True for dynamic-modifier slot nodes (elided in display) |
Example — Essay/Music and Essay/Anthropology subtrees:
| ontology_id | ontology_parent_id | ontology_scope_type_id | ontology_scope_genre_id | ontology_slug | ontology_name_en | ontology_is_open | ontology_is_placeholder |
|---|---|---|---|---|---|---|---|
| 100 | null | 5 (Essay) | 14 (Music) | classical | Classical | false | false |
| 101 | null | 5 | 14 | critique | Critique | false | false |
| 102 | null | 5 | 14 | electronic | Electronic | false | false |
| 103 | null | 5 | 14 | history | History | false | false |
| 104 | 103 | null | null | country | true | true | |
| 105 | null | 5 | 14 | instrument | true | true | |
| 106 | null | 5 | 14 | jazz | Jazz | false | false |
| 107 | null | 5 | 14 | monograph | Monograph | false | false |
| 108 | null | 5 | 14 | opera | Opera | false | false |
| 109 | null | 5 | 14 | pop | Pop | false | false |
| 110 | null | 5 | 14 | rock | Rock | false | false |
| 111 | null | 5 | 14 | theory | Theory | false | false |
| 120 | null | 5 | 15 (Anthropology) | critique | Critique | false | false |
| 121 | null | 5 | 15 | history | History | false | false |
| 122 | 121 | null | null | country | true | true | |
| 123 | null | 5 | 15 | monograph | Monograph | false | false |
| 124 | null | 5 | 15 | theory | Theory | false | false |
Example classification — Essay/Music/History/Italy:
| book_id | ontology_id | book_ontology_value | book_ontology_position |
|---|---|---|---|
| 1 | 103 (History) | null | 0 |
| 104 ( |
null | "Italy" | 1 |
Example classification — Essay/Music/<Instrument> (e.g. Piano):
| book_id | ontology_id | book_ontology_value | book_ontology_position |
|---|---|---|---|
| 2 | 105 ( |
"Piano" | 0 |
book_ontology¶
Table: book_ontology
| Field | Type | Notes |
|---|---|---|
| book_id | FK → Book | |
| ontology_id | FK → Ontology | |
| book_ontology_value | string (null) | Filled for open placeholder nodes. Null for predefined. |
| book_ontology_position | int | Order for hierarchical display (0-based) |
Primary key: (book_id, ontology_id).
Translation¶
Unified translation table for all translatable ontology entities. Non-English translations only — the entity's own _en field is the canonical English and the fallback when no translation exists. See ADR 0019.
Table: translation
| Field | Type | Notes |
|---|---|---|
| translation_id | PK | |
| translation_table_name | enum | type, genre, ontology — the table the entity belongs to |
| translation_entity_id | int | PK of the entity being translated (in the translation_table_name table) |
| translation_language_id | FK → Language | The language of this translation |
| translation_text | string | The translated text |
Unique constraint: (translation_table_name, translation_entity_id, translation_language_id) — one translation per entity per language.
Index: (translation_table_name, translation_language_id) for loading all translations for a language in a given table.
No foreign key from translation_entity_id to the entity tables — the translation_table_name discriminator makes a single FK impossible. Primary keys are inherently immutable and never reused, so orphans cannot occur from renames. Application-level validation checks that (translation_table_name, translation_entity_id) corresponds to an existing entity row before accepting a translation.
Fallback chain: translation table → entity's _en field. The _en field is always populated at seed time, so a displayable string is guaranteed.
Language¶
Table: language
| Field | Type | Notes |
|---|---|---|
| language_id | PK | |
| language_code | string | ISO 639-1 (e.g. it, en, fa) |
| language_name_en | string | English name (e.g. "Italian") |
| language_name_native | string | Native name (e.g. "Italiano", "Farsi", "English") |
Author / Curator¶
Table: author
| Field | Type | Notes |
|---|---|---|
| author_id | PK | |
| author_surname | string | Surname, extracted from author_name_latin for sorting. |
| author_name_latin | string | Romanized name in "Surname, Firstname" format (e.g. "Poe, Edgar Allan", "Tolstoj, Lev"). Always present. |
| author_name_original | string (null) | Name in the original script (e.g. "Толстой, Лев"). Null for Latin-script authors (Poe, Austen). |
| author_original_language_id | FK → Language | The language of the original script form. Null when author_name_original is null. Drives display: show original only when user's UI language matches. |
| author_aliases | json | Array of alternative names (e.g. ["E. A. Poe", "Edgar Poe"]). Generated per author normalization. |
| author_wikidata_id | string (null) | Wikidata item QID (e.g. Q1734). Stable and language-independent, unlike a per-language Wikipedia page id. |
| author_openlibrary_id | string (null) | OpenLibrary author ID (e.g. OL26348A). Populated from Wikidata P648, or when imported from OpenLibrary. |
| author_googlebooks_id | string (null) | Google Books author ID. Populated when imported from Google Books. |
Display logic: if author_name_original is null, show author_name_latin. If author_name_original is not null AND the user's UI language matches author_original_language_id, show author_name_original. Otherwise show author_name_latin.
Pre-loaded: the author table is pre-loaded from Wikidata before any user interaction (ADR 0016). Most authors already exist when a user enters a name. The mechanical rules that produce each field — name split, suffix and particle handling, script and language detection, alias permutations, disambiguation — are specified in author normalization.
Normalization rule: for manual entry, the user picks from autocomplete suggestions (prefix search on author_name_latin and author_aliases, min 3 characters). For CSV import, the system resolves via exact alias match, then prefix-scoped Levenshtein on the pre-loaded authors (see ADR 0016). Which alias matches resolve automatically and which only produce candidates is specified in author normalization.
Store¶
Table: store
| Field | Type | Notes |
|---|---|---|
| store_id | PK | |
| store_name | string | Display name (e.g. "Amazon.it", "ibs.it") |
| store_url | string | Base URL |
| store_implementation | string | Identifier for the adapter/service that implements price fetching for this store |
| store_languages | json | Array of language_id values this store is a good source for (e.g. Amazon.it is good for Italian and English, but not Farsi) |
| store_ships_to | json | Array of ISO 3166-1 alpha-2 country codes (e.g. ["IT", "DE"]) |
| store_currencies | json | Array of ISO 4217 currency codes (e.g. ["EUR", "USD"]) |
| store_sells_used | boolean | Whether the store sells used books |
| store_sells_ebooks | boolean | Whether the store sells ebooks |
Log¶
Application-level operational log. Written by a dedicated Logging Worker that receives events from the main Worker via ctx.waitUntil() (fire-and-forget). Covers system health and scheduled task output — not user activity. See ADR 0023.
Table: log
| Field | Type | Notes |
|---|---|---|
| log_id | PK | |
| log_time | DATETIME | UTC (ADR 0020) |
| log_level | enum | info, warn, error |
| log_source | enum | api, scheduled_fetch, consolidation, alert, auth |
| log_event | string | Machine-readable event name (e.g. fetch_started, api_error) |
| log_detail | json | Structured details (book count, store ID, error message, trace) |
Index: (log_source, log_time) for filtering by source within a time range.
Retention: a scheduled job deletes rows older than 90 days (configurable).
Workers Logs (Cloudflare built-in) captures access-level logs (request method, path, status, duration) and console.log() / console.error() output as a fallback. This table captures application-level events that need long-term retention and SQL querying.