---
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.
