11 — Data model and normalization
Commandments V and VII at the root: a backup is a Rollback — Reverting a change to the previous, working state — “Ctrl+Z” for a deployment. When a new version breaks production, a rollback restores the previous one in seconds instead of fixing in a panic., user data inviolable — but before you safeguard anything, it must have a shape in which change is cheap and predictable.
The schema is the contract of the whole system. Scrapers, web, pipelines, Migration (database) — A controlled change to the database layout — adding a column, a table or moving data — step by step. Like a renovation to plan: rebuilding data in a set order so nothing “collapses”. — everything rests on it. A poorly normalized model takes its revenge on every layer: duplicate data drifts, mappings on an unstable key lose records, computed values diverge from one another. Normalize first; denormalize deliberately and with a named source of truth.
Normalize first
- No repeating groups. A value once, in one place.
- Vocabularies in lookup tables. In the reference project:
countries/regions/product_types/tag_types— not free-text in a column. Plus aCHECKonproducts.type(type_a | type_b | type_c | …) — the database rejects garbage before it gets in. - Junction tables for M:N. A product has many tags/attributes →
product_tags(junction), notprimary_tag+secondary_tagas two free-text fields (those were dropped by migration 090 — the junction is the sole store of attributes).
Stable keys — slug, not ID
IDs drift. After duplicate merges product_id changes (the canonical row absorbs the rows,
the duplicate one disappears). That is why the stable identifier is the Slug — A readable, short part of a page address that describes its content in words instead of a mysterious number. Better for humans and SEO; a stable slug doesn’t break links when things change., not the numeric key.
The hardest lesson from the reference project — the prod-database swap: you map user data by slug → new ID,
never by the old ID (a review would land on the wrong product). A missing slug you skip and log,
you don’t push it through blindly. → 05
Active-row instead of overwriting
A pattern from prices, worth carrying everywhere history has value:
- An
expired_atcolumn (NULL = active). A price change → you expire the old row, insert a new one. - A partial unique Index (database) — A lookup in the database that makes searching instant instead of scanning everything in order. The first move for slow queries — like an index at the back of a book instead of reading 400 pages.
WHERE expired_at IS NULL— enforces “one active per key” (one active price per (product, retailer)). - A history table (
price_history) records every price ever seen. - The effect: audit for free — you know what changed and when, without triggers.
When to denormalize (deliberately)
Denormalization is legal for reads — but always name the source of truth and guard consistency:
- Displayed/computed fields → compute in helpers, don’t store.
display_name(brand + variant + edition name) computed dynamically inweb/src/helpers.js. Stored, it would drift after every change to a component. - A denormalized Cache — A temporarily remembered result, so the same thing isn’t computed again on every request. Speeds the app up, but can be a trap: a stale cache shows old data. with a clear source.
ext_profile_cacheholds data from an external source; theext_*columns were removed fromproducts(migration 051) — the cache is the sole source, no two truths. - A snapshot with a live fallback.
site_stats.jsonis a dump of numbers (products/prices/retailers); read by the home page, but with a fallback to the live database when the snapshot is stale.
Migrations and integrity
- Forward-only + additive —
ADD COLUMNis backward-compatible; never DROP/RENAME a column used by working old code (→ 04). - FK integrity check after every operation (
PRAGMA foreign_key_check). - Gating on column existence — the script checks whether a column/table exists before it operates on it (survives different schema states between environments).
The schema as a documented contract
ERD + controlled vocabulary in docs (e.g. db_schema.md with a Mermaid ERD,
data_model_reference.md with the allowed values for type/region/tag_types). A schema
nobody documented is a schema the next session guesses. → 01
Anti-patterns
- 🚫 Free-text where there should be a lookup (country as a string → 5 spellings of “USA”/“U.S.A.”/“United States”).
- 🚫 A duplicated source of truth without synchronization (external data in
productsand in the cache → divergence). - 🚫 Mapping user data by a mutable ID instead of the slug → a review on the wrong product.
- 🚫 Storing a computed value that drifts (
display_nameas a column). - 🚫 A destructive migration under working old code (DROP of a column the web still reads).
- 🚫 Two free-text fields instead of a junction table for an M:N relation.
In practice
When you declare privacy, the data model must enforce it. Separate the paths: user-facing data and a separate, Idempotency — A property of an operation you can run many times with the same result — no duplication. Key for scripts and events: a re-run doesn’t break data. Like an “ON” switch. processing pipeline, operating exclusively on anonymized data (PII removed before processing). The claim “we don’t read the data” must follow from the schema and the script’s contract, not from copy — and have a re-identification test that proves identity cannot be reconstructed. → 04, 09