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