diff --git a/CHANGELOG.md b/CHANGELOG.md index a0326ec..a97f506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,54 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.36.0 — 2026-06-04 + +**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that +activates project #2. No behavior change (deployments are still single-project +`default`); migration `028` runs automatically on deploy.** + +This lands the table rebuilds migration 026 deliberately deferred "until a +second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY / +UNIQUE constraints so two projects can hold the same slug without colliding. +Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration +hygiene: the schema change deploys and is verified on its own, smaller blast +radius. + +Added: + +- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite + slug keys: `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` all gain + `project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`, + `rfc_collaborators`, `contribution_requests` become composite + `(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is + unchanged — `(repo, pr_number)` is already globally unique.) +- **Migration-runner capability** (`db.run_migrations`): a migration whose + first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys` + toggled OFF around it (required by SQLite's table-rebuild procedure, and a + no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails + the migration loudly on any dangling reference. + +Changed: + +- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id` + (`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`, + `api.py`, `funder.py`) so they continue to match the new composite indexes. + The inserted `project_id` still defaults to `default`, so behavior is + identical for a single-project deployment. + +Upgrade steps: + +1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites + the listed tables in one transaction with FK enforcement off and verifies + `foreign_key_check` after. No data is dropped (rows already carry + `project_id` from migration 026, M1). No config change. +2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the + default-id re-stamp + the frontend guard removal) is the next slice (Plan B + 2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`. + ## 0.35.0 — 2026-06-04 **Minor (breaking) — §22 M3 frontend: `/p//` routing, runtime diff --git a/VERSION b/VERSION index 7b52f5e..93d4c1e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.0 +0.36.0 diff --git a/backend/app/api.py b/backend/app/api.py index 7333aa3..5aacc7c 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -934,7 +934,7 @@ def make_router( """ INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) VALUES ('rfc', ?, ?, ?) - ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case + ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case """, (slug, pr["number"], use_case), ) diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 38ae552..8e54c48 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -742,7 +742,7 @@ def make_router( """ INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) VALUES (?, ?, ?, ?) - ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = excluded.read_public, contribute_mode = excluded.contribute_mode """, @@ -896,7 +896,7 @@ def make_router( """ INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at) VALUES (?, ?, ?, ?, datetime('now')) - ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET + ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET last_seen_message_id = excluded.last_seen_message_id, seen_at = excluded.seen_at """, diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index 34320af..a76bf8e 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -222,7 +222,7 @@ def make_router(config: Config) -> APIRouter: """ INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now')) - ON CONFLICT(user_id, rfc_slug) DO UPDATE SET + ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET state = excluded.state, set_by = 'explicit', set_at = excluded.set_at diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 455d3e5..c4157c6 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -153,7 +153,7 @@ def make_router( """ INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) VALUES (?, ?, 1, 'just-me') - ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1 + ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1 """, (slug, branch), ) @@ -189,7 +189,7 @@ def make_router( """ INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) VALUES ('pr', ?, ?, ?) - ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case + ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case """, (slug, pr["number"], use_case), ) @@ -398,7 +398,7 @@ def make_router( INSERT INTO pr_seen (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at) VALUES (?, ?, ?, ?, ?, datetime('now')) - ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET + ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET last_seen_commit_sha = excluded.last_seen_commit_sha, last_seen_message_id = excluded.last_seen_message_id, seen_at = excluded.seen_at diff --git a/backend/app/cache.py b/backend/app/cache.py index 62c3154..869e212 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -95,7 +95,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None: unreviewed, reviewed_at, reviewed_by, last_entry_commit_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - ON CONFLICT(slug) DO UPDATE SET + ON CONFLICT(project_id, slug) DO UPDATE SET title = excluded.title, state = excluded.state, rfc_id = excluded.rfc_id, @@ -196,7 +196,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None: """ INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) VALUES (?, ?, ?, 'open', ?) - ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET head_sha = excluded.head_sha, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, last_commit_at = excluded.last_commit_at @@ -371,7 +371,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: """ INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) VALUES (?, ?, ?, 'open', ?) - ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET head_sha = excluded.head_sha, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, last_commit_at = excluded.last_commit_at @@ -393,7 +393,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None: """ INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) VALUES (?, 'main', ?, 'open', ?) - ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET head_sha = excluded.head_sha, last_commit_at = excluded.last_commit_at """, diff --git a/backend/app/db.py b/backend/app/db.py index 8b81acb..6385744 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None: if version in applied: continue sql = path.read_text() - conn.executescript("BEGIN; " + sql + "; COMMIT;") + if "-- migrate:no-foreign-keys" in sql: + # SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g. + # folding project_id into a composite key) follow the official + # 12-step ALTER procedure, which requires FK enforcement OFF — + # and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so + # it must be toggled here, around the script. The connection is + # in autocommit mode (isolation_level=None), so the PRAGMA takes + # effect immediately. We re-enable and run foreign_key_check + # after, failing the migration loudly if the rebuild left any + # dangling reference. + conn.execute("PRAGMA foreign_keys = OFF") + try: + conn.executescript("BEGIN; " + sql + "; COMMIT;") + violations = conn.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise RuntimeError( + f"migration {version} left foreign-key violations: " + f"{[tuple(v) for v in violations]}" + ) + finally: + conn.execute("PRAGMA foreign_keys = ON") + else: + conn.executescript("BEGIN; " + sql + "; COMMIT;") conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,)) finally: conn.close() diff --git a/backend/app/funder.py b/backend/app/funder.py index 2fc10da..c4793d5 100644 --- a/backend/app/funder.py +++ b/backend/app/funder.py @@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None: db.conn().execute( """ INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?) - ON CONFLICT(user_id, rfc_slug) DO NOTHING + ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING """, (user_id, slug), ) diff --git a/backend/migrations/028_project_scoped_keys.sql b/backend/migrations/028_project_scoped_keys.sql new file mode 100644 index 0000000..e030a02 --- /dev/null +++ b/backend/migrations/028_project_scoped_keys.sql @@ -0,0 +1,276 @@ +-- 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 `__new` with the new constraint, copy, DROP +-- the live table, rename `__new` -> ``, 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); diff --git a/backend/tests/test_migration_028_project_scoped_keys.py b/backend/tests/test_migration_028_project_scoped_keys.py new file mode 100644 index 0000000..a5a7174 --- /dev/null +++ b/backend/tests/test_migration_028_project_scoped_keys.py @@ -0,0 +1,95 @@ +"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates +project #2. Proves two projects can hold the same slug, that (project_id, slug) +is still unique within a project, that the rebuilt FK is composite + enforced, +and that the no-foreign-keys migration runner left no dangling references.""" +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 _seed_two_projects(conn): + for pid in ("default", "ecomm"): + conn.execute( + "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " + "VALUES (?, ?, 'document', ?, 'public', 'super-draft')", + (pid, pid.title(), pid + "-content"), + ) + + +def test_same_slug_coexists_across_projects(): + conn = _fresh_db() + _seed_two_projects(conn) + for pid in ("default", "ecomm"): + conn.execute( + "INSERT INTO cached_rfcs (slug, title, state, project_id) " + "VALUES ('intro', 'Intro', 'active', ?)", + (pid,), + ) + rows = conn.execute( + "SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id" + ).fetchall() + assert [r["project_id"] for r in rows] == ["default", "ecomm"] + + +def test_slug_still_unique_within_a_project(): + conn = _fresh_db() + _seed_two_projects(conn) + conn.execute( + "INSERT INTO cached_rfcs (slug, title, state, project_id) " + "VALUES ('intro', 'Intro', 'active', 'default')" + ) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO cached_rfcs (slug, title, state, project_id) " + "VALUES ('intro', 'Dup', 'active', 'default')" + ) + + +def test_rfc_collaborators_composite_fk_enforced(): + conn = _fresh_db() + _seed_two_projects(conn) + 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, project_id) " + "VALUES ('intro', 'Intro', 'active', 'ecomm')" + ) + # Matching (project_id, slug) — FK holds. + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) " + "VALUES ('intro', 1, 'contributor', 'ecomm')" + ) + # Same slug but a project with no such entry — composite FK must reject. + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) " + "VALUES ('intro', 1, 'contributor', 'default')" + ) + + +def test_stars_unique_now_scoped_by_project(): + conn = _fresh_db() + _seed_two_projects(conn) + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')") + # Same (user, slug) under two projects coexist; a duplicate within one rejects. + conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')") + conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')") diff --git a/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md b/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md index ad41684..f96da9f 100644 --- a/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md +++ b/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md @@ -62,6 +62,14 @@ deployment's URL is meaningful (`/p/ohm/` not `/p/default/`) and stable. ## 2. Slug-keyed PK / UNIQUE rebuilds (migration 026 header) +> **STATUS: SHIPPED — rfc-app v0.36.0 (Session 0071.0).** Migration `028` lands +> the rebuilds (13 tables, incl. composite FKs on `rfc_invitations`/ +> `rfc_collaborators`/`contribution_requests` → `cached_rfcs(project_id, slug)`), +> the migration-runner `-- migrate:no-foreign-keys` capability, and the +> `ON CONFLICT` target updates. 442 backend tests green; two-project same-slug +> coexistence proven (`test_migration_028_project_scoped_keys.py`). No behavior +> change. **Remaining Plan B = the §1 re-stamp + §3–§5 per-project serving.** + SQLite can't `ALTER` a PRIMARY KEY/UNIQUE in place, so each table below is rebuilt with the create-new → copy → drop-old → rename pattern, inside one transaction with `PRAGMA foreign_keys=OFF` around it (per the existing diff --git a/frontend/package.json b/frontend/package.json index fe16667..9e119b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.35.0", + "version": "0.36.0", "type": "module", "scripts": { "dev": "vite",