feat(migrations): reconcile user_cards UNIQUE constraint per Option A (B6) #149

Merged
varutasu merged 2 commits from convoy/reconcile-b6-user-cards-unique into main 2026-06-14 09:18:23 -04:00
varutasu commented 2026-06-14 09:04:03 -04:00 (Migrated from github.com)

Summary

Brief 6 of the reconcile-historical-add-scripts convoy. Adds one new
migration migrations/1781000000006_reconcile-user-cards-unique.js
that defensively reconciles the user_cards UNIQUE constraint to the
canonical 3-column shape declared by
migrations/1779853647564_initial-schema.js
(UNIQUE(user_id, card_id, is_foil)).

Finding 1 — operator decision (Option A, ratified 2026-06-14)

The convoy architect surfaced a conflict between two historical sources:

  • migrations/1779853647564_initial-schema.js declares
    UNIQUE(user_id, card_id, is_foil) (3-col) on user_cards (line 82).
  • scripts/fix-user-cards-constraints.js would ADD CONSTRAINT user_cards_user_card_unique UNIQUE (user_id, card_id) (2-col, stricter).

The operator picked Option A: keep the 3-col constraint, drop /
treat-as-no-op the 2-col one. Foil and non-foil copies of the same card
are semantically separate rows. The strict 2-col UNIQUE forbids that
distinction. The historical script was either never applied to prod OR
applied and then reverted; current prod has the 3-col version (matching
initial-schema, runtime confirmed against
pages/api/cards/[id]/ownership.js and the user_cards ownership flow).

Migration shape

Two pgm.sql() blocks, both idempotent:

  1. ALTER TABLE user_cards DROP CONSTRAINT IF EXISTS user_cards_user_card_unique; — defensive, removes the stricter 2-col
    constraint only if an env happened to apply
    scripts/fix-user-cards-constraints.js. No-op everywhere else.
  2. DO $$ BEGIN ALTER TABLE user_cards ADD CONSTRAINT user_cards_user_id_card_id_is_foil_key UNIQUE (user_id, card_id, is_foil); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
    re-asserts the canonical 3-col constraint under its
    Postgres-auto-generated name (the same name initial-schema's inline
    UNIQUE(...) produces). No-op against fresh / initial-schema-applied
    envs; constructive against any env where the constraint had been
    dropped.

down() is a hard-stub throw — reverting would re-install the 2-col
constraint (the regression Option A rejected).

Constraint names verified

  • user_cards_user_id_card_id_is_foil_key — Postgres auto-generated
    name for the inline UNIQUE(user_id, card_id, is_foil) declaration in
    migrations/1779853647564_initial-schema.js line 82. Convention:
    <table>_<col1>_<col2>_..._key. Used in the migration's DO $$ EXCEPTION ADD block.
  • user_cards_user_card_unique — the explicit name used by
    scripts/fix-user-cards-constraints.js line 52. Used in the migration's
    defensive DROP CONSTRAINT IF EXISTS.

