---
convoy: reconcile-historical-add-scripts
brief_number: 2
depends_on: []
files:
  - migrations/1781000000002_reconcile-collections-columns.js
---

# Brief 2: Reconcile `collections` columns into migration history

## Goal (1 sentence)

Capture the `collections`-table DDL added by three historical scripts (`add-collaboration-features.js` columns half, `add-collection-slugs.js`, `add-image-column.js`) into a single new `node-pg-migrate` migration so a brand-new Neon branch ends up with `visibility`, `tcg`, `tags`, `slug` (+ index + CHECK constraint), and `image` columns on `collections` after `npm run setup-db`.

## Scope (files in scope — do not edit anything else)

- `migrations/1781000000002_reconcile-collections-columns.js` — **new**

## Source scripts (read-only audit reference; DO NOT EDIT — all three are no-go-zones)

### `scripts/add-collaboration-features.js` lines 15-20 (collections half only — the `collection_permissions` / `collection_activity` / `users.is_pending` half is Brief 3's scope):

```js
await 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
`;
```

Plus the visibility index at lines 77-80:

```js
await sql`
  CREATE INDEX IF NOT EXISTS idx_collections_visibility 
  ON collections(visibility)
`;
```

### `scripts/add-collection-slugs.js` lines 17-99 (relevant DDL only):

```js
// Step 1: Add slug column
await sql`
  ALTER TABLE collections 
  ADD COLUMN IF NOT EXISTS slug VARCHAR(100) UNIQUE
`;

// Step 5: Add unique index
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_slug ON collections(slug)`;

// Step 6: Add format CHECK constraint
await sql`ALTER TABLE collections ADD CONSTRAINT check_slug_format CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(slug) <= 50)`;
```

The script also backfills slugs via per-row UPDATE using `lib/slug-utils.js::generateUniqueSlug` (lines 41-73). **Do NOT fold the backfill DML into this migration.** Fresh envs have no pre-existing `collections` rows to backfill; on prod, the backfill ran once historically. Future migrations that need slug generation should run that logic in application code, not in a migration.

### `scripts/add-image-column.js` lines 14-17 (entire DDL):

```js
await sql`
  ALTER TABLE collections 
  ADD COLUMN IF NOT EXISTS image TEXT
`;
```

## Target migration file

Path: `migrations/1781000000002_reconcile-collections-columns.js`

Contents:

```js
/**
 * Reconcile three historical scripts that all added columns to the
 * `collections` table:
 *
 *   - scripts/add-collaboration-features.js (visibility, tcg, tags +
 *     idx_collections_visibility index) — collections-table half only;
 *     the collection_permissions / collection_activity / users.is_pending
 *     half is reconciled by migrations/1781000000003_reconcile-collaboration-tables.js
 *   - scripts/add-collection-slugs.js (slug + idx_collections_slug
 *     unique index + check_slug_format CHECK constraint)
 *   - scripts/add-image-column.js (image)
 *
 * Grouped into one migration per D1 of .convoys/reconcile-historical-add-scripts.md
 * (one migration per table/feature surface). All historical DDL was
 * idempotent (ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS);
 * this migration preserves that. CHECK constraint adds via a
 * DO $$ EXCEPTION block because Postgres doesn't accept
 * ADD CONSTRAINT ... IF NOT EXISTS for CHECK.
 *
 * Per-row slug backfill DML from add-collection-slugs.js is intentionally
 * NOT folded in — fresh envs have no pre-existing collections to
 * backfill; on prod, the backfill ran once historically and is baked in.
 *
 * Notes on adjacent state:
 *   - `is_system_collection` was added by migrations/1780378340194_system-collection-description.js
 *     (and the runtime register hook at pages/api/auth/register.js:97-119
 *     creates the per-user system collection row — Finding 4 RESOLVED in
 *     .convoys/reconcile-historical-add-scripts.md).
 *
 * Two-column-visibility smell: `is_public BOOLEAN` (from initial-schema)
 * and `visibility VARCHAR(20)` (from this migration) coexist on prod.
 * Surfaced as docs/SCHEMA_MAP.md § "Known schema smells" #2 → queued
 * `unify-collection-visibility` is OUT OF SCOPE here; this migration
 * adds `visibility` for parity, nothing more.
 *
 * Idempotent re-apply: every statement uses IF NOT EXISTS or the
 * exception-swallowing DO block.
 *
 * @type {import('node-pg-migrate').ColumnDefinitions | undefined}
 */
export const shorthands = undefined;

/**
 * @param {import('node-pg-migrate').MigrationBuilder} pgm
 */
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,
      ADD COLUMN IF NOT EXISTS slug VARCHAR(100) UNIQUE,
      ADD COLUMN IF NOT EXISTS image TEXT;

    CREATE INDEX IF NOT EXISTS idx_collections_visibility
      ON collections (visibility);

    CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_slug
      ON collections (slug);

    DO $$
    BEGIN
      ALTER TABLE collections
        ADD CONSTRAINT check_slug_format
        CHECK (slug ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' AND length(slug) <= 50);
    EXCEPTION
      WHEN duplicate_object THEN NULL;
    END $$;
  `);
};

