Compare commits

..

4 Commits

Author SHA1 Message Date
Ben Stull 569066ef48 Merge pull request '§22 M3-backend Plan B (1/2): slug-keyed PK rebuilds (v0.36.0)' (#14) from feat/m3-backend-planb into main 2026-06-04 13:30:48 +00:00
Ben Stull a117fbb521 §22 M3-backend Plan B (1/2): slug-keyed PK rebuilds activate project #2 (v0.36.0)
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>
2026-06-04 06:30:37 -07:00
Ben Stull d5213fc2da Merge pull request 'docs(design): M3-backend Plan B design (§22)' (#13) from docs/m3-backend-planb-design into main 2026-06-04 13:12:47 +00:00
Ben Stull 380e1f9782 docs(design): M3-backend Plan B — per-project serving, default-id re-stamp, PK rebuilds (§22)
The blueprint for the next §22 slice after M3-frontend (v0.35.0): make a
second project's corpus actually serve + render. Pins the three things that
land together — the default-project-id re-stamp (§22.13 step 1), the deferred
slug-keyed PK/UNIQUE rebuilds (migration 026 header, 12 tables), and
per-project RFC serving (path-scoped endpoints + per-project cache/bot/webhook
dispatch + scoped frontend calls + guard removal). Notes the Tier-1 registry
seed as the shared unblock for both this slice's and M3-frontend's deferred
e2e. Target v0.36.0 (possibly split B-1 read / B-2 write).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:12:38 -07:00
13 changed files with 650 additions and 15 deletions
+48
View File
@@ -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/<project>/` routing, runtime
+1 -1
View File
@@ -1 +1 @@
0.35.0
0.36.0
+1 -1
View File
@@ -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),
)
+2 -2
View File
@@ -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
""",
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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
""",
+23 -1
View File
@@ -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()
+1 -1
View File
@@ -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),
)
@@ -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 `<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);
@@ -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')")
@@ -0,0 +1,194 @@
# M3-backend Plan B — §22 multi-project: per-project RFC serving, default-id re-stamp, slug-keyed PK rebuilds — design
**Date:** 2026-06-04
**Slice:** §22 M3 (the backend half that M3-frontend deferred). Pairs with
**M3-frontend** (`2026-06-03-m3-frontend-design.md`, shipped v0.35.0) which
delivered the shell (routing, runtime branding, directory/switcher, 308s) but
kept RFC *data* calls unscoped, so only the corpus-served default project
renders. This slice makes a **second project's corpus actually serve and
render**, and lands the two §22.13/migration-026 items that must precede
project #2.
**Status:** design, pending plan. Authoritative model: `multi-project-spec.md`
(Part A §22, Part C M3) + `multi-project.md`.
**Target release:** the next pre-1.0 minor (breaking: URL/migration). Likely
**v0.36.0**; may split into two minors (B-1 read path, B-2 write path) — see §7.
## Goal
After this slice a deployment with **N≥2** registry projects serves and renders
every project's own corpus under `/p/<id>/`, identified by slug *within* the
project (§22.4). The M3-frontend guard (`NotServedPlaceholder` for any
non-default project) is **removed** — the guard contract (`default_project_id`)
stays on `/api/deployment` only as the redirect target for legacy URLs.
Three things land together because none is safe without the others:
1. **Default-project-id re-stamp** (§22.13 step 1) — `default` → a config slug.
2. **Slug-keyed PK/UNIQUE rebuilds** (migration 026 header) — fold `project_id`
into the composite keys so a second project can't collide on a slug.
3. **Per-project RFC serving** — endpoints, cache mirror, bot, and webhook
routing all dispatch by project; the frontend calls the scoped routes.
## 1. Default-project-id re-stamp (§22.13 step 1)
Today `projects.resolved_default_id()` always returns the literal `"default"`
(Plan A), and M1's backfill stamped every existing row `project_id='default'`.
This slice re-stamps that bootstrap id to a **config-derived slug** so the
deployment's URL is meaningful (`/p/ohm/` not `/p/default/`) and stable.
- **Resolution order** (already drafted in `resolved_default_id`'s docstring):
`DEFAULT_PROJECT_ID` env var → else slug of the deployment name → else
`default`. Decision: keep it config-explicit — OHM sets
`DEFAULT_PROJECT_ID=ohm` in its overlay; the registry's first project `id`
SHOULD match.
- **Why it must precede public `/p/` URLs:** once `/p/<id>/` is linkable, the
id is a permanent URL; renaming it later breaks links. M3-frontend shipped
`/p/<default>/` already, so strictly this re-stamp is now *slightly late*
acknowledge that and treat the re-stamp as a one-time 308-preserving rename
(add `/p/default/* → /p/<slug>/*` 308s for one release if `DEFAULT_PROJECT_ID`
differs from `default`). Open: do we bother, given OHM isn't deployed on the
multi-project stack yet (pinned 0.31.5)? If OHM cuts over *directly* to a
re-stamped slug, no `default` URL was ever public and the extra 308s are
unnecessary. **Recommendation: re-stamp lands in the SAME deploy OHM first
adopts the registry, so `default` is never public for OHM → no legacy 308
needed.** Code the re-stamp as part of the registry-mirror reconcile (rename
rows whose `project_id` is the stale bootstrap value to the resolved id),
idempotent.
- **Mechanics:** a migration (or the reconciler, run-once guarded) `UPDATE`s
`project_id` from the old bootstrap value to the resolved slug across the
`projects` row, `project_members`, and every scoped table (the ~19 from
migration 026). Must run **inside the same transaction** as / before the PK
rebuilds (§2) so FKs stay consistent.
## 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
migration-runner convention — confirm in `db.py`). The composite-key targets
(verbatim from `026_projects.sql`):
| Table | old key | new key |
| --- | --- | --- |
| `cached_rfcs` | PK `(slug)` | `(project_id, slug)` |
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)` | `+project_id` |
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)` | `+project_id` |
| `stars` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
| `watches` | UNIQUE `(user_id, rfc_slug)` | `+project_id` |
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)` | `+project_id` |
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)` | `+project_id` |
| `funder_consents` | PK `(user_id, rfc_slug)` | `+project_id` |
| `rfc_collaborators` | UNIQUE idx `(rfc_slug, user_id)` | `+project_id` |
| `contribution_requests` | UNIQUE idx `(rfc_slug, requester_user_id) WHERE pending` | `+project_id` |
| `proposed_use_cases` | UNIQUE `(scope, pr_number)` | `+project_id` |
`cached_prs` UNIQUE `(repo, pr_number)` needs **no** rebuild — `repo` is the
full `org/repo` string, already distinct per project.
- Every dependent index/trigger/view is recreated against the new table.
- Backfilled rows already carry `project_id` (M1), so the copy is a straight
`INSERT INTO new SELECT * FROM old`.
- Add the FK `project_id REFERENCES projects(id)` on rebuild where it was
deferred.
- **Test:** a migration test that seeds two projects with the *same* slug and
asserts both rows coexist post-rebuild (the collision M1 couldn't allow).
## 3. Per-project RFC serving — endpoints
Decision to pin in planning: **path-scoped** routes, mirroring the frontend's
`/p/<project>/` and §22.4's `(project_id, slug)` identity:
```
GET /api/projects/{pid}/rfcs catalog (replaces GET /api/rfcs)
GET /api/projects/{pid}/rfcs/{slug} entry (replaces /api/rfcs/{slug})
POST /api/projects/{pid}/rfcs/propose propose (already partly there:
mark-reviewed is /api/projects/{pid}/…)
GET /api/projects/{pid}/proposals[/{n}] idea PRs
…branches / prs / discussion / graduation analogously gain the {pid} prefix.
```
- Keep the old unscoped `/api/rfcs*` as **thin shims** that resolve the default
project and delegate, for one release, so a stale frontend bundle mid-deploy
still works; remove in the following minor. (Or hard-cut — decide in planning;
the frontend ships scoped calls in the same release, so the shim is only for
in-flight bundles.)
- Every handler runs the §22.5 read/write gate on `{pid}`
(`require_project_readable`, `can_contribute_in_project`) — the resolver
primitives already exist (M2). The per-RFC lookups change from `WHERE slug=?`
to `WHERE project_id=? AND slug=?`.
- `propose`/graduation already call `projects_mod.resolved_default_id(config)`
(api.py:874) — change to the path `{pid}`.
## 4. Cache mirror, bot, webhook — dispatch by project
- **Mirror:** `cache.refresh_meta_repo()` reads one `content_repo` today
(`projects.default_content_repo`). Generalize to iterate **all** projects'
`content_repo`s, mirroring each into `cached_rfcs` (etc.) stamped with that
project's id. Loop over `projects` rows.
- **Bot:** `bot.open_idea_pr(...)` and the branch/PR helpers take a
`project_id` (or a resolved `content_repo`) instead of the default.
- **Webhook (§4):** `/api/webhooks/gitea` maps the pushed repo →
`projects.content_repo` → project, and refreshes only that project's cache
(the planner does exactly this `match_repo` step — mirror its shape).
- **Registry webhook** already reconciles `projects` (Plan A). No change.
## 5. Frontend — relax the guard, scope the calls
- `api.js`: `listRFCs`/`getRFC`/`listProposals`/`getProposal`/`proposeRFC`/…
gain a `projectId` arg and hit the `/api/projects/{pid}/…` routes. `useProjectId()`
(already added in M3-frontend, `lib/entryPaths.js`) supplies it.
- `ProjectLayout`: **remove the `served`/`NotServedPlaceholder` guard** — every
registry project now serves. Keep `NotServedPlaceholder` only as the 404/not-
readable surface (or delete and reuse the not-found branch).
- `Catalog`, `RFCView`, `PRView`, `ProposalView`, `ProposeModal` read their
data through the scoped api with `useProjectId()`.
- `/api/deployment.default_project_id` stays (the legacy-URL 308 target).
## 6. Testing
- **Migration:** two-project same-slug coexistence (§2); re-stamp idempotence;
FK integrity post-rebuild.
- **Backend vertical:** a second `document` project end-to-end — propose →
super-draft → graduate, identified by slug *in that project*; visibility gate
(gated 2nd project 404s a non-member while the default stays readable);
cross-project isolation (a slug in project A is not found under project B).
- **Frontend unit:** scoped api calls carry the right `pid`; the guard removal
renders a non-default project's catalog.
- **Tier-1 e2e (now unblockable):** seed a **registry repo + `projects.yaml`
with two projects** into the dockerized Gitea and set `REGISTRY_REPO` in
`testing/.env.tier1` (currently empty — this is the blocker M3-frontend's
e2e + this slice's e2e share). Then the M3-frontend Playwright specs (N=1
redirect, directory with 2+, 308s, switcher) AND a second-project corpus
render become runnable. **Land the Tier-1 registry seed as the first task of
this slice** — it pays off both slices' deferred e2e.
## 7. Sequencing & risk
- **Highest risk:** the §2 PK rebuilds (12 table recreates in one migration).
Mitigate with an isolated migration test DB seeded from a realistic dump and
a row-count assertion before/after each table.
- **Possible split:** B-1 = re-stamp + PK rebuilds + read-path serving
(catalog + entry view scoped) — enough for a 2nd project's corpus to *render*
read-only; B-2 = write path (propose/branch/PR/graduate) scoped. Each is
independently shippable behind the N=1 default. Decide in planning; a single
v0.36.0 is fine if the write-path rescope is mechanical.
- **OHM impact:** OHM is pinned 0.31.5 and not yet on the registry stack, so
this slice has **no live deployment to migrate** until the OHM cutover
milestone (ohm-rfc ROADMAP Phase G). Land it on `main`; it deploys to OHM as
part of that cutover.
## 8. Versioning
Pre-1.0 minor, breaking (URL move + migration). `VERSION` +
`frontend/package.json` move together (§20.1). CHANGELOG upgrade-steps:
migration runs automatically; old `/api/rfcs*` shims (if kept) deprecated; the
re-stamp note (`DEFAULT_PROJECT_ID`); the SPEC §22 merge stays for M7.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.35.0",
"version": "0.36.0",
"type": "module",
"scripts": {
"dev": "vite",