§22 S1: migration 029 — collections grain, field move-down, 13-table re-key, memberships

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 07:58:39 -07:00
parent 08bdea8539
commit 867f2504d6
2 changed files with 566 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
-- migrate:no-foreign-keys
--
-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project.
--
-- (1) a `collections` table beneath `projects`;
-- (2) move the per-corpus fields (type, initial_state) down from `projects`
-- (projects keeps id, name, content_repo, visibility, config_json, …);
-- (3) one default collection per project (id='default' for the standard
-- single-project deployment, subfolder = repo root), inheriting the
-- project's type / initial_state / visibility;
-- (4) re-key the 13 entry-corpus tables (project_id, slug) -> (collection_id,
-- slug) via the migration-028 rebuild pattern, mapping each row to its
-- project's default collection by JOIN;
-- (5) generalise project_members -> memberships(scope_type ∈ {project,
-- collection}, scope_id, …), collapsing the role enum to {owner,
-- contributor} (§B.3).
--
-- SQLite can't ALTER a PK/UNIQUE in place, so each keyed table is rebuilt by the
-- official create-copy-drop-rename procedure. FK enforcement is OFF for the file
-- (the `migrate:no-foreign-keys` marker tells the runner to toggle it and run
-- foreign_key_check after). cached_rfcs is rebuilt FIRST so the child tables can
-- re-point their composite FK at its new (collection_id, slug) key.
--
-- The tables 026 tagged with project_id but 028 did NOT key (threads, changes,
-- notifications, actions, pr_resolution_branches, cached_prs) keep project_id —
-- they carry a project-grain tag, untouched in S1. See
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
-- ── collections: the new typed-corpus grain beneath projects ───────────────
CREATE TABLE collections (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'document'
CHECK (type IN ('document', 'specification', 'bdd')),
subfolder TEXT NOT NULL DEFAULT '',
initial_state TEXT NOT NULL DEFAULT 'super-draft'
CHECK (initial_state IN ('super-draft', 'active')),
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
name TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (id)
);
CREATE INDEX idx_collections_project ON collections(project_id);
-- One default collection per project. id='default' for the standard
-- single-project deployment (a stable literal across deploy histories); the
-- project_id is used as a unique fallback id only if a non-standard
-- multi-project deployment migrates (pre-S5; avoids a PK collision).
INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name)
SELECT
CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END,
p.id, p.type, '', p.initial_state, p.visibility, p.name
FROM projects p;
-- ── projects: rebuild to DROP the per-corpus fields (type, initial_state) ───
CREATE TABLE projects__new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content_repo TEXT,
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
config_json TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at)
SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects;
DROP TABLE projects;
ALTER TABLE projects__new RENAME TO projects;
-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ────
CREATE TABLE cached_rfcs__new (
slug TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT,
repo TEXT,
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT,
funder_login TEXT,
proposed_use_case TEXT,
collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id),
unreviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
reviewed_by TEXT,
PRIMARY KEY (collection_id, slug)
);
INSERT INTO cached_rfcs__new
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at,
graduated_by, owners_json, arbiters_json, tags_json, body, body_sha,
last_main_commit_at, last_entry_commit_at, updated_at, models_json,
funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by)
SELECT
r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at,
r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha,
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json,
r.funder_login, r.proposed_use_case,
(SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1),
r.unreviewed, r.reviewed_at, r.reviewed_by
FROM cached_rfcs r;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id);
-- ── rfc_invitations: single-col FK -> composite (collection_id, rfc_slug) ───
CREATE TABLE rfc_invitations__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_invitations__new
(id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token,
expires_at, created_at, accepted_at, accepted_by_user_id, collection_id)
SELECT
i.id, i.rfc_slug, i.inviter_user_id, i.invitee_email, i.role_in_rfc, i.status, i.token,
i.expires_at, i.created_at, i.accepted_at, i.accepted_by_user_id,
(SELECT c.id FROM collections c WHERE c.project_id = i.project_id LIMIT 1)
FROM rfc_invitations i;
DROP TABLE rfc_invitations;
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
-- ── cached_branches: UNIQUE (project_id, rfc_slug, branch_name) -> collection
CREATE TABLE cached_branches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name)
);
INSERT INTO cached_branches__new
(id, rfc_slug, branch_name, head_sha, state, pinned, created_at, last_commit_at, closed_at, collection_id)
SELECT
b.id, b.rfc_slug, b.branch_name, b.head_sha, b.state, b.pinned, b.created_at, b.last_commit_at, b.closed_at,
(SELECT c.id FROM collections c WHERE c.project_id = b.project_id LIMIT 1)
FROM cached_branches b;
DROP TABLE cached_branches;
ALTER TABLE cached_branches__new RENAME TO cached_branches;
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- ── branch_visibility: UNIQUE (project_id, rfc_slug, branch_name) -> collection
CREATE TABLE branch_visibility__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name)
);
INSERT INTO branch_visibility__new
(id, rfc_slug, branch_name, read_public, contribute_mode, collection_id)
SELECT
v.id, v.rfc_slug, v.branch_name, v.read_public, v.contribute_mode,
(SELECT c.id FROM collections c WHERE c.project_id = v.project_id LIMIT 1)
FROM branch_visibility v;
DROP TABLE branch_visibility;
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
-- ── branch_contribute_grants: UNIQUE (..., grantee) -> +collection_id ───────
CREATE TABLE branch_contribute_grants__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, rfc_slug, branch_name, grantee_user_id)
);
INSERT INTO branch_contribute_grants__new
(id, rfc_slug, branch_name, grantee_user_id, granted_by, granted_at, collection_id)
SELECT
g.id, g.rfc_slug, g.branch_name, g.grantee_user_id, g.granted_by, g.granted_at,
(SELECT c.id FROM collections c WHERE c.project_id = g.project_id LIMIT 1)
FROM branch_contribute_grants g;
DROP TABLE branch_contribute_grants;
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- ── stars: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ──────────
CREATE TABLE stars__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug)
);
INSERT INTO stars__new (id, user_id, rfc_slug, starred_at, collection_id)
SELECT s.id, s.user_id, s.rfc_slug, s.starred_at,
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
FROM stars s;
DROP TABLE stars;
ALTER TABLE stars__new RENAME TO stars;
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
-- ── watches: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ────────
CREATE TABLE watches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT,
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug)
);
INSERT INTO watches__new
(id, user_id, rfc_slug, state, set_by, set_at, last_participation_at, collection_id)
SELECT
w.id, w.user_id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at,
(SELECT c.id FROM collections c WHERE c.project_id = w.project_id LIMIT 1)
FROM watches w;
DROP TABLE watches;
ALTER TABLE watches__new RENAME TO watches;
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
-- ── pr_seen: UNIQUE (project_id, user_id, rfc_slug, pr_number) -> collection ─
CREATE TABLE pr_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug, pr_number)
);
INSERT INTO pr_seen__new
(id, user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at, collection_id)
SELECT
p.id, p.user_id, p.rfc_slug, p.pr_number, p.last_seen_commit_sha, p.last_seen_message_id, p.seen_at,
(SELECT c.id FROM collections c WHERE c.project_id = p.project_id LIMIT 1)
FROM pr_seen p;
DROP TABLE pr_seen;
ALTER TABLE pr_seen__new RENAME TO pr_seen;
-- ── branch_chat_seen: UNIQUE (project_id, user_id, rfc_slug, branch) -> coll ─
CREATE TABLE branch_chat_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, user_id, rfc_slug, branch_name)
);
INSERT INTO branch_chat_seen__new
(id, user_id, rfc_slug, branch_name, last_seen_message_id, seen_at, collection_id)
SELECT
s.id, s.user_id, s.rfc_slug, s.branch_name, s.last_seen_message_id, s.seen_at,
(SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1)
FROM branch_chat_seen s;
DROP TABLE branch_chat_seen;
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
-- ── funder_consents: PRIMARY KEY (project_id, user_id, rfc_slug) -> collection
CREATE TABLE funder_consents__new (
user_id INTEGER NOT NULL,
rfc_slug TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
PRIMARY KEY (collection_id, user_id, rfc_slug),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO funder_consents__new (user_id, rfc_slug, created_at, collection_id)
SELECT f.user_id, f.rfc_slug, f.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = f.project_id LIMIT 1)
FROM funder_consents f;
DROP TABLE funder_consents;
ALTER TABLE funder_consents__new RENAME TO funder_consents;
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
-- ── rfc_collaborators: UNIQUE idx + composite FK -> collection_id ───────────
CREATE TABLE rfc_collaborators__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_collaborators__new
(id, rfc_slug, user_id, role_in_rfc, invitation_id, created_at, collection_id)
SELECT
rc.id, rc.rfc_slug, rc.user_id, rc.role_in_rfc, rc.invitation_id, rc.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = rc.project_id LIMIT 1)
FROM rfc_collaborators rc;
DROP TABLE rfc_collaborators;
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (collection_id, rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
-- ── contribution_requests: UNIQUE idx (pending) + composite FK -> collection ─
CREATE TABLE contribution_requests__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matched_term TEXT NOT NULL,
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
collection_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE
);
INSERT INTO contribution_requests__new
(id, rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case, status,
created_at, decided_at, decided_by_user_id, invitation_id, notification_id, collection_id)
SELECT
cr.id, cr.rfc_slug, cr.requester_user_id, cr.matched_term, cr.who_i_am, cr.why, cr.use_case, cr.status,
cr.created_at, cr.decided_at, cr.decided_by_user_id, cr.invitation_id, cr.notification_id,
(SELECT c.id FROM collections c WHERE c.project_id = cr.project_id LIMIT 1)
FROM contribution_requests cr;
DROP TABLE contribution_requests;
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
CREATE UNIQUE INDEX idx_contribution_requests_one_open
ON contribution_requests(collection_id, rfc_slug, requester_user_id)
WHERE status = 'pending';
-- ── proposed_use_cases: UNIQUE (project_id, scope, pr_number) -> collection ──
CREATE TABLE proposed_use_cases__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
collection_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (collection_id, scope, pr_number)
);
INSERT INTO proposed_use_cases__new
(id, scope, rfc_slug, pr_number, use_case, created_at, collection_id)
SELECT
u.id, u.scope, u.rfc_slug, u.pr_number, u.use_case, u.created_at,
(SELECT c.id FROM collections c WHERE c.project_id = u.project_id LIMIT 1)
FROM proposed_use_cases u;
DROP TABLE proposed_use_cases;
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─
CREATE TABLE memberships (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')),
scope_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (scope_type, scope_id, user_id)
);
CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id);
-- M2 project_members rows attached at what is now the *collection*; collapse the
-- role enum (project_admin -> owner, project_contributor -> contributor;
-- project_viewer dropped this pass, §B.3) and migrate onto the default
-- collection of each project.
INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at)
SELECT 'collection',
(SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1),
pm.user_id,
CASE pm.role WHEN 'project_admin' THEN 'owner'
WHEN 'project_contributor' THEN 'contributor'
ELSE 'contributor' END,
pm.granted_by, pm.granted_at
FROM project_members pm
WHERE pm.role IN ('project_admin', 'project_contributor');
DROP TABLE project_members;