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