§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>
This commit is contained in:
+1
-1
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
""",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user