deckhearth/docs/SCHEMA_MAP.md
Randall Stillwell ec9bb2b93e feat(catalog): unified multi-game bulk sync + schema map update
Ship Pokemon and Lorcana bulk import libs/scripts, unified weekly cron
sync across MTG/Pokemon/Lorcana with per-game error isolation and
catalog_sync_log telemetry. Admin UI adds Unified/Incremental/Bulk MTG modes.

- Rename reconcile migrations to 1781442330* timestamps so they apply
  after bulk-data migrations without node-pg-migrate ordering conflicts
- Add Lorcana set-code normalization + orphan cleanup migrations
- Drop stricter user_cards_user_card_unique (keep 3-column foil unique)
- Update docs/SCHEMA_MAP.md for tags, card_tags, catalog_sync_log, bulk columns

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:22:19 -05:00

14 KiB
Raw Blame History

SCHEMA_MAP.md

Hand-curated reference for the Neon Postgres schema. As of the migration-tool convoy (2026-05-26), the formal source of truth is the migration history under migrations/ at the repo root (managed by node-pg-migrate). The initial backfill migration migrations/1779853647564_initial-schema.js reproduces scripts/setup-neon-db.js's 7-table bootstrap DDL verbatim. The historical scripts/add-*.js / scripts/fix-*.js jobs are preserved as append-only history; their effects are baked into the production schema but are NOT replayed by npm run migrate up on a fresh env — the initial backfill captures only the post-setup-neon-db.js shape. If a fresh env needs the full historical column set, a follow-up convoy (reconcile-historical-add-scripts) will fold the historical effects into the migration history; until then this file remains the curated reference for the full prod shape.

Last reviewed: 2026-06-14 against migrations/ HEAD (through 1781442340000_cleanup-lorcana-orphans.js).

Quick model groups

Group Tables Purpose
Identity users, user_settings, user_avatars Accounts, profile, preferences
Catalog cards, tags, card_tags Master card list + Scryfall Tagger community tags
Sync telemetry catalog_sync_log Per-game bulk/incremental catalog sync history
Ownership user_cards, user_favorites What a user owns / has favorited (UI: My Collection)
Collections collections, collection_cards, collection_permissions, collection_activity Curated card lists with sharing (UI: Lists)
Decks decks, deck_cards Playable deck definitions
Scanning card_submissions, scan_attempts Unknown-card review queue + scan telemetry
Invitations invitations (referenced; verify) Pending share requests

Tables

users

Column Type Notes
id SERIAL PK
email VARCHAR(255) UNIQUE NOT NULL Lowercase before query/insert (no CI collation set)
password VARCHAR(255) NOT NULL bcrypt hash, cost 12
role VARCHAR(50) default 'user' 'user' | 'admin'
created_at, updated_at TIMESTAMP default now
first_name, last_name VARCHAR(255) From add-user-profile-columns.js
username VARCHAR(255) UNIQUE
profile_image_url, avatar_url TEXT Two redundant columns; verify which is canonical
bio TEXT
favorite_games JSONB default '["MTG"]' Per-user game preference array
collection_visibility VARCHAR(20) default 'private'
preferred_currency VARCHAR(3) default 'USD'
cards_per_page INTEGER default 50
default_view VARCHAR(10) default 'grid'
notifications_email BOOLEAN default true
notifications_marketing BOOLEAN default false
two_factor_enabled BOOLEAN default false Not implemented yet
theme VARCHAR(10) default 'system' 'light' | 'dark' | 'system'
language VARCHAR(5) default 'en'
is_pending BOOLEAN default false Set by invitation flow before signup completes

cards

