2f507e5721
Deploying the three-tier series onto OHM crash-looped on migration 029: `NOT NULL constraint failed: cached_branches__new.collection_id`, then a UNIQUE collision. Root cause: the v0.39.0 default→ohm re-stamp updated cached_rfcs.project_id but NOT the entry-satellite tables, so ~1.3k cached_branches rows were stranded at project_id='default' — which 029's per-project collection backfill can't map (NULL), some of which also duplicate freshly-re-mirrored 'ohm' rows (UNIQUE), and some of which reference entries that no longer exist. Fix — a repair prologue at the top of 029 (no schema change): - drop stale rows that duplicate an already-correctly-stamped row (keep the fresh copy) for the branch-keyed tables; - re-derive each satellite's project_id from its entry (cached_rfcs, by slug); - drop rows whose entry no longer exists (stale cache, rebuildable from gitea). A no-op on clean/fresh deployments (empty or consistent satellites). Validated against a snapshot of the live OHM DB: 029→032 apply cleanly, zero NULL collection_id, FK check clean, watches/RFCs/branch_visibility preserved, cached_branches 1397 → 1291 (−67 no-RFC, −39 dups). Fresh-install path unchanged: existing 029 suite green + a new regression test for the stale/dup/orphan shape. Full backend suite 547 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
475 lines
27 KiB
SQL
475 lines
27 KiB
SQL
-- 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.
|
|
|
|
-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ──────
|
|
-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`)
|
|
-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving
|
|
-- rows with a stale `project_id` (e.g. 'default') that the per-project collection
|
|
-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild
|
|
-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each
|
|
-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs
|
|
-- are unique per collection and, pre-rebuild, globally), and drop rows whose
|
|
-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from
|
|
-- gitea). On a clean/fresh deployment every satellite is empty or already
|
|
-- consistent, so this whole block is a no-op. (Discovered on the OHM data:
|
|
-- ~1.3k `cached_branches` rows stranded at project_id='default'.)
|
|
-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same
|
|
-- branch cached under both the stale and the real project_id) — re-stamping them
|
|
-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly-
|
|
-- stamped copy is kept (it carries the current head_sha / visibility). Only the
|
|
-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id)
|
|
-- /(scope,pr_number) and have no stale data here, so they need no dedup.
|
|
DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects)
|
|
AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects));
|
|
DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects)
|
|
AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects));
|
|
UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
|
|
DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
|
|
|
|
-- ── 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;
|