/**
 * Down-migration intentionally throws. Removing these columns on
 * long-lived envs would drop user-curated tag / slug / image data
 * and break the runtime code that reads collections.visibility,
 * collections.slug, collections.image, collections.tcg, collections.tags.
 *
 * If a future schema correction needs to mutate any of these columns,
 * write a NEW dated migration with a real `down()` — do NOT roll back
 * this one.
 *
 * @returns {void}
 */
export const down = () => {
  throw new Error(
    '[migration:1781000000002_reconcile-collections-columns] Down not supported. ' +
      'Dropping collections.visibility / tcg / tags / slug / image would erase ' +
      'user-curated data and break runtime reads. Write a new dated migration ' +
      'for any future schema correction.'
  );
};
```

## Conventions to follow

- `.cursor/rules/db-and-schema.mdc` § "Schema source of truth" — one migration per table/feature surface.
- `.cursor/rules/no-go-zones.mdc` — all three source scripts are append-only history. **Do not edit any of them.**
- Style: raw `pgm.sql(...)` template literals, matching the other migrations under `migrations/`. Per D2 of the convoy file.
- ESM exports; `"type": "module"` per `package.json` line 5.
- `IF NOT EXISTS` on every ALTER and CREATE INDEX. CHECK constraint wraps in `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL END $$;` (Postgres doesn't accept `IF NOT EXISTS` directly on CHECK constraints).

## Acceptance criteria

- [ ] `migrations/1781000000002_reconcile-collections-columns.js` exists with the exact filename above (the timestamp `1781000000002` is the reservation token assigned by the architect — do NOT use `npm run migrate create`).
- [ ] The file's `up()` adds 5 columns (`visibility`, `tcg`, `tags`, `slug`, `image`) via `IF NOT EXISTS`, creates 2 indexes (`idx_collections_visibility`, unique `idx_collections_slug`) via `IF NOT EXISTS`, and adds the `check_slug_format` CHECK via a `DO $$ EXCEPTION` block.
- [ ] The file's `down()` throws with a clear message.
- [ ] The file's docstring cites all three source scripts + the convoy file + the visibility smell (#2 in SCHEMA_MAP).
- [ ] Per-row slug backfill DML is NOT in the migration (out of scope per the convoy plan).
- [ ] `node --check migrations/1781000000002_reconcile-collections-columns.js` passes.
- [ ] `node -e "import('./migrations/1781000000002_reconcile-collections-columns.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"` prints `function function undefined`.
- [ ] `npm run lint` matches the baseline (no new errors).
- [ ] `npm run test:run` reports 21/21 passing.

## Verification

Run from the convoy worktree BEFORE opening the PR:

```bash
node --check migrations/1781000000002_reconcile-collections-columns.js
node -e "import('./migrations/1781000000002_reconcile-collections-columns.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"
npm run lint
npm run test:run
```

Expected:
- `node --check` exits 0 silently.
- `node -e` prints `function function undefined`.
- `npm run lint` matches baseline.
- `npm run test:run` reports 21/21 passing.

**Do NOT** run `npm run migrate up` against any environment — that's the post-merge operator step covered by Brief 7's verification runbook.

## Commit message

```
feat(migrations): reconcile collections columns into migration history (brief 2/7)

Captures the DDL effect of three historical scripts into one new
node-pg-migrate migration:

  - scripts/add-collaboration-features.js (collections half: visibility,
    tcg, tags + idx_collections_visibility)
  - scripts/add-collection-slugs.js (slug + idx_collections_slug unique
    + check_slug_format CHECK constraint)
  - scripts/add-image-column.js (image)

