feat(migrations): reconcile collections columns missed by initial-schema backfill (B2) (#150)
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 <cursoragent@cursor.com>
2026-06-14 09:18:05 -04:00
|
|
|
/**
|
|
|
|
|
* 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`,
|
2026-08-14 21:32:10 -04:00
|
|
|
* `idx_collection_permissions_*`, `idx_collection_activity_collection_id`
|
|
|
|
|
* — B3 (`reconcile-collaboration-tables`).
|
|
|
|
|
* `idx_collections_visibility` — ships in
|
|
|
|
|
* `1786757312884_add-collections-visibility-index` (B3's timestamp
|
|
|
|
|
* precedes this file's `visibility` column add).
|
feat(migrations): reconcile collections columns missed by initial-schema backfill (B2) (#150)
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 <cursoragent@cursor.com>
2026-06-14 09:18:05 -04:00
|
|
|
* - `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.'
|
|
|
|
|
);
|
|
|
|
|
};
|