From 867f2504d68ab9aa1dc7ed53fa5e4d3df6df6f53 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 07:58:39 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S1:=20migration=20029=20=E2=80=94=20?= =?UTF-8?q?collections=20grain,=20field=20move-down,=2013-table=20re-key,?= =?UTF-8?q?=20memberships?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/migrations/029_collections.sql | 427 ++++++++++++++++++ .../tests/test_migration_029_collections.py | 139 ++++++ 2 files changed, 566 insertions(+) create mode 100644 backend/migrations/029_collections.sql create mode 100644 backend/tests/test_migration_029_collections.py diff --git a/backend/migrations/029_collections.sql b/backend/migrations/029_collections.sql new file mode 100644 index 0000000..bde568f --- /dev/null +++ b/backend/migrations/029_collections.sql @@ -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; diff --git a/backend/tests/test_migration_029_collections.py b/backend/tests/test_migration_029_collections.py new file mode 100644 index 0000000..ab3f8bb --- /dev/null +++ b/backend/tests/test_migration_029_collections.py @@ -0,0 +1,139 @@ +"""Migration 029 — the collection grain beneath projects (§22 three-tier S1). + +Proves: a `collections` table exists with one default collection per project +(id='default', subfolder=repo root); the per-corpus fields (type, initial_state) +moved off `projects`; the 13 entry-corpus tables re-key (project_id,slug) -> +(collection_id,slug) with the composite PK/FK enforced; and project_members +generalises into memberships(scope_type, …) with the role enum collapsed. +Template: test_migration_028_project_scoped_keys.py. +""" +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from app import db + + +class _Cfg: + def __init__(self, path): + self.database_path = path + + +def _fresh_db(): + d = tempfile.mkdtemp() + path = Path(d) / "t.db" + db.run_migrations(_Cfg(str(path))) + return db.connect(str(path)) + + +def test_collections_table_exists_with_default_per_project(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")} + assert {"id", "project_id", "type", "subfolder", + "initial_state", "visibility", "name", "registry_sha"} <= cols + # one default collection seeded for the bootstrap 'default' project (026) + row = conn.execute( + "SELECT id, project_id, subfolder FROM collections WHERE project_id='default'" + ).fetchone() + assert row is not None + assert row["id"] == "default" + assert row["subfolder"] == "" # repo root + + +def test_per_corpus_fields_moved_off_projects(): + conn = _fresh_db() + proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")} + assert "type" not in proj_cols + assert "initial_state" not in proj_cols + # projects keeps the grouping-tier fields + assert {"id", "name", "content_repo", "visibility"} <= proj_cols + + +def test_entry_tables_rekeyed_to_collection_id(): + conn = _fresh_db() + for t in ("cached_rfcs", "cached_branches", "stars", "watches", + "rfc_collaborators", "contribution_requests", "proposed_use_cases", + "branch_visibility", "branch_contribute_grants", "pr_seen", + "branch_chat_seen", "funder_consents", "rfc_invitations"): + cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")} + assert "collection_id" in cols, f"{t} missing collection_id" + assert "project_id" not in cols, f"{t} still has project_id" + + +def test_cached_rfcs_pk_is_collection_slug(): + conn = _fresh_db() + # a second collection under the default project + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')") + n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"] + assert n == 2 + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')") + + +def test_cached_rfcs_collection_fk_enforced(): + conn = _fresh_db() + conn.execute("PRAGMA foreign_keys=ON") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('x','X','active','nope')") + + +def test_collaborator_fk_is_composite_on_collection(): + conn = _fresh_db() + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')") + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','c2')" + ) + with pytest.raises(sqlite3.IntegrityError): + # same slug, a collection with no such entry — composite FK rejects + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','default')" + ) + + +def test_stars_unique_now_scoped_by_collection(): + conn = _fresh_db() + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')") + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')") + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','c2')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')") + + +def test_memberships_table_replaces_project_members(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")} + assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols + # project_members is gone + assert conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'" + ).fetchone() is None + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (9,'x','X','contributor')") + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')") + # scope_type and role are CHECK-constrained + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")