a117fbb521
Lands the table rebuilds migration 026 deferred until a second project exists, shipped separately from per-project serving for migration hygiene. No behavior change (deployments stay single-project 'default'). - migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed PK/UNIQUE of 13 tables (cached_rfcs PK (slug)->(project_id,slug); the UNIQUE/PK on cached_branches, branch_visibility, branch_contribute_grants, stars, watches, pr_seen, branch_chat_seen, funder_consents, rfc_collaborators, contribution_requests, proposed_use_cases gain project_id) and makes the FKs to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug). - db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild procedure) + foreign_key_check after, failing loudly on dangling refs. - ON CONFLICT upsert targets for the rebuilt tables gain project_id so they match the new composite indexes (value still defaults to 'default'). - test_migration_028_project_scoped_keys.py: proves two-project same-slug coexistence, within-project uniqueness, composite-FK enforcement. 442 pass. - design doc §2 marked shipped. Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
14 KiB
SQL
277 lines
14 KiB
SQL
-- migrate:no-foreign-keys
|
|
--
|
|
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
|
|
-- constraints that migration 026 deliberately left global, so a *second*
|
|
-- project can hold an entry with the same slug as the first. M1 (026) added
|
|
-- project_id additively (no rebuild); this is the rebuild that activates
|
|
-- project #2, enumerated in 026's header.
|
|
--
|
|
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
|
|
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
|
|
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
|
|
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
|
|
-- above tells the runner to toggle it and run foreign_key_check after).
|
|
--
|
|
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
|
|
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
|
|
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
|
|
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
|
|
-- their FK at its new composite (project_id, slug) key.
|
|
|
|
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_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,
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
unreviewed INTEGER NOT NULL DEFAULT 0,
|
|
reviewed_at TEXT,
|
|
reviewed_by TEXT,
|
|
PRIMARY KEY (project_id, slug)
|
|
);
|
|
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
|
|
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_project ON cached_rfcs(project_id);
|
|
|
|
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
|
|
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
|
|
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
|
|
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
|
|
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,
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
|
|
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 (rfc_slug, branch_name) -> +project_id ─────────
|
|
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,
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, rfc_slug, branch_name)
|
|
);
|
|
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
|
|
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 (rfc_slug, branch_name) -> +project_id ───────
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, rfc_slug, branch_name)
|
|
);
|
|
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
|
|
DROP TABLE branch_visibility;
|
|
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
|
|
|
|
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
|
|
);
|
|
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
|
|
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 (user_id, rfc_slug) -> +project_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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, user_id, rfc_slug)
|
|
);
|
|
INSERT INTO stars__new SELECT * FROM stars;
|
|
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 (user_id, rfc_slug) -> +project_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,
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, user_id, rfc_slug)
|
|
);
|
|
INSERT INTO watches__new SELECT * FROM watches;
|
|
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 (user_id, rfc_slug, pr_number) -> +project_id ──────────
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, user_id, rfc_slug, pr_number)
|
|
);
|
|
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
|
|
DROP TABLE pr_seen;
|
|
ALTER TABLE pr_seen__new RENAME TO pr_seen;
|
|
|
|
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, user_id, rfc_slug, branch_name)
|
|
);
|
|
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
|
|
DROP TABLE branch_chat_seen;
|
|
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
|
|
|
|
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
|
|
CREATE TABLE funder_consents__new (
|
|
user_id INTEGER NOT NULL,
|
|
rfc_slug TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
PRIMARY KEY (project_id, user_id, rfc_slug),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
|
|
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 (rfc_slug, user_id) -> +project_id;
|
|
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
|
|
DROP TABLE rfc_collaborators;
|
|
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
|
|
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
|
|
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
|
|
|
|
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
|
|
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
|
|
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,
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
|
|
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(project_id, rfc_slug, requester_user_id)
|
|
WHERE status = 'pending';
|
|
|
|
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
|
|
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')),
|
|
project_id TEXT NOT NULL DEFAULT 'default',
|
|
UNIQUE (project_id, scope, pr_number)
|
|
);
|
|
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
|
|
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);
|