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 release's steps in order — no A-to-B path is pre-computed beyond
that. 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 ## 0.35.0 — 2026-06-04
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime **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) INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?) 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), (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) INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?) 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, read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode 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) INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now')) 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, last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at 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) INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now')) 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, state = excluded.state,
set_by = 'explicit', set_by = 'explicit',
set_at = excluded.set_at 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) INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me') 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), (slug, branch),
) )
@@ -189,7 +189,7 @@ def make_router(
""" """
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?) 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), (slug, pr["number"], use_case),
) )
@@ -398,7 +398,7 @@ def make_router(
INSERT INTO pr_seen INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at) (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now')) 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_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id, last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at 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, unreviewed, reviewed_at, reviewed_by,
last_entry_commit_at, updated_at) last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET ON CONFLICT(project_id, slug) DO UPDATE SET
title = excluded.title, title = excluded.title,
state = excluded.state, state = excluded.state,
rfc_id = excluded.rfc_id, 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) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?) 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, head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at 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) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?) 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, head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at 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) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?) 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, head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at 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: if version in applied:
continue continue
sql = path.read_text() 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,)) conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally: finally:
conn.close() conn.close()
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute( db.conn().execute(
""" """
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?) 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), (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", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.35.0", "version": "0.36.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",