(Note: the brief speculatively suggested the script's would-be name might
be the default user_cards_user_id_card_id_key. The actual script names
its constraint explicitly; the migration uses the script's literal name.)

Why this is safe against all 3 prod states

Env state DROP block ADD block Net effect
Fresh Neon branch (initial-schema only) No-op (constraint never existed) No-op (3-col already exists under auto-gen name) pgmigrations row inserted
Long-lived env that never ran fix-user-cards-constraints.js No-op No-op pgmigrations row inserted
Long-lived env that DID run fix-user-cards-constraints.js Drops the 2-col constraint No-op (3-col still in place from initial-schema) 2-col removed; 3-col preserved

Verification

  • node --check migrations/1781000000006_*.js → exit 0
  • npm run lint → 0 errors (1 unrelated pre-existing warning in
    components/CollectionsPageView.js, baseline)
  • npm run test:run → 26 files / 131 tests pass
  • Constraint-name grep verified against initial-schema.js line 82
    and scripts/fix-user-cards-constraints.js line 52

Optional Neon-branch verification (deferred to convoy B7's runbook):
post-migration, INSERT two rows with same (user_id, card_id) but
is_foil=true and is_foil=false to confirm both coexist; insert a
third duplicate of either to confirm the _key constraint rejects it
with duplicate key value violates unique constraint "user_cards_user_id_card_id_is_foil_key".

Files changed

  • migrations/1781000000006_reconcile-user-cards-unique.js (new, 100 LOC)

Test plan

  • Migration file passes node --check
  • Lint passes (0 errors baseline)
  • Vitest passes (131/131)
  • Operator runs npm run migrate up against prod after merge — expected: no-op
    (DROP doesn't match, ADD's EXCEPTION swallows duplicate)
    , only
    change is the new pgmigrations row
  • Operator runs npm run migrate up against a fresh Neon branch
    created from empty parent — expected: no-op DDL-wise, only change
    is the pgmigrations row (sequence: initial-schema already declared
    the 3-col under auto-gen name; this migration's ADD's EXCEPTION
    swallows)

Notes for reviewer

  • Brief 6 is tagged depends_on: [] in the convoy's
    slice_dependencies: block — disjoint files vs B1–B5; PR base is
    main, not the convoy parent branch.
  • B7 will sweep the convoy file, docs/SCHEMA_MAP.md, and AGENTS.md
    Gotcha #6 once B1–B6 land. This PR does not touch any of those.
  • The historical script scripts/fix-user-cards-constraints.js is
    untouched (no-go-zones rule). It will be retired by the queued
    retire-graveyard-scripts-after-audit follow-up once all of B1–B6
    land.

Made with Cursor

## Summary Brief 6 of the `reconcile-historical-add-scripts` convoy. Adds one new migration `migrations/1781000000006_reconcile-user-cards-unique.js` that defensively reconciles the `user_cards` UNIQUE constraint to the canonical 3-column shape declared by `migrations/1779853647564_initial-schema.js` (`UNIQUE(user_id, card_id, is_foil)`). <!-- pipeline: brief=6, convoy=reconcile-historical-add-scripts --> ## Finding 1 — operator decision (Option A, ratified 2026-06-14) The convoy architect surfaced a conflict between two historical sources: - `migrations/1779853647564_initial-schema.js` declares `UNIQUE(user_id, card_id, is_foil)` (3-col) on `user_cards` (line 82). - `scripts/fix-user-cards-constraints.js` would `ADD CONSTRAINT user_cards_user_card_unique UNIQUE (user_id, card_id)` (2-col, stricter). The operator picked **Option A**: keep the 3-col constraint, drop / treat-as-no-op the 2-col one. Foil and non-foil copies of the same card are semantically separate rows. The strict 2-col UNIQUE forbids that distinction. The historical script was either never applied to prod OR applied and then reverted; current prod has the 3-col version (matching initial-schema, runtime confirmed against `pages/api/cards/[id]/ownership.js` and the `user_cards` ownership flow). ## Migration shape Two `pgm.sql()` blocks, both idempotent: 1. `ALTER TABLE user_cards DROP CONSTRAINT IF EXISTS user_cards_user_card_unique;` — defensive, removes the stricter 2-col constraint **only if** an env happened to apply `scripts/fix-user-cards-constraints.js`. No-op everywhere else. 2. `DO $$ BEGIN ALTER TABLE user_cards ADD CONSTRAINT user_cards_user_id_card_id_is_foil_key UNIQUE (user_id, card_id, is_foil); EXCEPTION WHEN duplicate_object THEN NULL; END $$;` — re-asserts the canonical 3-col constraint under its Postgres-auto-generated name (the same name `initial-schema`'s inline `UNIQUE(...)` produces). No-op against fresh / initial-schema-applied envs; constructive against any env where the constraint had been dropped. `down()` is a hard-stub throw — reverting would re-install the 2-col constraint (the regression Option A rejected). ## Constraint names verified - **`user_cards_user_id_card_id_is_foil_key`** — Postgres auto-generated name for the inline `UNIQUE(user_id, card_id, is_foil)` declaration in `migrations/1779853647564_initial-schema.js` line 82. Convention: `<table>_<col1>_<col2>_..._key`. Used in the migration's `DO $$ EXCEPTION` ADD block. - **`user_cards_user_card_unique`** — the explicit name used by `scripts/fix-user-cards-constraints.js` line 52. Used in the migration's defensive `DROP CONSTRAINT IF EXISTS`. (Note: the brief speculatively suggested the script's would-be name might be the default `user_cards_user_id_card_id_key`. The actual script names its constraint explicitly; the migration uses the script's literal name.) ## Why this is safe against all 3 prod states | Env state | DROP block | ADD block | Net effect | | --- | --- | --- | --- | | Fresh Neon branch (`initial-schema` only) | No-op (constraint never existed) | No-op (3-col already exists under auto-gen name) | `pgmigrations` row inserted | | Long-lived env that never ran `fix-user-cards-constraints.js` | No-op | No-op | `pgmigrations` row inserted | | Long-lived env that DID run `fix-user-cards-constraints.js` | Drops the 2-col constraint | No-op (3-col still in place from `initial-schema`) | 2-col removed; 3-col preserved | ## Verification - [x] `node --check migrations/1781000000006_*.js` → exit 0 - [x] `npm run lint` → 0 errors (1 unrelated pre-existing warning in `components/CollectionsPageView.js`, baseline) - [x] `npm run test:run` → 26 files / 131 tests pass - [x] Constraint-name grep verified against `initial-schema.js` line 82 and `scripts/fix-user-cards-constraints.js` line 52 Optional Neon-branch verification (deferred to convoy B7's runbook): post-migration, `INSERT` two rows with same `(user_id, card_id)` but `is_foil=true` and `is_foil=false` to confirm both coexist; insert a third duplicate of either to confirm the `_key` constraint rejects it with `duplicate key value violates unique constraint "user_cards_user_id_card_id_is_foil_key"`. ## Files changed - `migrations/1781000000006_reconcile-user-cards-unique.js` (new, 100 LOC) ## Test plan - [x] Migration file passes `node --check` - [x] Lint passes (0 errors baseline) - [x] Vitest passes (131/131) - [ ] Operator runs `npm run migrate up` against prod after merge — **expected: no-op (DROP doesn't match, ADD's EXCEPTION swallows duplicate)**, only change is the new `pgmigrations` row - [ ] Operator runs `npm run migrate up` against a fresh Neon branch created from empty parent — **expected: no-op DDL-wise**, only change is the `pgmigrations` row (sequence: `initial-schema` already declared the 3-col under auto-gen name; this migration's ADD's EXCEPTION swallows) ## Notes for reviewer - Brief 6 is tagged `depends_on: []` in the convoy's `slice_dependencies:` block — disjoint files vs B1–B5; PR base is `main`, not the convoy parent branch. - B7 will sweep the convoy file, `docs/SCHEMA_MAP.md`, and AGENTS.md Gotcha #6 once B1–B6 land. This PR does not touch any of those. - The historical script `scripts/fix-user-cards-constraints.js` is untouched (no-go-zones rule). It will be retired by the queued `retire-graveyard-scripts-after-audit` follow-up once all of B1–B6 land. Made with [Cursor](https://cursor.com)
vercel[bot] commented 2026-06-14 09:04:08 -04:00 (Migrated from github.com)

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tcg-vault Ready Ready Preview, Comment Jun 14, 2026 1:09pm

Request Review

[vc]: #wta1O7GM3XTqy7kdaR1t4eaP8qJBX4b9MD+iczw1o28=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ0Y2ctdmF1bHQiLCJwcm9qZWN0SWQiOiJwcmpfRjZXOEVvRkd3Y0g3aWVGcnRvRlNlOXdVVkFhNSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ0Y2ctdmF1bHQtZ2l0LWNvbnZveS1yZWNvbmNpLTVhNjg1YS1yYW5kYWxsLXN0aWxsd2VsbHMtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vcmFuZGFsbC1zdGlsbHdlbGxzLXByb2plY3RzL3RjZy12YXVsdC83NXJoNWZkVnQ3MWdkTGlhRWt6MkNHTDZQbmJHIiwicHJldmlld1VybCI6InRjZy12YXVsdC1naXQtY29udm95LXJlY29uY2ktNWE2ODVhLXJhbmRhbGwtc3RpbGx3ZWxscy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dLCJyZXF1ZXN0UmV2aWV3VXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3ZlcmNlbC1hZ2VudC9yZXF1ZXN0LXJldmlldz9vd25lcj1zdHdsLWxhYnMmcmVwbz10Y2ctdmF1bHQmcHI9MTQ5In0= The latest updates on your projects. Learn more about [Vercel for GitHub](https://vercel.link/github-learn-more). | Project | Deployment | Actions | Updated (UTC) | | :--- | :----- | :------ | :------ | | [tcg-vault](https://vercel.com/randall-stillwells-projects/tcg-vault) | ![Ready](https://vercel.com/static/status/ready.svg) [Ready](https://vercel.com/randall-stillwells-projects/tcg-vault/75rh5fdVt71gdLiaEkz2CGL6PnbG) | [Preview](https://tcg-vault-git-convoy-reconci-5a685a-randall-stillwells-projects.vercel.app), [Comment](https://vercel.live/open-feedback/tcg-vault-git-convoy-reconci-5a685a-randall-stillwells-projects.vercel.app?via=pr-comment-feedback-link) | Jun 14, 2026 1:09pm | <a href="https://vercel.com/vercel-agent/request-review?owner=stwl-labs&repo=tcg-vault&pr=149" rel="noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://agents-vade-review.vercel.sh/request-review-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://agents-vade-review.vercel.sh/request-review-light.svg"><img src="https://agents-vade-review.vercel.sh/request-review-light.svg" alt="Request Review"></picture></a>
github-actions[bot] commented 2026-06-14 09:07:01 -04:00 (Migrated from github.com)

Pipeline Health

Build + CI gates

Gate Status
Vercel build (Preview) pass
CI: Lint pass
CI: Schema map fresh skipped
Preview smoke failure
Visual diff ⏭ skipped or pending

Build runs on Vercel; this CI runs lint and schema-map drift only (no duplicate build).

Role reports

Role Status
Reviewer report pending
A11y audit pending
Design system audit pending

See individual comments above for details. This rollup updates automatically.

<!-- pipeline-rollup --> ## Pipeline Health ### Build + CI gates | Gate | Status | | --- | --- | | Vercel build (Preview) | ✅ pass | | CI: Lint | ✅ pass | | CI: Schema map fresh | ❌ skipped | | Preview smoke | ❌ failure | | Visual diff | ⏭ skipped or pending | _Build runs on Vercel; this CI runs lint and schema-map drift only (no duplicate build)._ ### Role reports | Role | Status | | --- | --- | | Reviewer report | ⏳ pending | | A11y audit | ⏳ pending | | Design system audit | ⏳ pending | See individual comments above for details. This rollup updates automatically.
Sign in to join this conversation.
No description provided.