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>
318 lines
14 KiB
Markdown
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.
|