deckhearth/.convoys/reconcile-historical-add-scripts/brief-3-reconcile-collaboration-tables.md
Randall Stillwell 1db6d4dd60 convoy(architect): draft B1-B5 + B7 implementer briefs (Finding 1 → A)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:52:04 -05:00

14 KiB

convoy brief_number depends_on files
reconcile-historical-add-scripts 3
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.jsnew

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):

// 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:

/**
 * 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.mdcscripts/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

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:

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.