- scanner-desktop-layout: open -> shipped; adds As-shipped section
(PR #165, squash 938c161, 2026-08-15) with post-merge follow-up refs.
- reconcile-historical-add-scripts: open -> shipped to match its
existing As-shipped section and ship-readiness RESOLVED entry.
790 lines
43 KiB
Markdown
790 lines
43 KiB
Markdown
---
|
||
name: reconcile-historical-add-scripts
|
||
classification: quality
|
||
priority: P1 (last open P1 in launch queue)
|
||
success_metric: |
|
||
A brand-new Neon branch can be onboarded by `npm install` →
|
||
`npm run setup-db` alone. After `setup-db` exits 0, the fresh-env
|
||
schema (`information_schema.tables` + `.columns` + `.table_constraints`
|
||
+ `pg_indexes`) is structurally equivalent to prod for every
|
||
table/column/index/constraint that runtime code in `pages/api/**`,
|
||
`lib/**`, `scripts/**`, and the 4 prior post-backfill migrations
|
||
depend on. `.convoys/ship-readiness.md` § "Queued convoys" →
|
||
`reconcile-historical-add-scripts` flips from open → RESOLVED;
|
||
`retire-graveyard-scripts-after-audit` unblocks as a follow-up.
|
||
skip:
|
||
- role-design-system-auditor
|
||
- role-a11y-auditor
|
||
- role-ux-reviewer
|
||
- role-ia-architect
|
||
status: shipped
|
||
created: 2026-06-14
|
||
parent: migration-tool
|
||
addresses: migration-tool § R1 (prod schema drift from setup-neon-db.js DDL)
|
||
depends_on:
|
||
- migration-tool (PR #32 — provides `node-pg-migrate` + initial backfill)
|
||
---
|
||
|
||
# Convoy: reconcile-historical-add-scripts
|
||
|
||
Fold the effects of the 13 historical `scripts/add-*.js` / `scripts/fix-*.js`
|
||
/ `scripts/seed-*.js` jobs into the migration history so a brand-new Neon
|
||
branch can be onboarded by `npm install` → `npm run setup-db` alone,
|
||
without manually replaying the historical scripts. Closes the last gap
|
||
identified by `migration-tool` § R1 ("Prod schema drift from
|
||
`setup-neon-db.js` DDL").
|
||
|
||
## Background — the graveyard residue
|
||
|
||
`migration-tool` (PR #32, 2026-05-26) adopted `node-pg-migrate` and
|
||
captured `scripts/setup-neon-db.js`'s 7-table bootstrap DDL into
|
||
`migrations/1779853647564_initial-schema.js`. Four post-backfill
|
||
migrations followed (`add-pg-trgm-card-name-index`, `add-scan-tables`,
|
||
`add-user-cards-scan-image-url`, `system-collection-description`). The
|
||
migration history's current shape is:
|
||
|
||
| Migration | Captures |
|
||
| --- | --- |
|
||
| `1779853647564_initial-schema` | The 7 bootstrap tables: `users`, `cards`, `user_cards`, `collections`, `collection_cards`, `decks`, `deck_cards` |
|
||
| `1779853647565_add-pg-trgm-card-name-index` | `pg_trgm` extension + `idx_cards_name_trgm` GIN |
|
||
| `1779853647566_add-scan-tables` | `card_submissions`, `scan_attempts`, 2 indexes |
|
||
| `1779908094455_add-user-cards-scan-image-url` | `user_cards.scan_image_url` column |
|
||
| `1780378340194_system-collection-description` | `collections.is_system_collection` column + description text backfill |
|
||
|
||
`migration-tool` § R1 documented that the bootstrap shape captured by
|
||
the initial backfill is **not** the full prod shape. Between
|
||
`setup-neon-db.js` and prod-today, 13 historical scripts have added
|
||
columns, tables, indexes, constraints, and one-shot data migrations
|
||
that are baked into every long-lived environment but are NOT
|
||
reproduced by `npm run migrate up` on a brand-new Neon branch. Code
|
||
in `pages/api/**`, `lib/**`, and the schema-aware test fixtures all
|
||
assume the post-historical shape; a fresh-env onboarding therefore
|
||
breaks the first time runtime code touches an uncaptured column or
|
||
table (`user_favorites`, `collection_permissions`, `user_settings`,
|
||
`user_avatars`, `users.first_name`, `collections.visibility`, etc.).
|
||
|
||
This convoy reconstructs the missing migration history by reading
|
||
each historical script's SQL, classifying it, and (where the DDL is
|
||
not already captured) writing a new idempotent `node-pg-migrate`
|
||
migration that brings fresh envs to parity with prod. Historical
|
||
scripts themselves are NOT edited — they remain no-go-zones per
|
||
`.cursor/rules/no-go-zones.mdc`. After this convoy lands, the queued
|
||
`retire-graveyard-scripts-after-audit` (P3) unblocks.
|
||
|
||
## Inventory (13 scripts audited)
|
||
|
||
Legend:
|
||
- **C** = captured (DDL already present in `migrations/`)
|
||
- **M** = missed (DDL exists in prod via the script but no migration captures it)
|
||
- **DML-only** = no DDL; either pure data migration or dev-fixture seed
|
||
- **Mixed** = DDL captured but accompanying DML backfill not captured
|
||
|
||
### add-* (9)
|
||
|
||
| # | Script | DDL summary | DML summary | Classification | Captured by |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 1 | `add-card-columns.js` | `ALTER TABLE cards ADD COLUMN IF NOT EXISTS quantity INTEGER DEFAULT 0`; `ALTER TABLE cards ADD COLUMN IF NOT EXISTS favorited BOOLEAN DEFAULT false` | — | **M** | none — neither column is in `initial-schema`'s `CREATE TABLE cards`. Both flagged as "unused" smells in `docs/SCHEMA_MAP.md` § "Known schema smells" #3 |
|
||
| 2 | `add-collaboration-features.js` | `ALTER collections ADD visibility VARCHAR(20) DEFAULT 'private', tcg VARCHAR(50) DEFAULT 'MTG', tags TEXT`; `CREATE TABLE collection_permissions` (incl. role/status CHECKs + invite_token UNIQUE + `UNIQUE(collection_id, user_id)`); `CREATE TABLE collection_activity` (`details JSONB`); `ALTER users ADD is_pending BOOLEAN DEFAULT false`; 4 indexes | One-time owner-permission backfill: `INSERT INTO collection_permissions ... 'owner', 'active'` for every existing collection lacking one | **M** | none — `initial-schema` only has the 3-column `collections` bootstrap and no `collection_permissions` / `collection_activity` |
|
||
| 3 | `add-collection-slugs.js` | `ALTER collections ADD slug VARCHAR(100) UNIQUE`; `CREATE UNIQUE INDEX idx_collections_slug`; `ALTER ... ADD CONSTRAINT check_slug_format CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(slug) <= 50)` | Per-row UPDATE to backfill slugs from `name` via `lib/slug-utils.js::generateUniqueSlug` | **M** | none |
|
||
| 4 | `add-favorites-system.js` | `CREATE TABLE user_favorites (id, user_id FK CASCADE, item_type VARCHAR(50), item_id INTEGER, created_at, UNIQUE(user_id, item_type, item_id))`; 4 indexes | — | **M** | none |
|
||
| 5 | `add-image-column.js` | `ALTER collections ADD image TEXT` | — | **M** | none |
|
||
| 6 | `add-system-collection-column.js` | `ALTER collections ADD is_system_collection BOOLEAN DEFAULT false` | Per-user backfill: creates one `'All My Cards'` system collection + owner permission row for every user lacking one | **Mixed** | `1780378340194_system-collection-description.js` captures the DDL (`ADD COLUMN IF NOT EXISTS is_system_collection`). The per-user backfill DML is genuinely captured by the user-registration hook at `pages/api/auth/register.js:97-119` (`INSERT INTO collections ... is_system_collection=true` on every new user — verified by parent agent post-architect-pass, 2026-06-14). No further work required; see Finding 4 RESOLVED below. |
|
||
| 7 | `add-updated-at-column.js` | `ALTER cards ADD updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` | — | **C** | `1779853647564_initial-schema.js` — `cards` `CREATE TABLE` already includes `updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP` (line 67). Script is now a no-op on fresh envs. No work required. |
|
||
| 8 | `add-user-profile-columns.js` | `ALTER users ADD first_name, last_name VARCHAR(255), username VARCHAR(255) UNIQUE, profile_image_url TEXT` | Per-row UPDATE: defaults `first_name='User'`, `last_name=<id>`, `username='<emailprefix>_<id>'` | **M** (overlaps #9 — see Drift findings) |
|
||
| 9 | `add-user-profile-fields.js` | `ALTER users ADD first_name, last_name VARCHAR(255), username VARCHAR(255) UNIQUE, bio TEXT, avatar_url TEXT`; `ALTER users ADD favorite_games JSONB DEFAULT '["MTG"]', collection_visibility VARCHAR(20) DEFAULT 'private', preferred_currency VARCHAR(3) DEFAULT 'USD', cards_per_page INTEGER DEFAULT 50, default_view VARCHAR(10) DEFAULT 'grid'`; `ALTER users ADD notifications_email BOOLEAN DEFAULT true, notifications_marketing BOOLEAN DEFAULT false, two_factor_enabled BOOLEAN DEFAULT false`; `ALTER users ADD theme VARCHAR(10) DEFAULT 'system', language VARCHAR(5) DEFAULT 'en'`; `CREATE TABLE user_settings (id, user_id FK CASCADE, setting_key VARCHAR(100), setting_value JSONB, UNIQUE(user_id, setting_key))`; `CREATE TABLE user_avatars (id, user_id FK CASCADE, filename, original_name, mime_type, file_size, file_path, is_active BOOLEAN DEFAULT true)`; 6 CHECK constraints (`check_collection_visibility`, `check_preferred_currency`, `check_cards_per_page`, `check_default_view`, `check_theme`, `check_language`); 6 indexes | UPDATE on users to write defaults for any row with NULLs in the new columns | **M** |
|
||
|
||
### fix-* (2)
|
||
|
||
| # | Script | DDL summary | DML summary | Classification | Captured by |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 10 | `fix-lorcana-images.js` | — | UPDATE on `cards WHERE game='Lorcana' AND image_url LIKE '%-716.webp' OR '%-512.webp'`: rewrite to `-1024.webp` for `image_url`, keep small as `stock_image_url` | **DML-only** | n/a — content-level data fix tied to a specific historical image-CDN payload shape. Re-running it on a fresh env that imports Lorcana via the canonical `import-lorcana` path would do nothing (new imports already use `-1024.webp`). |
|
||
| 11 | `fix-user-cards-constraints.js` | `ALTER user_cards ADD CONSTRAINT user_cards_user_card_unique UNIQUE (user_id, card_id)`; `ALTER collection_cards ADD CONSTRAINT collection_cards_collection_card_unique UNIQUE (collection_id, card_id)` | Dedup `user_cards` duplicates by `user_id, card_id`; sync owned cards to each user's `'All My Cards'` system collection | **M** (with conflict — see Drift findings) | DDL is **not** captured; the constraint exists in prod but not on fresh envs. Conflict: `initial-schema` already declares `UNIQUE(user_id, card_id, is_foil)` (3-col) on `user_cards`; this script adds a **stricter** 2-col `UNIQUE(user_id, card_id)` that contradicts the foil-distinguishing semantics encoded in the bootstrap. Halt-and-ask flagged below. |
|
||
|
||
### seed-* (2)
|
||
|
||
| # | Script | DDL summary | DML summary | Classification | Captured by |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| 12 | `seed-collections-alice-bob.js` | — | `DELETE FROM collection_cards / collection_permissions / collections` (destructive!); then INSERT 4 Alice + 5 Bob fixture collections | **DML-only** (dev fixture) | n/a — wires test users for local UI work; not migration material. |
|
||
| 13 | `seed-collections-with-cards.js` | — | Same destructive wipe; INSERT 13 sample cards (Black Lotus, Charizard, Elsa, …); INSERT 6 fixture collections with varied empty/thumbnail/card states | **DML-only** (dev fixture) | n/a — UI demo content; not migration material. |
|
||
|
||
### Inventory counts
|
||
|
||
- **Already captured (no work):** 2 — `add-updated-at-column.js` (#7) fully; `add-system-collection-column.js` (#6) DDL by `1780378340194` + DML backfill by `pages/api/auth/register.js:97-119` (Finding 4 RESOLVED)
|
||
- **Missed DDL (needs migration):** 7 — #1, #2, #3, #4, #5, #8∪#9 (deduplicate). #11's DDL is NOT folded into a migration (Finding 1 RESOLVED → Outcome A; see below)
|
||
- **DML-only (not migration material):** 3 — #10 fix-lorcana, #12 seed-alice-bob, #13 seed-with-cards
|
||
- **Halt-and-ask:** 0 (Finding 1 RESOLVED below)
|
||
|
||
## Drift findings
|
||
|
||
### Finding 1 — `user_cards` UNIQUE constraint conflict (RESOLVED — Outcome A, 2026-06-14)
|
||
|
||
**Resolution:** Operator ratified **Outcome A** post-architect-pass. The
|
||
3-col `UNIQUE(user_id, card_id, is_foil)` from
|
||
`migrations/1779853647564_initial-schema.js` is canonical and matches
|
||
the actual prod state. `scripts/fix-user-cards-constraints.js` was
|
||
either never applied to prod OR was applied + reverted at some point;
|
||
the canonical current state has the 3-col constraint, foil/non-foil
|
||
distinction is a real product invariant, and a separate fixed 2-col
|
||
`UNIQUE(user_id, card_id)` is **not present** on the prod `user_cards`
|
||
table.
|
||
|
||
**Consequence for this convoy:** B6 collapses entirely. No migration
|
||
is written for `fix-user-cards-constraints.js`. Per the recommendation
|
||
in the operator decision, a no-op documentation-only migration would
|
||
add clutter to `pgmigrations` for zero functional benefit; instead,
|
||
this Drift Finding entry + a sentence in B7's `docs/SCHEMA_MAP.md`
|
||
update + a sentence in the As-shipped section document the reasoning.
|
||
|
||
The historical script remains a no-go-zone per
|
||
`.cursor/rules/no-go-zones.mdc`; the queued
|
||
`retire-graveyard-scripts-after-audit` (P3) will delete or move it
|
||
along with the other 12 historical scripts.
|
||
|
||
The original audit narrative is preserved below for cross-reference.
|
||
|
||
---
|
||
|
||
**Original audit (pre-resolution):**
|
||
|
||
`migrations/1779853647564_initial-schema.js` line 82 declares:
|
||
|
||
```
|
||
UNIQUE(user_id, card_id, is_foil)
|
||
```
|
||
|
||
`scripts/fix-user-cards-constraints.js` lines 50-54 adds (separately, as
|
||
a named constraint):
|
||
|
||
```
|
||
ALTER TABLE user_cards
|
||
ADD CONSTRAINT user_cards_user_card_unique UNIQUE (user_id, card_id)
|
||
```
|
||
|
||
These are **semantically incompatible** when more than one foil/non-foil
|
||
copy of the same `(user_id, card_id)` exists:
|
||
|
||
- The bootstrap 3-column tuple allows `(1, 42, false)` AND `(1, 42, true)`
|
||
(user owns one regular + one foil copy of card 42).
|
||
- The fix-script's 2-column constraint **forbids** that pair.
|
||
|
||
In any prod environment where `fix-user-cards-constraints.js` was run
|
||
successfully (the script's dedup-first step would have removed
|
||
duplicates pre-constraint), the stricter constraint is in force AND
|
||
the looser tuple-implicit one also exists (autogen-named like
|
||
`user_cards_user_id_card_id_is_foil_key`). Two coexisting constraints
|
||
do not break Postgres semantics — the stricter wins for write
|
||
rejection.
|
||
|
||
Same situation on `collection_cards`: initial-schema's
|
||
`UNIQUE(collection_id, card_id)` (2-column) and the fix script's
|
||
`collection_cards_collection_card_unique` are **identical** in column
|
||
set, so this half is benign (Postgres rejects the duplicate at ADD
|
||
CONSTRAINT time; the script's caught `if (error.message.includes('already exists'))` swallows it).
|
||
|
||
**Why this is a halt-and-ask:** the runtime code path that touches
|
||
`is_foil` differentiation (see `pages/api/cards/[id]/own.js` and
|
||
`pages/api/user-cards.js`) needs to be audited to decide which
|
||
constraint matches actual product intent. Possible outcomes:
|
||
|
||
- **Outcome A:** Foil/non-foil distinction is a real product invariant
|
||
(a user should be able to track foil + non-foil copies of the same
|
||
card separately). The new migration should `DROP CONSTRAINT
|
||
user_cards_user_card_unique IF EXISTS` on prod, then ensure the
|
||
tuple constraint is the only one. Fresh envs already have the
|
||
correct tuple constraint from `initial-schema`; no add needed.
|
||
- **Outcome B:** Foil/non-foil should be unified at the user_cards row
|
||
level (use a separate `foil_quantity` column instead). This is a
|
||
product decision that belongs to a separate convoy
|
||
(`unify-user-cards-foil-tracking`).
|
||
- **Outcome C:** Both are tolerable (current prod state). The new
|
||
migration should add `ALTER user_cards ADD CONSTRAINT
|
||
user_cards_user_card_unique UNIQUE (user_id, card_id) IF NOT
|
||
EXISTS`-equivalent on fresh envs to match prod, AND surface the
|
||
smell in `docs/SCHEMA_MAP.md`.
|
||
|
||
**Halt point:** before B6 is written, operator picks A / B / C. **Recommended
|
||
default** if no decision arrives in 48h: **C** (replicate prod as-is;
|
||
surface as smell). Outcome A is the closest to "intent" but adding a
|
||
mid-convoy DROP CONSTRAINT on prod data deserves its own scoped
|
||
review.
|
||
|
||
_— Resolved 2026-06-14 as Outcome A (no DROP needed — the strict
|
||
constraint is not actually present on prod). See resolution block at
|
||
the top of this Finding._
|
||
|
||
### Finding 2 — `users.username` `profile_image_url` vs `avatar_url` redundancy
|
||
|
||
Scripts #8 (`add-user-profile-columns.js`) and #9 (`add-user-profile-fields.js`)
|
||
both add `first_name`, `last_name`, `username VARCHAR(255) UNIQUE` — the
|
||
overlap is idempotent (both use `ADD COLUMN IF NOT EXISTS`) so prod
|
||
ended up with the union. But:
|
||
|
||
- #8 adds `profile_image_url TEXT`
|
||
- #9 adds `avatar_url TEXT`
|
||
|
||
`docs/SCHEMA_MAP.md` § "Known schema smells" #1 already flags this
|
||
redundancy ("Pick one"). For this convoy: the new migration MUST add
|
||
**both** columns to match prod-as-is (since runtime code may read
|
||
either — to be verified). Surface as follow-up
|
||
`unify-user-avatar-column`.
|
||
|
||
### Finding 3 — `cards.quantity` and `cards.favorited` are dead columns
|
||
|
||
`add-card-columns.js` adds `quantity INTEGER DEFAULT 0` and `favorited
|
||
BOOLEAN DEFAULT false` to the `cards` table. `docs/SCHEMA_MAP.md`
|
||
flags both as **Unused** (smell #3). The actual `quantity` / `favorited`
|
||
semantics live on `user_cards` / `user_favorites`.
|
||
|
||
For this convoy: add both columns to fresh envs to match prod. Do NOT
|
||
drop them in prod (separate convoy). Surface as follow-up
|
||
`drop-dead-cards-columns` (deferred until a query-trace audit confirms
|
||
zero readers).
|
||
|
||
### Finding 4 — `add-system-collection-column.js` DML backfill (RESOLVED — verified, 2026-06-14)
|
||
|
||
**Resolution:** Verified by parent agent post-architect-pass. The
|
||
`pages/api/auth/register.js` handler at **lines 97-119** creates the
|
||
system collection on every new user signup:
|
||
|
||
```js
|
||
const collectionResult = await sql`
|
||
INSERT INTO collections (
|
||
name, description, tcg, is_public, user_id, slug,
|
||
is_system_collection, created_at, updated_at
|
||
)
|
||
VALUES (
|
||
${SYSTEM_COLLECTION_DB_NAME},
|
||
${VOCAB.SYSTEM_COLLECTION_SEED_DESCRIPTION},
|
||
'All', false, ${user.id}, ${uniqueSlug},
|
||
true, -- is_system_collection
|
||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||
)
|
||
RETURNING id
|
||
`;
|
||
```
|
||
|
||
The runtime invariant is intact. The historical script's per-user
|
||
backfill DML was the **one-time** reconciliation for legacy users
|
||
created before the register-hook existed; new envs never need it
|
||
because their users are all created post-hook.
|
||
|
||
For this convoy: **no migration, no follow-up convoy needed.** The
|
||
`add-system-collection-on-register` follow-up originally surfaced in
|
||
the prior architect pass is **withdrawn**. B7 will cite this
|
||
verification in `docs/SCHEMA_MAP.md` § `collections.is_system_collection`
|
||
notes.
|
||
|
||
### Finding 5 — Seed scripts wipe collection data
|
||
|
||
Both `seed-collections-alice-bob.js` and `seed-collections-with-cards.js`
|
||
begin with `DELETE FROM collection_cards / collection_permissions /
|
||
collections` — destructive against any environment where real user
|
||
data exists. They are clearly tagged as dev fixtures (the test users
|
||
`alice@tcgvault.com` / `bob@tcgvault.com` are local-only). Per D4
|
||
below, these stay out of migrations entirely.
|
||
|
||
## Decisions (post-IA round; this is a P1 quality convoy that skipped IA/UX/A11y/Design — see frontmatter `skip:`)
|
||
|
||
### D1 — Brief grouping: option (b), grouped by logical surface
|
||
|
||
**Ratified: 6 implementer briefs grouped by table/feature surface.**
|
||
(Originally 7; B6 collapsed post-Finding-1 resolution — see § Brief outline.)
|
||
|
||
Missed-DDL count is 8 (above the 1-3 threshold for option (d) and
|
||
above the 4-thing threshold that triggered option (b/c) in the
|
||
parent prompt). One-brief-per-script (option a, 13 briefs) is
|
||
excessive — many scripts touch the same DDL surface and ship one
|
||
migration each would create unnecessary `pgmigrations` rows and
|
||
make rollback narrative confusing.
|
||
|
||
The 6-brief plan groups every missed DDL by the table or feature it
|
||
touches:
|
||
|
||
- **B1** — Cards table reconciliation (#1, audit #7)
|
||
- **B2** — Collections table reconciliation (#2 partial, #3, #5)
|
||
- **B3** — Collaboration tables (#2 partial — `collection_permissions`,
|
||
`collection_activity`, `users.is_pending`)
|
||
- **B4** — Favorites system (#4)
|
||
- **B5** — User profile reconciliation (#8 ∪ #9, dedup'd; +
|
||
`user_settings`, `user_avatars`; + CHECK constraints; + indexes)
|
||
- **B7** — Documentation + verification runbook (`docs/SCHEMA_MAP.md`
|
||
refresh, AGENTS.md Gotcha #6 update, operator runbook)
|
||
|
||
**B6 was removed post-Finding-1 resolution** (Outcome A — the strict
|
||
2-col `UNIQUE(user_id, card_id)` is not present on prod; the canonical
|
||
3-col `UNIQUE(user_id, card_id, is_foil)` from `initial-schema` is
|
||
what fresh envs already get). No migration is needed. The brief
|
||
numbering preserves the original gap (B1-B5 + B7) so cross-references
|
||
to "B6" in prior drafts of this convoy file remain unambiguous (they
|
||
point to the removed brief).
|
||
|
||
Rationale:
|
||
1. **Independence.** Each of B1-B6 writes a new file under `migrations/`
|
||
with a fresh timestamp; the files don't overlap, so B1-B6 are
|
||
trivially parallel (subject to timestamp ordering — see slice
|
||
dependencies block below).
|
||
2. **Reviewability.** Each PR is one migration + one optional
|
||
`docs/SCHEMA_MAP.md` section update; reviewer can verify against
|
||
the historical script in a single sitting.
|
||
3. **Rollback granularity.** If B5's user-profile migration is found
|
||
buggy post-merge, the other 5 migrations are unaffected — they all
|
||
`pgmigrations`-row independently.
|
||
4. **Estimated LOC per brief stays under 400.** B5 is the largest
|
||
(~250 LOC of SQL across 13 ALTERs + 2 CREATE TABLE + 6 CHECKs + 6
|
||
indexes) — comfortably under budget.
|
||
|
||
Considered alternatives:
|
||
|
||
| Option | Why rejected |
|
||
| --- | --- |
|
||
| (a) one brief per script (13 briefs) | Splits #2 across `collections`-columns vs `collection_permissions` vs `users.is_pending` artificially. Pads review surface 2x. |
|
||
| (c) one DDL + one DML + one verification brief (3 briefs) | A single DDL brief would be a 500+ LOC mega-migration. Hard to review, hard to rollback. |
|
||
| (d) single PR | Missed-DDL count >> 1; rejected by spec. |
|
||
|
||
### D2 — Idempotency pattern: raw `pgm.sql(...)` with `IF [NOT] EXISTS` guards
|
||
|
||
**Ratified: raw `pgm.sql(...)`-style SQL matching `1779853647564_initial-schema.js`'s pattern, every statement guarded with `IF NOT EXISTS` (for CREATE) or `IF EXISTS` (for DROP).**
|
||
|
||
Rationale:
|
||
1. **Style consistency.** The five existing migrations all use
|
||
`pgm.sql()`. Mixing `pgm.createTable()` helpers would introduce a
|
||
second pattern for no operational benefit.
|
||
2. **Verbatim SQL transparency.** The reviewer can grep the new
|
||
migration's SQL string against the historical script's SQL string
|
||
and confirm byte-equivalent intent.
|
||
3. **Idempotency.** Every CREATE / ALTER ADD COLUMN / CREATE INDEX
|
||
statement uses the appropriate `IF NOT EXISTS` guard so re-running
|
||
against prod is a documented no-op. CHECK constraints don't accept
|
||
`IF NOT EXISTS` directly — wrap in a `DO $$ BEGIN ... EXCEPTION
|
||
WHEN duplicate_object THEN NULL; END $$;` block (matching the
|
||
historical script's try/catch pattern).
|
||
|
||
`node-pg-migrate` helpers (`pgm.createTable`, `pgm.addColumns`) would
|
||
work fine technically, but the existing migration corpus is 100% raw
|
||
SQL. Optimize for review uniformity.
|
||
|
||
### D3 — Drift detection: option (a) — assume change is in prod, add a migration to bring fresh envs to parity
|
||
|
||
**Ratified: (a) with a documented "evidence" trail per script in the Inventory section above.**
|
||
|
||
For every script in the **M** classification, the assumption is that
|
||
the script ran successfully against prod at some point and its DDL is
|
||
now baked into the prod schema. Evidence supporting this assumption:
|
||
|
||
- `docs/SCHEMA_MAP.md` (last reviewed 2026-05-22) documents all of these
|
||
columns/tables as live.
|
||
- Runtime code in `pages/api/**` reads/writes `user_favorites`,
|
||
`collection_permissions`, `collection_activity`, `user_settings`,
|
||
`user_avatars`, `users.first_name`, `users.username`, `users.bio`,
|
||
`users.avatar_url`, `users.theme`, `users.language`,
|
||
`collections.visibility`, `collections.tcg`, `collections.tags`,
|
||
`collections.slug`, `collections.image` — confirmed via grep
|
||
(`pages/api/user/settings.js`, `pages/api/user/profile.js`,
|
||
`pages/api/favorites.js`, `pages/api/invite/{accept,decline}.js`,
|
||
`pages/api/collections/[identifier]/permissions.js`, etc.).
|
||
- Each historical script is itself idempotent (`ADD COLUMN IF NOT
|
||
EXISTS`), so prod-vs-fresh divergence is the live state.
|
||
|
||
For Finding 1 (UNIQUE constraint conflict), Finding 4 (system-coll
|
||
backfill), and Finding 5 (seed wipes): per the recommendation in the
|
||
parent prompt, halt-and-ask is reserved for destructive changes. (1)
|
||
qualifies (DROP CONSTRAINT) → halt-and-ask. (4) is operationally
|
||
benign (no DROP) → defer to follow-up. (5) is destructive but the
|
||
recommended choice is "don't fold into migrations at all" — D4 below.
|
||
|
||
### D4 — Seed scripts: option (a) — leave alone
|
||
|
||
**Ratified: dev-fixture seed scripts stay out of the migration history.**
|
||
|
||
`seed-collections-alice-bob.js` and `seed-collections-with-cards.js`
|
||
are dev-fixture loaders tied to `alice@tcgvault.com` /
|
||
`bob@tcgvault.com` (which only exist in `scripts/create-test-users.js`,
|
||
gated post-`purge-weak-creds-from-helpers` behind `TEST_USERS_PASSWORD`).
|
||
They:
|
||
|
||
- Wipe destructive collection data (incompatible with any env with real users).
|
||
- Reference Disney/Lorcana sample art URLs (`example.com/elsa.jpg`).
|
||
- Are clearly UI-demo fodder.
|
||
|
||
They have no place in `setup-db`'s migrate pipeline. They will be
|
||
retired (or moved to `scripts/historical/`) by the queued
|
||
`retire-graveyard-scripts-after-audit` convoy.
|
||
|
||
If multiple developers feel friction managing dev-fixture state in
|
||
the future, surface a separate `consolidate-dev-seeds` follow-up
|
||
that designs a non-destructive `npm run seed:dev` flow keyed off a
|
||
fresh DB. Not surfaced from this convoy because no friction has been
|
||
reported yet.
|
||
|
||
### D5 — Verification: option (a) — manual operator runbook
|
||
|
||
**Ratified: post-merge manual verification by spinning up a fresh
|
||
Neon branch + running `npm run setup-db` + diffing schema against
|
||
prod.**
|
||
|
||
Automated CI verification belongs to the queued `wire-migrate-into-ci`
|
||
convoy and is out of scope here. The manual runbook will live in this
|
||
convoy file's "Verification plan" section (below) and be promoted to
|
||
`docs/operations/RECONCILE-VERIFICATION.md` by B7.
|
||
|
||
### D6 — `scripts/migrations/2026-05-24-rename-admin-email.js`: option (b) — leave where it is
|
||
|
||
**Ratified: do not move into `migrations/`.**
|
||
|
||
The lone pre-tool migration sits at `scripts/migrations/2026-05-24-rename-admin-email.js`
|
||
and was applied to every long-lived env at `pick-a-name` time
|
||
(2026-05-24). Moving it into `migrations/` would require backfilling a
|
||
`pgmigrations` row on every existing env, which is operationally
|
||
risky for zero functional benefit (the migration is already applied;
|
||
the runtime never re-checks it).
|
||
|
||
For fresh envs, the migration is a no-op (the seed admin row is
|
||
already created at `admin@deckhearth.com` by post-rename
|
||
`scripts/setup-neon-db.js`; there's no `@tcgvault.com` row to rename).
|
||
|
||
Document the rationale in B7's `docs/SCHEMA_MAP.md` update + a brief
|
||
note in AGENTS.md Gotcha #4's "Rotation script" subsection. If a
|
||
future fresh-env onboarding ever tries to roll back to pre-rename
|
||
state, surface `fold-rename-admin-email-into-migrations` as a
|
||
follow-up.
|
||
|
||
### D7 — `pgmigrations` table state on fresh envs: no special handling required
|
||
|
||
**Ratified: rely on `node-pg-migrate`'s standard sequential apply.**
|
||
|
||
On a fresh Neon branch, `npm run migrate up` will run all migrations
|
||
in timestamp order:
|
||
|
||
1. `1779853647564_initial-schema` (7 bootstrap tables)
|
||
2. `1779853647565_add-pg-trgm-card-name-index`
|
||
3. `1779853647566_add-scan-tables`
|
||
4. `1779908094455_add-user-cards-scan-image-url`
|
||
5. `1780378340194_system-collection-description`
|
||
6. **NEW: B1** — cards columns (quantity, favorited)
|
||
7. **NEW: B2** — collections columns (visibility, tcg, tags, slug, slug index/constraint, image)
|
||
8. **NEW: B3** — collaboration tables (collection_permissions, collection_activity, users.is_pending, 4 indexes)
|
||
9. **NEW: B4** — favorites system (user_favorites + 4 indexes)
|
||
10. **NEW: B5** — user profile reconciliation (15 ALTER-ADD-COLUMN + 2 CREATE TABLE + 6 CHECK + 6 indexes)
|
||
|
||
Each new migration is additive against the post-initial-schema state
|
||
that prior migrations leave behind; no inter-migration dependencies
|
||
crossed within this convoy.
|
||
|
||
Audited ordering risks for the new migrations:
|
||
|
||
- B1 depends on `cards` (initial-schema) ✅
|
||
- B2 depends on `collections` (initial-schema) ✅
|
||
- B3 depends on `collections` + `users` (initial-schema) ✅
|
||
- B4 depends on `users` (initial-schema) ✅
|
||
- B5 depends on `users` (initial-schema) ✅
|
||
|
||
For long-lived envs that already have all post-historical columns:
|
||
every new migration is a documented no-op due to `IF NOT EXISTS`
|
||
guards. The only state change is the `pgmigrations` row insertion.
|
||
|
||
## Brief outline
|
||
|
||
Six implementer briefs. Each B1-B5 ships exactly one new file under
|
||
`migrations/<timestamp>_<slug>.js`. B7 updates docs only. Implementer
|
||
briefs are drafted under `.convoys/reconcile-historical-add-scripts/brief-<N>-<slug>.md`.
|
||
|
||
| Brief | Title | Files (new) | Depends on | Est. LOC | Notes |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| B1 | Reconcile cards columns | `migrations/1781000000001_reconcile-cards-columns.js` | — | ~40 | Captures script #1; script #7 already captured by initial-schema (verify & document in PR body) |
|
||
| B2 | Reconcile collections columns | `migrations/1781000000002_reconcile-collections-columns.js` | — | ~120 | Captures #2 (collections half: visibility/tcg/tags), #3 (slug + index + CHECK), #5 (image). Defer slug backfill DML — generating slugs on a fresh env is moot. |
|
||
| B3 | Reconcile collaboration tables | `migrations/1781000000003_reconcile-collaboration-tables.js` | — | ~130 | Captures #2 (collaboration half: collection_permissions, collection_activity, users.is_pending, 4 indexes). Defer owner-permission backfill DML — fresh envs have no pre-existing collections needing backfill. |
|
||
| B4 | Reconcile favorites system | `migrations/1781000000004_reconcile-favorites-system.js` | — | ~50 | Captures #4 (user_favorites + 4 indexes). |
|
||
| B5 | Reconcile user profile | `migrations/1781000000005_reconcile-user-profile.js` | — | ~250 | Captures #8 ∪ #9 (deduplicated; both add columns coexist in prod). Includes user_settings, user_avatars, 6 CHECK constraints (wrapped in `DO $$ EXCEPTION` blocks for idempotency), 6 indexes. Defer defaults-backfill UPDATE — column DEFAULTs handle it. |
|
||
| ~~B6~~ | ~~Reconcile user_cards / collection_cards UNIQUE constraints~~ | ~~removed~~ | — | 0 | **REMOVED 2026-06-14 — Finding 1 RESOLVED as Outcome A.** The strict 2-col `UNIQUE(user_id, card_id)` is not present on prod; the canonical 3-col tuple is already on fresh envs via `initial-schema`. Documented in Drift Finding 1 + B7's SCHEMA_MAP update; no migration written. |
|
||
| B7 | Documentation + verification | `docs/SCHEMA_MAP.md` (update); `docs/MIGRATION_VERIFICATION_RUNBOOK.md` (new); `AGENTS.md` (Gotcha #6 audit); `.convoys/ship-readiness.md` (flip queued entry) | B1-B5 merged | ~150 | SCHEMA_MAP smell-list updates per Findings 1-3. Verification runbook (see § Verification plan below). AGENTS.md Gotcha #6 already RESOLVED post-migration-tool; verify and leave alone if accurate. ship-readiness.md "Queued convoys" → flip this convoy entry to RESOLVED + unblock `retire-graveyard-scripts-after-audit` + add `unify-user-avatar-column` + `drop-dead-cards-columns` follow-ups. |
|
||
|
||
Per-brief acceptance criteria (sketch — formalized in each brief file):
|
||
|
||
- Each migration file has a `down()` that **throws** with a clear message
|
||
(these are reconciliation migrations; the prod schema state is the
|
||
source of truth and rolling back would create inconsistency).
|
||
- Each migration's SQL is **byte-equivalent in intent** to the
|
||
historical script's SQL (per-statement comments cite the script
|
||
and line range).
|
||
- Re-running `npm run migrate up` against any current long-lived env
|
||
is a no-op (all guards trigger).
|
||
- `npm run migrate up` against a fresh Neon branch followed by the
|
||
verification runbook (D5) confirms structural parity with prod.
|
||
|
||
### Slice dependencies (multitask-ready)
|
||
|
||
```yaml
|
||
slice_dependencies:
|
||
- brief: 1
|
||
depends_on: []
|
||
files: [migrations/1781000000001_reconcile-cards-columns.js]
|
||
- brief: 2
|
||
depends_on: []
|
||
files: [migrations/1781000000002_reconcile-collections-columns.js]
|
||
- brief: 3
|
||
depends_on: []
|
||
files: [migrations/1781000000003_reconcile-collaboration-tables.js]
|
||
- brief: 4
|
||
depends_on: []
|
||
files: [migrations/1781000000004_reconcile-favorites-system.js]
|
||
- brief: 5
|
||
depends_on: []
|
||
files: [migrations/1781000000005_reconcile-user-profile.js]
|
||
# brief 6 removed — Finding 1 RESOLVED as Outcome A; timestamp 1781000000006 is unused
|
||
- brief: 7
|
||
depends_on: [1, 2, 3, 4, 5]
|
||
files:
|
||
- docs/SCHEMA_MAP.md
|
||
- docs/MIGRATION_VERIFICATION_RUNBOOK.md
|
||
- AGENTS.md
|
||
- .convoys/ship-readiness.md
|
||
```
|
||
|
||
**Timestamp coordination.** Each B1-B5 writes a
|
||
`migrations/<timestamp>_*.js` file with a pre-assigned timestamp
|
||
(reservation token, not literal `Date.now()`). Pre-assigned
|
||
timestamps avoid the parallel-implementer collision risk documented
|
||
in `scaffold-nextjs-app` retro recommendation #4. The brief frontmatter
|
||
in each `.convoys/reconcile-historical-add-scripts/brief-<N>-*.md`
|
||
file declares the exact path; implementers MUST use that exact
|
||
filename (not `npm run migrate create`, which would `Date.now()`).
|
||
|
||
## Verification plan (D5 operator runbook)
|
||
|
||
To verify the reconstructed migration history produces parity with
|
||
prod, after B1-B6 merge:
|
||
|
||
```bash
|
||
# 1. Snapshot prod's structural shape (run against prod POSTGRES_URL)
|
||
# Schema-only dump, no data, no owner/ACL noise
|
||
POSTGRES_URL=<prod-url> pg_dump --schema-only --no-owner --no-acl \
|
||
--schema=public > /tmp/prod-schema.sql
|
||
|
||
# 2. Create a clean Neon branch from EMPTY (no parent branch) and onboard via setup-db
|
||
# (use the Neon dashboard or `neon branches create --empty`)
|
||
POSTGRES_URL=<fresh-branch-url> \
|
||
ADMIN_INITIAL_PASSWORD=$(openssl rand -base64 24) \
|
||
npm run setup-db
|
||
|
||
# 3. Snapshot the fresh branch's structural shape
|
||
POSTGRES_URL=<fresh-branch-url> pg_dump --schema-only --no-owner --no-acl \
|
||
--schema=public > /tmp/fresh-schema.sql
|
||
|
||
# 4. Diff. Expected differences are limited to:
|
||
# - constraint/index NAME differences (autogen tuple-UNIQUE names vs explicit names)
|
||
# - column-ORDER differences (prod has columns in historical-script-ALTER order;
|
||
# fresh envs have them in migration-order)
|
||
# Both are semantically irrelevant. Material differences = bug; flag and reopen.
|
||
diff <(sort /tmp/prod-schema.sql) <(sort /tmp/fresh-schema.sql)
|
||
```
|
||
|
||
Supplementary information-schema spot-checks for the highest-risk surfaces:
|
||
|
||
```sql
|
||
-- Every column on every table
|
||
SELECT table_name, column_name, data_type, is_nullable, column_default
|
||
FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
ORDER BY table_name, ordinal_position;
|
||
|
||
-- Every constraint
|
||
SELECT table_name, constraint_name, constraint_type
|
||
FROM information_schema.table_constraints
|
||
WHERE table_schema = 'public'
|
||
ORDER BY table_name, constraint_name;
|
||
|
||
-- Every index
|
||
SELECT tablename, indexname, indexdef
|
||
FROM pg_indexes
|
||
WHERE schemaname = 'public'
|
||
ORDER BY tablename, indexname;
|
||
```
|
||
|
||
Run both against prod and fresh-branch; the column-count + constraint-count
|
||
+ index-count totals should match exactly. Mismatch = bug.
|
||
|
||
B7 will move this runbook to `docs/operations/RECONCILE-VERIFICATION.md`
|
||
and link it from `AGENTS.md` Gotcha #6 + `migration-tool` § R1's
|
||
"resolved-by" note.
|
||
|
||
## Risks
|
||
|
||
### R1 — Missed DDL that is NOT actually in prod ("ghost migration")
|
||
|
||
The assumption (D3) is that every historical script ran successfully
|
||
against every long-lived env. If a script in fact failed silently
|
||
mid-execution on prod (e.g. `add-collaboration-features.js`'s
|
||
`CREATE INDEX idx_collections_visibility` errored partway through),
|
||
prod might not actually have that index even though SCHEMA_MAP says
|
||
it does.
|
||
|
||
**Mitigation:** the verification plan (D5) catches this. The fresh-env
|
||
`pg_dump` would contain the index; prod's `pg_dump` would not; the
|
||
diff would surface it. If found, operator decides: (a) the script's
|
||
intent was sound, apply the missed DDL to prod manually with
|
||
`POSTGRES_URL=<prod> psql -c "CREATE INDEX IF NOT EXISTS ..."`; or
|
||
(b) the index is unwanted, drop it from the new migration and document.
|
||
|
||
### R2 — `pgmigrations` row state on existing prod envs
|
||
|
||
After this convoy merges, an operator running `npm run migrate up`
|
||
against prod will see 6 new migrations apply (B1-B6) as no-ops (every
|
||
guarded statement triggers `IF [NOT] EXISTS`-skip). Six new
|
||
`pgmigrations` rows record successful application.
|
||
|
||
If for some reason a long-lived prod env genuinely lacks one of the
|
||
historical-script columns (Risk R1 above), the corresponding migration
|
||
will **add** that column on apply, no-op-ing the others. The
|
||
`pgmigrations` row records success; subsequent applies are no-ops.
|
||
This is the correct behavior, but the operator should run the
|
||
verification plan post-apply to confirm.
|
||
|
||
**Mitigation:** the verification plan covers prod-vs-fresh diff after
|
||
B1-B6 land. Run it once on each prod-shaped env immediately after
|
||
merge.
|
||
|
||
### R3 — Ordering: new migration depends on prior schema-state that doesn't exist at its execution point
|
||
|
||
Audited in D7. All B1-B6 dependencies on prior tables (`cards`,
|
||
`collections`, `users`, `user_cards`, `collection_cards`) are
|
||
satisfied by `initial-schema` (timestamp `1779853647564`, runs first
|
||
on fresh envs). No B-to-B inter-dependency required.
|
||
|
||
### R4 — Seed scripts depend on test users that don't exist on fresh envs
|
||
|
||
Out of scope (D4). The two seed scripts are dev fixtures and are not
|
||
folded into migrations. Test users (`alice@`, `bob@`) are managed by
|
||
`scripts/create-test-users.js` (post-`purge-weak-creds-from-helpers`,
|
||
gated behind `TEST_USERS_PASSWORD`). Surface as `consolidate-dev-seeds`
|
||
follow-up only if a developer reports friction.
|
||
|
||
### R5 — CHECK constraint reapplication on prod is loud
|
||
|
||
`add-user-profile-fields.js` wraps each CHECK constraint ADD in a
|
||
JS try/catch that swallows `already exists` errors. Postgres doesn't
|
||
accept `ADD CONSTRAINT ... IF NOT EXISTS` for CHECK; the SQL has to be
|
||
wrapped in `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL END
|
||
$$;`. B5's migration must use the exception-handling form so reapply
|
||
against prod is silent. Implementer brief will spell this out.
|
||
|
||
### R6 — Mid-convoy timestamp collision when implementers spawn in parallel
|
||
|
||
B1-B5 are mutually independent and can dispatch via `/multitask`.
|
||
Pre-assigned timestamps (D7 table) avoid the `Date.now()`-collision
|
||
risk that bit the `scaffold-nextjs-app` convoy. Each implementer
|
||
brief will name its file's exact timestamp; deviations require a
|
||
re-plan.
|
||
|
||
### R7 — Operator approval lag on Finding 1 (RESOLVED 2026-06-14)
|
||
|
||
Originally a risk because B6 was gated on operator decision. Finding 1
|
||
RESOLVED as Outcome A; B6 removed. No lag risk remains.
|
||
|
||
## Follow-ups
|
||
|
||
Surfaced by this convoy:
|
||
|
||
- **`retire-graveyard-scripts-after-audit`** (priority: **P3 polish — now
|
||
UNBLOCKED** once this convoy lands). Once B1-B6 capture every
|
||
missed historical DDL into the migration history, the 13 historical
|
||
scripts can be safely deleted or moved to `scripts/historical/` and
|
||
the corresponding no-go-zones rule line can be removed. Documented
|
||
in `.convoys/migration-tool.md` § Follow-ups; ship-readiness.md
|
||
"Queued convoys" entry; AGENTS.md Gotcha #6 cross-reference.
|
||
- **`wire-migrate-into-ci`** (priority: P2 CI infra — pre-existing).
|
||
Adds a CI job that runs `npm run migrate up` against a test DB on
|
||
every PR. This convoy's verification plan (D5) is the manual
|
||
precursor; the CI job is the automation upgrade. Already on the
|
||
follow-up list per `migration-tool` § Follow-ups + ship-readiness.md
|
||
"Queued convoys".
|
||
- **`unify-user-avatar-column`** (priority: P3 hygiene — NEW). Driven
|
||
by Finding 2. Two redundant TEXT columns
|
||
(`users.profile_image_url` and `users.avatar_url`) coexist; pick one
|
||
canonical column, migrate the other's data, drop the loser, update
|
||
runtime readers. Requires a query-trace audit first.
|
||
- **`drop-dead-cards-columns`** (priority: P3 hygiene — NEW). Driven
|
||
by Finding 3. `cards.quantity` and `cards.favorited` are documented
|
||
as unused. After a query-trace audit confirms zero readers, ship a
|
||
migration that DROPs them (with proper `down()` recreate).
|
||
- ~~**`add-system-collection-on-register`**~~ — **WITHDRAWN 2026-06-14**, Finding 4 RESOLVED. Verified the register hook exists at `pages/api/auth/register.js:97-119`.
|
||
- **`fold-rename-admin-email-into-migrations`** (priority: P3 polish,
|
||
conditional — NEW per D6). Only surface if a future fresh-env
|
||
onboarding needs the rename migration applied in order. Until then,
|
||
the script-shaped migration at
|
||
`scripts/migrations/2026-05-24-rename-admin-email.js` is left in
|
||
place per D6.
|
||
- ~~**`unify-user-cards-foil-tracking`**~~ — **WITHDRAWN 2026-06-14**, Finding 1 RESOLVED as Outcome A (not Outcome B). The 3-col tuple stays canonical; no foil-tracking redesign needed.
|
||
|
||
Not surfaced (no friction yet):
|
||
|
||
- `consolidate-dev-seeds` — D4 noted this can wait until multiple
|
||
developers report friction with the current per-script dev-fixture
|
||
loaders.
|
||
|
||
## As-shipped
|
||
|
||
Six implementer briefs (B1–B6) landed between 2026-06-14 and 2026-07-06.
|
||
Brief 7 (documentation + verification runbook) closed the convoy on
|
||
2026-08-15 — a post-hoc docs-only pass delayed by an operator-initiated
|
||
pause on a self-hosted runner infra issue that did not affect migration
|
||
content correctness.
|
||
|
||
### Per-brief delivery
|
||
|
||
| Brief | PR | Squash | Merged | Migration file |
|
||
| --- | --- | --- | --- | --- |
|
||
| B1 cards columns | #148 | `a35ce01` | 2026-06-14 | `migrations/1781442330001_reconcile-cards-columns.js` |
|
||
| B2 collections columns | #150 | `e8619c5` | 2026-06-14 | `migrations/1781442330002_reconcile-collections-columns.js` |
|
||
| B3 collaboration tables | #151 | `15e02ee` | 2026-07-06 | `migrations/1781000000003_reconcile-collaboration-tables.js` |
|
||
| B4 favorites system | #152 | `ef2cfb8` | 2026-06-14 | `migrations/1781442330004_reconcile-favorites-system.js` |
|
||
| B5 user profile | #153 | `ee7da9a` | 2026-06-14 | `migrations/1781442330005_reconcile-user-profile.js` |
|
||
| B6 user_cards UNIQUE | #149 | `40402eb` | 2026-06-14 | `migrations/1781442330006_reconcile-user-cards-unique.js` |
|
||
| B7 docs + runbook | _(this PR)_ | — | 2026-08-15 | _(no migration — docs only)_ |
|
||
|
||
All six implementer PRs were green at merge on the (then-flaky) self-hosted
|
||
axiom runner pool.
|
||
|
||
### Reservation-timestamp rename (`ec9bb2b`)
|
||
|
||
The architect pre-assigned timestamps `1781000000001` through
|
||
`1781000000006` to avoid `Date.now()` collisions during parallel
|
||
implementer dispatch. Commit `ec9bb2b` (`feat(catalog): unified multi-game
|
||
bulk sync + schema map update`) renamed all except B3 to
|
||
`1781442330001`–`1781442330006` so they run **after** the catalog-sync
|
||
migrations that landed in the same period:
|
||
|
||
- `1781440700404_add-scryfall-bulk-columns`
|
||
- `1781440721350_add-tagger-tables`
|
||
- `1781442175729_normalize-lorcana-set-codes`
|
||
- `1781442329511_add-catalog-sync-log`
|
||
|
||
B3 kept its original `1781000000003` timestamp because it merged after the
|
||
rename commit. The rename is filename-only; migration `up()` content is
|
||
identical.
|
||
|
||
### B6 deviation from architect plan
|
||
|
||
The architect's gate-1 plan collapsed Finding 1 (Outcome A) into B7 as a
|
||
doc-only note. Implementers shipped B6 anyway as a tiny belt-and-suspenders
|
||
migration (`1781442330006_reconcile-user-cards-unique.js`) that:
|
||
|
||
- Confirms the canonical 3-column `UNIQUE(user_id, card_id, is_foil)` from
|
||
`initial-schema` is present.
|
||
- Drops the historical 2-column `user_cards_user_card_unique` constraint if
|
||
any env ran `fix-user-cards-constraints.js`.
|
||
|
||
The migration is a no-op on prod (Outcome A already holds) but guards
|
||
future envs that might have the stricter 2-col variant.
|
||
|
||
### Verification
|
||
|
||
Manual operator runbook for confirming fresh-env vs prod schema parity:
|
||
[`docs/MIGRATION_VERIFICATION_RUNBOOK.md`](../docs/MIGRATION_VERIFICATION_RUNBOOK.md)
|
||
(per architect Decision D5). Automated CI verification remains queued as
|
||
`wire-migrate-into-ci`.
|
||
|
||
### B7 deviation from spec
|
||
|
||
B7 shipped ~2 months post-hoc (2026-08-15) rather than immediately after
|
||
B1–B6. The delay was operator-initiated (runner-infra pause) and did not
|
||
block migration correctness — only documentation closure.
|