Column Type Notes
id SERIAL PK
name VARCHAR(255) NOT NULL
set_name, set_code, card_number VARCHAR
rarity VARCHAR(50)
game VARCHAR(50) NOT NULL 'mtg' | 'pokemon' | 'lorcana'
mana_cost VARCHAR(50) MTG only
cmc INTEGER MTG only
card_type VARCHAR(255)
colors JSONB MTG color array
oracle_text TEXT LARGE — never SELECT *
power, toughness VARCHAR(10) MTG creatures
image_url, stock_image_url TEXT
current_price, market_price DECIMAL(10,2)
scryfall_id VARCHAR(255) UNIQUE External dedupe key (Scryfall UUID, Pokémon TCG id, Lorcana Unique_ID)
verified BOOLEAN default false Admin-edited cards
quantity INTEGER default 0 Unused; consider dropping — quantity lives in user_cards (1781442330001_reconcile-cards-columns)
favorited BOOLEAN default false Unused; favorites live in user_favorites (1781442330001_reconcile-cards-columns)
oracle_id VARCHAR(36) Stable across MTG printings; joins oracle tags (1781440700404)
illustration_id VARCHAR(36) Stable per artwork; joins art tags (1781440700404)
color_identity JSONB MTG Commander identity; Lorcana ink colors (1781440700404)
keywords JSONB MTG mechanics / Lorcana classifications (1781440700404)
legalities JSONB Format legality map (1781440700404)
flavor_text TEXT (1781440700404)
artist VARCHAR(255) (1781440700404)
released_at DATE (1781440700404)
layout VARCHAR(50) MTG card layout (1781440700404)
edhrec_rank INTEGER MTG Commander popularity; Lorcana lore value repurposed (1781440700404)
reserved BOOLEAN default false MTG Reserved List (1781440700404)
reprint BOOLEAN default false (1781440700404)
finishes JSONB ["nonfoil","foil","etched"] (1781440700404)
created_at, updated_at TIMESTAMP default now

Indexes (post 1779853647565_add-pg-trgm-card-name-index + 1781440700404):

  • idx_cards_name_trgm — GIN on name using gin_trgm_ops for Layer-1 OCR fuzzy match (similarity() / pg_trgm).
  • cards_oracle_id_index, cards_illustration_id_index, cards_artist_index, cards_edhrec_rank_index
  • GIN on color_identity, keywords, legalities

Extensions used by scan pipeline:

  • pg_trgm — enabled by 1779853647565_add-pg-trgm-card-name-index.js for trigram similarity on cards.name.

user_cards

Column Type Notes
id SERIAL PK
user_id INTEGER FK users(id) ON DELETE CASCADE
card_id INTEGER FK cards(id) ON DELETE CASCADE
quantity INTEGER default 1
condition VARCHAR(50) default 'NM' NM / LP / MP / HP / DMG
is_foil BOOLEAN default false
notes TEXT
scan_image_url TEXT Vercel Blob URL of scanner capture (1779908094455_add-user-cards-scan-image-url)
UNIQUE(user_id, card_id, is_foil) — canonical 3-column constraint per 1781442330006_reconcile-user-cards-unique (foil and non-foil are separate rows). The historical 2-column user_cards_user_card_unique constraint is dropped if present.

tags

Scryfall Tagger community tags (1781440721350_add-tagger-tables).

Column Type Notes
id UUID PK Stable tag id from Scryfall bulk
slug VARCHAR(255) NOT NULL URL-safe identifier
label VARCHAR(255) NOT NULL Human-readable name
type VARCHAR(20) NOT NULL 'oracle' (functional) or 'illustration' (art)
description TEXT Optional
parent_ids, child_ids, aliases JSONB Tag hierarchy
created_at, updated_at TIMESTAMP

card_tags

Joins tags to cards via oracle_id (oracle tags) or illustration_id (art tags).

Column Type Notes
id SERIAL PK
tag_id UUID FK tags(id) ON DELETE CASCADE
card_id INTEGER FK cards(id) ON DELETE CASCADE Optional resolved link
oracle_id VARCHAR(36) For oracle tags
illustration_id VARCHAR(36) For art tags
weight VARCHAR(20) default 'median' very_strong / strong / median / weak
annotation TEXT Optional per-tagging note
created_at TIMESTAMP
UNIQUE(tag_id, oracle_id) where oracle_id IS NOT NULL
UNIQUE(tag_id, illustration_id) where illustration_id IS NOT NULL

catalog_sync_log

Per-game catalog sync telemetry (1781442329511_add-catalog-sync-log).

Column Type Notes
id SERIAL PK
game VARCHAR(20) NOT NULL 'mtg', 'pokemon', 'lorcana'
mode VARCHAR(20) NOT NULL 'unified', 'bulk', 'incremental'
upserted INTEGER default 0 Cards written this run
errors INTEGER default 0 Failed rows
source_updated_at TIMESTAMPTZ Upstream bulk file timestamp when available
ran_at TIMESTAMPTZ default now
details JSONB Per-run summary payload

user_favorites

Column Type Notes
id SERIAL PK
user_id, card_id FKs cascade
created_at TIMESTAMP
UNIQUE(user_id, card_id) (verify constraint exists)

collections

