feat(projects): M1 — the multi-project schema spine (§22)

Lays the additive foundation for hosting N projects per deployment, with
today's single corpus as the N=1 case. No behavior change: the app runs
exactly as before, single project, with the spine underneath.

- migration 025: `projects` + `project_members` tables; seed the `default`
  project (visibility=public, preserving open-by-default); thread
  `project_id NOT NULL DEFAULT 'default'` onto all 19 slug-bearing tables,
  backfilling existing rows. Additive — no table rebuilds; the slug-keyed
  uniqueness/PK rework is enumerated in the migration header and deferred to
  the slice that activates project #2.
- app/projects.py: §22.13 startup backfill of the default project's
  content_repo from META_REPO (idempotent — never clobbers a value the
  future registry mirror sets).
- config: REGISTRY_REPO wired (optional through M1; consumed by the M3
  mirror), documented in .env.example as META_REPO's successor.
- tests: 6 vertical assertions on the spine (seed, backfill, column shape,
  role CHECK, idempotency, config). Full suite 381 passed.
- docs: align Part C M1/M3 boundaries with the landed code (registry mirror
  + redirect move to M3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-01 18:14:53 -07:00
parent 7aba89655d
commit 6f901e3e2c
7 changed files with 318 additions and 13 deletions
@@ -0,0 +1,133 @@
"""Slice M1 — the §22 multi-project spine.
Migration 025 introduces the `projects` and `project_members` tables, seeds
the single `default` project (the N=1 case, §22.13), and threads a
`project_id` column onto every slug-bearing table, backfilled to `default`.
The §22.13 startup backfill then fills the default project's content_repo
from META_REPO. These tests prove the spine lands without disturbing the
single-project app.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# The 19 tables migration 025 threads project_id onto (docs/design/
# multi-project-spec.md §5 amendment list).
SLUG_TABLES = [
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
"branch_chat_seen", "watches", "notifications", "actions",
"pr_resolution_branches", "funder_consents", "rfc_invitations",
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
]
def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
rows = list(db.conn().execute(
"SELECT id, name, visibility, content_repo FROM projects"
))
assert len(rows) == 1
row = rows[0]
assert row["id"] == "default"
# public preserves the pre-multi-project open-by-default posture.
assert row["visibility"] == "public"
# §22.13 startup backfill set content_repo from META_REPO (tmp_env).
assert row["content_repo"] == "meta"
def test_project_id_on_every_slug_table(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
for table in SLUG_TABLES:
cols = {r["name"]: r for r in db.conn().execute(
f"PRAGMA table_info({table})"
)}
assert "project_id" in cols, f"{table} missing project_id"
col = cols["project_id"]
# NOT NULL with the constant 'default' backfill default.
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
def test_existing_row_backfills_to_default(app_with_fake_gitea):
"""A row inserted the old way (no project_id) lands in the default
project — the trick that keeps every pre-multi-project INSERT working."""
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)",
("human", "Human", "active"),
)
got = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["project_id"]
assert got == "default"
def test_project_members_table_shape(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
cols = {r["name"] for r in db.conn().execute(
"PRAGMA table_info(project_members)"
)}
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
# The role CHECK rejects an unknown role.
db.conn().execute(
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
)
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'project_admin')"
)
import sqlite3
try:
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'nonsense')"
)
assert False, "CHECK should reject an unknown project role"
except sqlite3.IntegrityError:
pass
def test_seed_default_project_is_idempotent(app_with_fake_gitea):
"""Re-running the backfill never clobbers a content_repo already set —
so a later registry mirror (M3) wins over the META_REPO fallback."""
from app import db, projects
from app.config import load_config
app, _ = app_with_fake_gitea
with TestClient(app):
db.conn().execute(
"UPDATE projects SET content_repo = 'ohm-content' WHERE id = 'default'"
)
projects.seed_default_project(load_config())
got = db.conn().execute(
"SELECT content_repo FROM projects WHERE id = 'default'"
).fetchone()["content_repo"]
assert got == "ohm-content"
def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch):
from app.config import load_config
monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry")
assert load_config().registry_repo == "wiggleverse-registry"
# Optional through M1: absent resolves to empty, not a startup failure.
monkeypatch.delenv("REGISTRY_REPO", raising=False)
assert load_config().registry_repo == ""