deckhearth/.convoys/reconcile-historical-add-scripts/brief-3-reconcile-collaboration-tables.md
varutasu 0d52858bbd
docs: post-hoc B7 closure for reconcile-historical-add-scripts (#163)
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>
2026-08-15 16:31:37 -05:00

318 lines
14 KiB
Markdown

---
convoy: reconcile-historical-add-scripts
brief_number: 3
depends_on: []
files:
- migrations/1781000000003_reconcile-collaboration-tables.js
---
# Brief 3: Reconcile collaboration tables into migration history
## Goal (1 sentence)
Capture the `collection_permissions` + `collection_activity` table creation, the `users.is_pending` column, and the 3 related indexes from `scripts/add-collaboration-features.js` into a single new `node-pg-migrate` migration so a brand-new Neon branch ends up with the collaboration / sharing surface after `npm run setup-db`.
## Scope (files in scope — do not edit anything else)
- `migrations/1781000000003_reconcile-collaboration-tables.js`**new**
## Source script (read-only audit reference; DO NOT EDIT — no-go-zone)
`scripts/add-collaboration-features.js` lines 24-81 (relevant DDL only; the collections-columns half is reconciled by Brief 2):
```js
// collection_permissions table
await sql`
CREATE TABLE IF NOT EXISTS collection_permissions (
id SERIAL PRIMARY KEY,
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'pending', 'declined')),
invite_token VARCHAR(255) UNIQUE,
invited_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, user_id)
)
`;
// collection_activity table
await sql`
CREATE TABLE IF NOT EXISTS collection_activity (
id SERIAL PRIMARY KEY,
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`;
// users.is_pending column
await sql`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT false
`;
// 3 indexes (the 4th — idx_collections_visibility — is Brief 2's scope)
await sql`
CREATE INDEX IF NOT EXISTS idx_collection_permissions_collection_id
ON collection_permissions(collection_id)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_collection_permissions_user_id
ON collection_permissions(user_id)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_collection_activity_collection_id
ON collection_activity(collection_id)
`;
```
**Owner-permission backfill DML** at lines 84-99 inserts `('owner', 'active')` rows for every pre-existing collection. **Do NOT fold the backfill DML into this migration.** Fresh envs have no pre-existing collections needing backfill; on prod, the backfill ran once historically and is baked in. The current runtime invariant for new collection creation lives in `pages/api/collections.js` (verify post-merge if needed — out of scope for this brief).
## Target migration file
Path: `migrations/1781000000003_reconcile-collaboration-tables.js`
Contents:
```js
/**
* Reconcile the collaboration / sharing half of
* scripts/add-collaboration-features.js into the migration history:
*
* - CREATE TABLE collection_permissions (with role/status CHECK
* constraints inline + invite_token UNIQUE + UNIQUE(collection_id,
* user_id))
* - CREATE TABLE collection_activity (with JSONB details column)
* - ALTER users ADD COLUMN is_pending BOOLEAN DEFAULT false
* - 3 indexes (idx_collection_permissions_collection_id,
* idx_collection_permissions_user_id,
* idx_collection_activity_collection_id)
*
* The 4th index from the source script (idx_collections_visibility)
* is reconciled by migrations/1781000000002_reconcile-collections-columns.js
* because it indexes a column added in that brief.
*
* The collections-columns half (visibility, tcg, tags) of
* add-collaboration-features.js is reconciled by
* migrations/1781000000002_reconcile-collections-columns.js.
*
* The owner-permission backfill DML from the source script (INSERT
* INTO collection_permissions ... 'owner', 'active' for every
* pre-existing collection) 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. The runtime
* invariant for owner-permission creation on new collections is the
* responsibility of pages/api/collections.js (out of scope here).
*
* Both CREATE TABLE statements use IF NOT EXISTS, with CHECK
* constraints declared inline (no idempotency issue — IF NOT EXISTS
* on the parent table makes the whole CREATE a no-op when the table
* already exists, CHECK constraints and all).
*
* Idempotent re-apply: every statement uses IF NOT EXISTS.
*
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS collection_permissions (
id SERIAL PRIMARY KEY,
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')),
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'pending', 'declined')),
invite_token VARCHAR(255) UNIQUE,
invited_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, user_id)
);
CREATE TABLE IF NOT EXISTS collection_activity (
id SERIAL PRIMARY KEY,
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL,
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_collection_permissions_collection_id
ON collection_permissions (collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_permissions_user_id
ON collection_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_collection_activity_collection_id
ON collection_activity (collection_id);
`);
};
/**
* Down-migration intentionally throws. Dropping collection_permissions
* + collection_activity on a long-lived env would erase every active
* sharing relationship + every audit trail row. The runtime in
* pages/api/collections/[identifier]/permissions.js, pages/api/invite/*.js,
* and lib/permission-middleware.js all read these tables; rolling back
* would break the live app.
*
* @returns {void}
*/
export const down = () => {
throw new Error(
'[migration:1781000000003_reconcile-collaboration-tables] Down not supported. ' +
'Dropping collection_permissions / collection_activity would erase every ' +
'sharing relationship and audit trail, and break runtime reads in ' +
'pages/api/collections/[identifier]/permissions.js, pages/api/invite/*.js, ' +
'lib/permission-middleware.js. Write a new dated migration for any future ' +
'schema correction.'
);
};
```
## Conventions to follow
- `.cursor/rules/db-and-schema.mdc` § "Schema source of truth" — `migrations/` is canonical; one migration per feature surface.
- `.cursor/rules/no-go-zones.mdc``scripts/add-collaboration-features.js` is append-only history. **Do not edit it.**
- Style: raw `pgm.sql(...)` template literals matching the other migrations. Per D2.
- ESM exports; `"type": "module"`.
- `IF NOT EXISTS` on every CREATE / ALTER. Inline CHECK constraints on `CREATE TABLE` are fine — `CREATE TABLE IF NOT EXISTS` skips the entire statement (constraints and all) when the table exists.
- FK declarations match the source script verbatim (`ON DELETE CASCADE` for primary FKs, `ON DELETE SET NULL` where the source uses it).
## Acceptance criteria
- [ ] `migrations/1781000000003_reconcile-collaboration-tables.js` exists with the exact filename above.
- [ ] The file's `up()`:
- Creates `collection_permissions` table with all 10 columns + role/status CHECKs + invite_token UNIQUE + UNIQUE(collection_id, user_id), all via `CREATE TABLE IF NOT EXISTS`.
- Creates `collection_activity` table with 6 columns including JSONB `details`, via `CREATE TABLE IF NOT EXISTS`.
- Adds `users.is_pending BOOLEAN DEFAULT false` via `ADD COLUMN IF NOT EXISTS`.
- Creates 3 indexes via `CREATE INDEX IF NOT EXISTS`.
- [ ] The 4th index from the source script (`idx_collections_visibility`) is NOT in this migration — it belongs to Brief 2.
- [ ] The owner-permission backfill DML is NOT in this migration.
- [ ] The file's `down()` throws with a clear message.
- [ ] The file's docstring cites the source script + the convoy file + the Brief 2 split.
- [ ] `node --check migrations/1781000000003_reconcile-collaboration-tables.js` passes.
- [ ] `node -e "import('./migrations/1781000000003_reconcile-collaboration-tables.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.
## Verification
```bash
node --check migrations/1781000000003_reconcile-collaboration-tables.js
node -e "import('./migrations/1781000000003_reconcile-collaboration-tables.js').then(m => console.log(typeof m.up, typeof m.down, typeof m.shorthands))"
npm run lint
npm run test:run
```
Expected: `node --check` silent, `node -e` prints `function function undefined`, lint baseline, 21/21 tests pass.
**Do NOT** run `npm run migrate up` against any environment.
## Commit message
```
feat(migrations): reconcile collaboration tables into migration history (brief 3/7)
Captures the collaboration / sharing half of
scripts/add-collaboration-features.js into one new node-pg-migrate
migration:
- CREATE TABLE collection_permissions (role/status CHECKs,
invite_token UNIQUE, UNIQUE(collection_id, user_id))
- CREATE TABLE collection_activity (JSONB details)
- ALTER users ADD COLUMN is_pending BOOLEAN DEFAULT false
- 3 indexes
The collections-columns half (visibility, tcg, tags +
idx_collections_visibility) is reconciled by Brief 2. The 4th index
(idx_collections_visibility) belongs to Brief 2 because it indexes a
column added there.
Per-row owner-permission backfill DML from the source script is
intentionally NOT folded in — fresh envs have no pre-existing
collections to backfill; the runtime invariant for new-collection
owner-perm creation lives in pages/api/collections.js.
Per .convoys/reconcile-historical-add-scripts.md § Brief outline → B3.
Idempotent re-apply (IF NOT EXISTS guards).
```
## PR shape
**Title:** `feat(migrations): reconcile collaboration tables into migration history (brief 3/7)`
**Body template:**
```markdown
Brief 3 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/1781000000003_reconcile-collaboration-tables.js`
captures the collaboration / sharing DDL of
`scripts/add-collaboration-features.js` (the table-and-column half;
the columns-on-collections half is Brief 2).
- `CREATE TABLE IF NOT EXISTS collection_permissions` (10 columns
including inline role/status CHECK constraints + invite_token
UNIQUE + UNIQUE(collection_id, user_id))
- `CREATE TABLE IF NOT EXISTS collection_activity` (6 columns
including JSONB `details`)
- `ALTER TABLE users ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT false`
- 3 indexes via `CREATE INDEX IF NOT EXISTS`
## What this PR does NOT do
- Does **NOT** edit `scripts/add-collaboration-features.js` (no-go-zone).
- Does **NOT** capture the columns-on-collections half (`visibility`,
`tcg`, `tags`, `idx_collections_visibility`) — that's **Brief 2**.
- Does **NOT** fold in the per-row owner-permission backfill DML from
the source script — fresh envs have no pre-existing collections to
backfill.
- Does **NOT** edit `scripts/setup-neon-db.js`, `package.json`, README,
`AGENTS.md`, or `docs/SCHEMA_MAP.md` (B7 owns SCHEMA_MAP).
- Does **NOT** run `npm run migrate up` against any environment.
## Verification checklist
- [ ] `node --check migrations/1781000000003_reconcile-collaboration-tables.js` exits 0
- [ ] `node -e "import('./migrations/1781000000003_reconcile-collaboration-tables.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 script (no-go-zone, audit reference only):
`scripts/add-collaboration-features.js`
```
## DO NOT
- DO NOT edit `scripts/add-collaboration-features.js` or any file under `scripts/`.
- 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`.
- DO NOT run `npm run migrate up` against any environment.
- DO NOT include the owner-permission backfill DML.
- DO NOT add work for the collections-columns half (`visibility`, `tcg`, `tags`, `idx_collections_visibility`) — that's Brief 2.
- DO NOT call `npm run migrate create`.
## Rationale (≤3 sentences)
Splitting `add-collaboration-features.js` between Brief 2 (collections columns + the visibility index that indexes one of those columns) and Brief 3 (collaboration tables + the users.is_pending column + the 3 indexes that index collaboration-table columns) keeps each migration scoped to the table surface it touches, matching D1. Inline CHECK constraints on `CREATE TABLE` are idempotent for free under `CREATE TABLE IF NOT EXISTS` (the whole statement no-ops when the table exists). The backfill DML is intentionally out of scope because fresh envs need no backfill and prod's backfill already ran.