Column Type Notes
id SERIAL PK
user_id INTEGER FK users(id) ON DELETE CASCADE
name VARCHAR(255) NOT NULL
description TEXT
is_public BOOLEAN default false Used by withCollectionPermission('viewer')
slug VARCHAR(100) UNIQUE Format constraint: lowercase kebab, ≤50 chars
image TEXT Cover image
visibility VARCHAR(20) default 'private' Coexists with is_public; verify single source of truth
tcg VARCHAR(50) default 'MTG'
tags TEXT Comma-separated; consider migrating to JSONB array
is_system_collection BOOLEAN default false Auto-sync list; DB name 'All My Cards' (internal — UI: Synced binder via collectionDisplayName())
created_at, updated_at TIMESTAMP

collection_cards

Column Type Notes
id SERIAL PK
collection_id, card_id FKs cascade
quantity INTEGER default 1
UNIQUE(collection_id, card_id)

collection_permissions

Column Type Notes
id SERIAL PK
collection_id, user_id FKs cascade
role VARCHAR 'viewer' | 'editor' | 'owner'
status VARCHAR 'pending' | 'active' | 'declined' — used by invite flow
created_at TIMESTAMP

collection_activity

Column Type Notes
id SERIAL PK
collection_id, user_id FKs
action VARCHAR e.g. 'card_added', 'permission_granted'
details JSONB
created_at TIMESTAMP

decks

Column Type Notes
id SERIAL PK
user_id FK
name, description text
game VARCHAR(50)
is_public BOOLEAN default false
created_at, updated_at TIMESTAMP

deck_cards

Column Type Notes
id SERIAL PK
deck_id, card_id FKs cascade
quantity INTEGER default 1
UNIQUE(deck_id, card_id)

card_submissions

Added by migrations/1779853647566_add-scan-tables.js (server-side scan pipeline). Unknown high-confidence scans queue here for admin review instead of polluting cards.

Column Type Notes
id SERIAL PK
user_id FK → users cascade Submitter
ocr_text TEXT Raw OCR / vision text
ocr_confidence INTEGER 0100 from scan layer
scan_image_url TEXT Optional blob URL of capture
candidate_card_ids JSONB default '[]' Near-miss catalog IDs
ocr_payload JSONB Structured fields for admin promote
status VARCHAR(32) default 'pending' 'pending' | 'approved' | 'rejected'
reviewed_by FK → users SET NULL Admin reviewer
promoted_card_id FK → cards SET NULL Set on approve
created_at, updated_at TIMESTAMP default now

Index: idx_card_submissions_status (status, created_at DESC).

scan_attempts

Per-scan telemetry for the identify pipeline (layer 2 = Gemini today).

Column Type Notes
id SERIAL PK
user_id FK → users SET NULL
ocr_text TEXT
ocr_confidence INTEGER
layer INTEGER default 2 OCR layer (1 = Tesseract future)
matched_card_id FK → cards SET NULL
result_kind VARCHAR(32) e.g. 'matched', 'disambiguation', 'submitted', 'not_a_card'
latency_ms INTEGER End-to-end identify latency
created_at TIMESTAMP default now

Index: idx_scan_attempts_user_created (user_id, created_at DESC).

user_settings (split from users.*; verify which is canonical)

Defined in add-user-profile-fields.js. Mirrors several users.* columns — there's redundancy that needs to be reconciled.

user_avatars

Tracks uploaded avatar history. Older avatars are typically deleted from blob storage; verify the cleanup job runs.

Known schema smells

  1. Two users avatar columnsprofile_image_url and avatar_url. Pick one.
  2. Two collection-visibility flagscollections.is_public (BOOLEAN) and collections.visibility (VARCHAR). Pick one.
  3. cards.quantity + cards.favorited — these belong on user_cards / user_favorites, not the catalog. Drop them.
  4. user_settingsusers.* — split-brain. Reconcile.
  5. No formal constraints on enumsrole, condition, theme, language, game, visibility are all VARCHAR. Consider CHECK constraints or proper ENUMs.
  6. collections.tags is TEXT — should be JSONB or a join table.

Regeneration

For new schema changes (post-migration-tool), look at migrations/<timestamp>_<slug>.js files and update the matching table section here in the same PR. For the historical state captured before the migration tool landed:

rg "ALTER TABLE|CREATE TABLE|ADD COLUMN" scripts/ migrations/

…then update this file by hand. A npm run schema:map script regenerated from the migration history is in .convoys/.