§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:
@@ -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;
|
||||
@@ -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')")
|
||||
Reference in New Issue
Block a user