The collaboration tables half (collection_permissions, collection_activity,
users.is_pending) is reconciled separately by Brief 3.

Per-row slug backfill DML from add-collection-slugs.js is intentionally
NOT folded in — fresh envs have no pre-existing collections to backfill.

Per .convoys/reconcile-historical-add-scripts.md § Brief outline → B2.
Idempotent re-apply (IF NOT EXISTS guards; DO $$ EXCEPTION for CHECK).
```

## PR shape

**Title:** `feat(migrations): reconcile collections columns into migration history (brief 2/7)`

**Body template:**

```markdown
Brief 2 of the `reconcile-historical-add-scripts` convoy. See
[`.convoys/reconcile-historical-add-scripts.md`](../.convoys/reconcile-historical-add-scripts.md)
for the full plan and rationale.

## What this PR does

Adds `migrations/1781000000002_reconcile-collections-columns.js` —
captures the DDL effect of three historical scripts in a single new
`node-pg-migrate` migration:

- `scripts/add-collaboration-features.js` — collections columns half
  (`visibility VARCHAR(20)`, `tcg VARCHAR(50)`, `tags TEXT`,
  `idx_collections_visibility` index)
- `scripts/add-collection-slugs.js` — `slug VARCHAR(100) UNIQUE`,
  `idx_collections_slug` unique index, `check_slug_format` CHECK
  constraint
- `scripts/add-image-column.js` — `image TEXT`

All ALTERs use `ADD COLUMN IF NOT EXISTS`. The CHECK constraint wraps
in a `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL END $$;`
block because Postgres doesn't accept `ADD CONSTRAINT ... IF NOT EXISTS`
for CHECK.

## What this PR does NOT do

- Does **NOT** edit any of the three source scripts (no-go-zones).
- Does **NOT** capture the collaboration tables half of
  `add-collaboration-features.js` (`collection_permissions`,
  `collection_activity`, `users.is_pending`, related indexes,
  owner-permission backfill DML) — that's **Brief 3**.
- Does **NOT** fold in the per-row slug backfill DML from
  `add-collection-slugs.js` — fresh envs have no pre-existing
  collections to backfill.
- Does **NOT** unify the `is_public` / `visibility` redundancy
  (SCHEMA_MAP smell #2) — that's a future `unify-collection-visibility`
  follow-up.
- Does **NOT** edit `scripts/setup-neon-db.js`, `package.json`, README,
  or AGENTS.md.
- Does **NOT** run `npm run migrate up` against any environment.

## Verification checklist

- [ ] `node --check migrations/1781000000002_reconcile-collections-columns.js` exits 0
- [ ] `node -e "import('./migrations/1781000000002_reconcile-collections-columns.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"` prints `function function undefined`
- [ ] `npm run lint` matches baseline
- [ ] `npm run test:run` reports 21/21 passing
- [ ] Did not run `npm run migrate up` against any environment in this PR

## Cross-references

- Convoy file: `.convoys/reconcile-historical-add-scripts.md`
- Source scripts (no-go-zones, audit reference only):
  `scripts/add-collaboration-features.js`,
  `scripts/add-collection-slugs.js`,
  `scripts/add-image-column.js`
```

## DO NOT

- DO NOT edit `scripts/add-collaboration-features.js`, `scripts/add-collection-slugs.js`, `scripts/add-image-column.js`, or any other file under `scripts/` — append-only no-go-zone.
- DO NOT edit any other file under `migrations/`.
- DO NOT edit `scripts/setup-neon-db.js`, `package.json`, README, `AGENTS.md`, or `docs/SCHEMA_MAP.md` (B7 owns SCHEMA_MAP).
- DO NOT run `npm run migrate up` against any environment.
- DO NOT include the per-row slug backfill DML — out of scope.
- DO NOT add work for `collection_permissions` / `collection_activity` / `users.is_pending` — that's Brief 3.
- DO NOT call `npm run migrate create` — it would generate a `Date.now()` timestamp colliding with B1/B3/B4/B5's reservation tokens.

## Rationale (≤3 sentences)

Grouping all `collections`-table DDL into one migration matches the per-table grouping of D1 and keeps the diff focused on a single surface. Splitting `add-collaboration-features.js` between this brief (columns) and Brief 3 (tables) avoids creating an artificial dependency between two parallel briefs — each writes only its own new file. The CHECK-constraint exception block matches the historical script's try/catch pattern and is the idiomatic Postgres way to do idempotent CHECK adds.
