Compare commits

...

1 commit

Author SHA1 Message Date
Randall Stillwell
77be685d2e feat(migrations): reconcile collaboration tables missed by initial-schema backfill (B3)
Captures the DDL half of scripts/add-collaboration-features.js that the
initial-schema backfill missed:

  - collection_permissions (collaboration roles + invite tokens)
  - collection_activity (audit-trail with JSONB details)
  - users.is_pending (column for invited-but-not-yet-accepted users)
  - 4 indexes for query hot-paths

lib/permission-middleware.js reads collection_permissions in
withCollectionPermission and writes collection_activity from
logCollectionActivity, so a fresh Neon branch onboarded by
`npm run setup-db` MUST land these tables. Prod was brought to this
shape by add-collaboration-features.js running historically; this
migration brings fresh envs to parity per
.convoys/reconcile-historical-add-scripts.md Brief outline -> B3.

Idempotent against fresh and existing envs:
CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / CREATE INDEX
IF NOT EXISTS so re-application against any post-historical-script env
is a no-op except recording the pgmigrations row.

Owner-permission DML backfill is intentionally NOT captured - fresh
envs have no pre-existing collections needing backfill, and prod's
backfill is already applied.

down() is a hard stub matching the initial-schema pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:18:44 -05:00

View file

@ -0,0 +1,101 @@
/**
* Reconcile collaboration tables missed by the initial-schema backfill.
*
* Captures the DDL half of `scripts/add-collaboration-features.js` (a
* historical no-go-zone script) that the `initial-schema` backfill missed:
*
* 1. `collection_permissions` (collaboration roles + invite tokens)
* 2. `collection_activity` (audit-trail with JSONB details)
* 3. `users.is_pending` (column for invited-but-not-yet-accepted users)
* 4. 4 indexes for query hot-paths
*
* `lib/permission-middleware.js` reads `collection_permissions` in
* `withCollectionPermission` and writes `collection_activity` from
* `logCollectionActivity` both are runtime invariants, so a fresh Neon
* branch onboarded by `npm run setup-db` MUST land these tables. Prod was
* brought to this shape by `add-collaboration-features.js` running
* historically; this migration brings fresh envs to parity per
* `.convoys/reconcile-historical-add-scripts.md` § Brief outline B3.
*
* Idempotent against:
* - A brand-new Neon branch (creates everything fresh).
* - Any long-lived env where `add-collaboration-features.js` already ran
* (every CREATE TABLE / ADD COLUMN / CREATE INDEX is `IF NOT EXISTS`-
* guarded no-op except recording the `pgmigrations` row).
*
* The owner-permission DML backfill from the historical script is NOT
* captured: a fresh env has no pre-existing collections needing
* back-fill, and prod's backfill is already applied.
*
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
* @returns {void}
*/
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)
)
`);
pgm.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
)
`);
pgm.sql(`
ALTER TABLE users
ADD COLUMN IF NOT EXISTS is_pending BOOLEAN DEFAULT false
`);
pgm.sql(`
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);
CREATE INDEX IF NOT EXISTS idx_collections_visibility
ON collections(visibility);
`);
};
/**
* Down-migration is a hard stub. Dropping `collection_permissions` /
* `collection_activity` would erase every collaboration role assignment
* and every audit-trail row in the database; dropping `users.is_pending`
* would discard the invited-user state for every pending invite. If a
* future schema correction needs to mutate one of these surfaces, write a
* NEW dated migration with a real `down` do NOT remove this stub.
*
* @returns {void}
*/
export const down = () => {
throw new Error(
'[migration:1781000000003_reconcile-collaboration-tables] Refusing to drop ' +
'collection_permissions / collection_activity / users.is_pending. Rolling back ' +
'this migration would erase every collaboration role assignment, every audit-trail ' +
'row, and every invited-user pending state in the database. If you need a clean ' +
'schema for testing, branch the Neon database and run `npm run migrate up` against ' +
'the branch instead.'
);
};