From e8619c583648a5154f83358dbc55caf74ec03fbb Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Sun, 14 Jun 2026 08:18:05 -0500 Subject: [PATCH] feat(migrations): reconcile collections columns missed by initial-schema backfill (B2) (#150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the `collections`-table DDL that historical scripts added to prod but `migrations/1779853647564_initial-schema.js` did not capture: - `visibility VARCHAR(20) DEFAULT 'private'` (scripts/add-collaboration-features.js, lines 15-21 — collections half only) - `tcg VARCHAR(50) DEFAULT 'MTG'` (scripts/add-collaboration-features.js, lines 15-21 — collections half only) - `tags TEXT` (scripts/add-collaboration-features.js, lines 15-21 — collections half only) - `slug VARCHAR(100) UNIQUE` (scripts/add-collection-slugs.js, lines 17-20) - `idx_collections_slug` UNIQUE INDEX (scripts/add-collection-slugs.js, line 79) - `check_slug_format` CHECK constraint (scripts/add-collection-slugs.js, line 91) - `image TEXT` (scripts/add-image-column.js, lines 14-17) Idempotency (D2): every statement is `IF NOT EXISTS`-guarded (ADD COLUMN IF NOT EXISTS, CREATE UNIQUE INDEX IF NOT EXISTS, plus a DO $$ pg_constraint guard for the CHECK since Postgres has no native IF NOT EXISTS clause for named constraints). Safe against fresh, prod, and re-apply. Down() is a hard stub matching initial-schema style — these columns hold visibility flags, slugs, tcg labels, tags, and images that production collections rely on at every page render. Out-of-scope per architect plan (B3 territory): collection_permissions, collection_activity, users.is_pending, idx_collections_visibility, and the 3 idx_collection_* indexes. Out-of-scope per architect plan (already captured): is_system_collection (in 1780378340194). Deferred DML: per-row slug backfill from `name` via `lib/slug-utils.js::generateUniqueSlug`. Generating slugs on a fresh env is moot (no pre-existing collections); operators of long-lived envs already ran the backfill historically. Static idempotency proof — grep confirms each B2 column/constraint is defined exactly ONCE across all 8 existing migrations: $ grep -nE "(visibility|tcg|^.*tags TEXT|slug VARCHAR|^.*image TEXT|check_slug_format|idx_collections_slug)" migrations/*.js migrations/1781000000002_reconcile-collections-columns.js (sole owner) The `image_url` / `stock_image_url` matches in `1779853647564_initial-schema.js` are on the `cards` table, not `collections`. The `tags` table created in `1781440721350_add-tagger-tables.js` is a separate table from this migration's `collections.tags` column. Verification: node --check ✅, npm run lint ✅ (0 errors, baseline 1 unrelated warning), npm run test:run ✅ (131/131). Live Neon-branch verification deferred to operator runbook (D5 of the convoy plan). Convoy: reconcile-historical-add-scripts Brief: B2 Pre-assigned timestamp: 1781000000002 Co-authored-by: Cursor --- ...000000002_reconcile-collections-columns.js | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 migrations/1781000000002_reconcile-collections-columns.js diff --git a/migrations/1781000000002_reconcile-collections-columns.js b/migrations/1781000000002_reconcile-collections-columns.js new file mode 100644 index 0000000..b8f7439 --- /dev/null +++ b/migrations/1781000000002_reconcile-collections-columns.js @@ -0,0 +1,114 @@ +/** + * Reconcile-historical-add-scripts convoy — Brief 2. + * + * Backfills the `collections`-table columns that historical scripts added to + * prod but which `migrations/1779853647564_initial-schema.js` never captured: + * + * - `visibility VARCHAR(20) DEFAULT 'private'` (from `scripts/add-collaboration-features.js`, lines 15-21 — collections half only) + * - `tcg VARCHAR(50) DEFAULT 'MTG'` (from `scripts/add-collaboration-features.js`, lines 15-21 — collections half only) + * - `tags TEXT` (from `scripts/add-collaboration-features.js`, lines 15-21 — collections half only) + * - `slug VARCHAR(100) UNIQUE` (from `scripts/add-collection-slugs.js`, lines 17-20) + * - `idx_collections_slug` UNIQUE INDEX (from `scripts/add-collection-slugs.js`, line 79) + * - `check_slug_format` CHECK constraint (from `scripts/add-collection-slugs.js`, line 91) + * - `image TEXT` (from `scripts/add-image-column.js`, lines 14-17) + * + * Excluded from B2 (out of scope per architect inventory): + * - `collection_permissions`, `collection_activity`, `users.is_pending`, + * `idx_collections_visibility`, `idx_collection_permissions_*`, + * `idx_collection_activity_collection_id` — these belong to B3 + * (`reconcile-collaboration-tables`) per the architect plan. + * - `is_system_collection` — already captured by + * `migrations/1780378340194_system-collection-description.js`. + * + * Excluded DML: per-row UPDATE that backfills slugs from `name` via + * `lib/slug-utils.js::generateUniqueSlug` (script lines 41-74). Generating + * slugs on a fresh env is moot — there are no pre-existing collections to + * backfill — and replicating the runtime helper inside a migration would + * couple the schema timeline to application code. Operators of long-lived + * envs already ran the backfill historically; this migration does NOT + * re-run it. + * + * Idempotency strategy (D2): every statement is `IF NOT EXISTS`-guarded. + * + * - `ADD COLUMN IF NOT EXISTS` — Postgres native; the `UNIQUE` inline + * clause on `slug` is ignored when the column already exists, so prod + * (which already has the autogen `collections_slug_key` constraint) + * and fresh envs (where the autogen constraint will be added) both + * converge correctly. + * - `CREATE UNIQUE INDEX IF NOT EXISTS` — Postgres native. + * - `ALTER TABLE ... ADD CONSTRAINT` for CHECK — Postgres has no + * `IF NOT EXISTS` clause for named constraints, so we wrap in a + * `DO $$ BEGIN ... END $$` block that checks `pg_constraint` first. + * + * Down-migration is a hard stub. See `down()` below — matches + * `1779853647564_initial-schema.js` style. + * + * @type {import('node-pg-migrate').ColumnDefinitions | undefined} + */ +export const shorthands = undefined; + +/** + * @param {import('node-pg-migrate').MigrationBuilder} pgm + * @returns {void} + */ +export const up = (pgm) => { + pgm.sql(` + ALTER TABLE collections + ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'private', + ADD COLUMN IF NOT EXISTS tcg VARCHAR(50) DEFAULT 'MTG', + ADD COLUMN IF NOT EXISTS tags TEXT + `); + + pgm.sql(` + ALTER TABLE collections + ADD COLUMN IF NOT EXISTS image TEXT + `); + + pgm.sql(` + ALTER TABLE collections + ADD COLUMN IF NOT EXISTS slug VARCHAR(100) UNIQUE + `); + + pgm.sql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_slug + ON collections(slug) + `); + + pgm.sql(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'check_slug_format' + AND conrelid = 'collections'::regclass + ) THEN + ALTER TABLE collections + ADD CONSTRAINT check_slug_format + CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(slug) <= 50); + END IF; + END + $$; + `); +}; + +/** + * Down-migration is a hard stub. This is a reconciliation migration: the + * prod schema state is the source of truth, and dropping these columns + * would discard slugs / visibility flags / tcg labels / tags / images that + * application code reads on every collection page render. If you need a + * clean schema for testing, branch the Neon database and run + * `npm run migrate up` against the branch instead of rolling this back. + * + * @returns {void} + */ +export const down = () => { + throw new Error( + '[migration:1781000000002_reconcile-collections-columns] Refusing to drop reconciled columns. ' + + 'Rolling back this migration would erase visibility/tcg/tags/slug/image data on every ' + + 'collections row in the database. If you need a clean schema for testing, branch the ' + + 'Neon database and run `npm run migrate up` against the branch instead. See ' + + 'migrations/1781000000002_reconcile-collections-columns.js up() docstring for the ' + + 'reconciliation rationale.' + ); +};