deckhearth/docs/SCHEMA_MAP.md
varutasu e78f78e3f6
ci: run migrations against Postgres service container in CI (#64)
* ci: run migrations against Postgres service container in CI

Add a migrate job that applies node-pg-migrate against an ephemeral
Postgres 16 service container so broken migrations fail at PR time.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(migrations): retimestamp scan tables after initial-schema.

Fresh CI/postgres runs failed because 1748365200000 sorted before
initial-schema. Renamed to 1779853647566 with IF NOT EXISTS guards intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(migrations): ensure is_system_collection exists before description backfill.

Fresh CI Postgres runs initial-schema without this column (added historically
via scripts); ADD COLUMN IF NOT EXISTS makes the data migration safe on new
and existing envs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:01:18 -05:00

10 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-05-22 against scripts/setup-neon-db.js + every scripts/add-*.js and scripts/fix-*.js in repo HEAD.

Quick model groups

Group Tables Purpose
Identity users, user_settings, user_avatars Accounts, profile, preferences
Catalog cards Master card list across MTG / Pokémon / Lorcana
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 Use for dedupe on MTG import
verified BOOLEAN default false Admin-edited cards
quantity INTEGER default 0 Unused; consider dropping — quantity lives in user_cards
favorited BOOLEAN default false Unused; favorites live in user_favorites
created_at, updated_at TIMESTAMP default now

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

  • idx_cards_name_trgm — GIN on name using gin_trgm_ops for Layer-1 OCR fuzzy match (similarity() / pg_trgm).

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)

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/.