Ships Brief 7 (documentation + verification runbook) of the
reconcile-historical-add-scripts convoy, ~2 months post-hoc. The
6 implementer briefs (B1-B6) landed 2026-06-14 to 2026-07-06 via
PRs #148, #149, #150, #151, #152, #153. This PR closes the loop:
- Brings the architect's parent convoy file + 6 brief files onto
main (they only existed on the stale convoy/reconcile-historical-
add-scripts branch, never merged)
- Adds § As-shipped to the parent convoy file documenting all 6
squash SHAs + PR numbers + merge dates + the reservation-timestamp
rename (1781000000001-006 → 1781442330001-006 in ec9bb2b, except
B3 which kept its original) + the B6 shipped-as-tiny-migration
deviation from the collapse-to-docs plan
- Fixes docs/SCHEMA_MAP.md § user_favorites (was stale
(user_id, card_id); actual polymorphic (item_type, item_id) per
B4's migration)
- Adds docs/MIGRATION_VERIFICATION_RUNBOOK.md — manual
fresh-Neon-branch vs prod pg_dump diff runbook per architect D5
- Flips .convoys/ship-readiness.md entries:
- reconcile-historical-add-scripts → RESOLVED
- retire-graveyard-scripts-after-audit → UNBLOCKED
- Adds two new queued follow-ups surfaced by the architect:
- unify-user-avatar-column (P3 — dual avatar column smell)
- drop-dead-cards-columns (P3 — cards.quantity + cards.favorited)
No source-code changes. Docs only.
Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
13 KiB
Markdown
307 lines
13 KiB
Markdown
---
|
|
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.
|