§22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)
- collections.py resolution helpers (default_collection_id, type, initial_state) - registry mirror writes project grouping fields + default-collection corpus fields - auth.project_of_rfc joins collections; project_member_role reads memberships - cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised project_id tags unchanged); api_deployment reads type/initial_state from the default collection - projects.py restamp detects bootstrap via collections; initial_state via collection - tests updated to the three-tier schema; test_migration_028 retired (superseded by 029) - add @S1 acceptance test (collection grain + N=1 serving) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-25
@@ -29,6 +29,7 @@ from . import (
|
||||
api_notifications,
|
||||
api_prs,
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
projects as projects_mod,
|
||||
db,
|
||||
device_trust as device_trust_mod,
|
||||
@@ -637,13 +638,13 @@ def make_router(
|
||||
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
|
||||
rows = db.conn().execute(
|
||||
f"""
|
||||
SELECT slug, title, state, rfc_id, repo,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
|
||||
r.owners_json, r.arbiters_json, r.tags_json,
|
||||
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
|
||||
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
|
||||
WHERE r.state IN ('super-draft', 'active')
|
||||
AND c.project_id IN ({placeholders}){unreviewed_clause}
|
||||
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
@@ -685,8 +686,8 @@ def make_router(
|
||||
raise HTTPException(404, "Not found")
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
# 404 to non-members.
|
||||
auth.require_project_readable(viewer, row["project_id"])
|
||||
# 404 to non-members. Recover the project via the entry's collection.
|
||||
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
|
||||
# §13.7: a retired entry is removed from every browsing surface. The
|
||||
# sole exception is a site owner, so the un-retire affordance has
|
||||
# somewhere to live; everyone else gets a plain 404.
|
||||
@@ -723,6 +724,8 @@ def make_router(
|
||||
viewer = auth.current_user(request)
|
||||
# §22.5 read gate: a gated project's catalog 404s to a non-member.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
# §22 S1: serve the project's default collection (the corpus grain).
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
viewer_id = viewer.user_id if viewer else None
|
||||
unreviewed_clause = ""
|
||||
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||
@@ -734,18 +737,18 @@ def make_router(
|
||||
last_main_commit_at, last_entry_commit_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state IN ('super-draft', 'active')
|
||||
AND project_id = ?{unreviewed_clause}
|
||||
AND collection_id = ?{unreviewed_clause}
|
||||
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||
""",
|
||||
(project_id,),
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
starred = set()
|
||||
if viewer_id is not None:
|
||||
starred = {
|
||||
r["rfc_slug"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?",
|
||||
(viewer_id, project_id),
|
||||
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
|
||||
(viewer_id, collection_id),
|
||||
)
|
||||
}
|
||||
items = [
|
||||
@@ -770,9 +773,10 @@ def make_router(
|
||||
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
|
||||
(project_id, slug),
|
||||
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
|
||||
(collection_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
@@ -782,10 +786,10 @@ def make_router(
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
|
||||
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, project_id),
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
@@ -802,9 +806,10 @@ def make_router(
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
if not auth.is_project_superuser(viewer, project_id):
|
||||
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?",
|
||||
(slug, project_id),
|
||||
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
|
||||
(slug, collection_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
@@ -965,8 +970,9 @@ def make_router(
|
||||
# We re-check atomically here even though the client also checks
|
||||
# on every keystroke, since a concurrent submission could land
|
||||
# between dialog-open and submit.
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
clash = db.conn().execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND project_id = ?", (slug, project_id)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
|
||||
).fetchone()
|
||||
if clash:
|
||||
raise HTTPException(409, f"Slug `{slug}` is already taken")
|
||||
@@ -1041,11 +1047,11 @@ def make_router(
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id)
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
|
||||
VALUES ('rfc', ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case, project_id),
|
||||
(slug, pr["number"], use_case, collection_id),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
|
||||
@@ -1205,12 +1211,12 @@ def make_router(
|
||||
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
rfc = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
auth.require_project_readable(user, rfc["project_id"])
|
||||
auth.require_project_readable(user, auth.project_of_rfc(slug))
|
||||
# §6.7: refuse consent from a user with no registered credentials
|
||||
# — a consent without a universe would be inert and the surface
|
||||
# should fail loudly rather than silently.
|
||||
|
||||
@@ -742,7 +742,7 @@ def make_router(
|
||||
"""
|
||||
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_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(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
last_seen_message_id = excluded.last_seen_message_id,
|
||||
seen_at = excluded.seen_at
|
||||
""",
|
||||
@@ -1092,7 +1092,7 @@ def make_router(
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
|
||||
|
||||
@@ -62,7 +62,7 @@ def _require_super_draft(slug: str, viewer):
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -108,7 +108,7 @@ def make_router() -> APIRouter:
|
||||
@router.get("/api/rfcs/{slug}/contribution-target")
|
||||
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
|
||||
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from . import auth, db, projects as projects_mod
|
||||
from . import auth, collections as collections_mod, db, projects as projects_mod
|
||||
from .config import Config
|
||||
|
||||
|
||||
@@ -33,12 +33,21 @@ def make_router(config: Config) -> APIRouter:
|
||||
).fetchone()
|
||||
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
|
||||
visible = set(auth.visible_project_ids(viewer))
|
||||
# §22 three-tier: `type` is a per-corpus field on the (default) collection
|
||||
# now; surface the default collection's type for each project.
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, name, type, visibility FROM projects "
|
||||
"SELECT id, name, visibility FROM projects "
|
||||
"WHERE visibility != 'unlisted' ORDER BY name"
|
||||
).fetchall()
|
||||
projects = [
|
||||
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]}
|
||||
{
|
||||
"id": r["id"],
|
||||
"name": r["name"],
|
||||
"type": collections_mod.collection_type(
|
||||
collections_mod.default_collection_id(r["id"])
|
||||
),
|
||||
"visibility": r["visibility"],
|
||||
}
|
||||
for r in rows
|
||||
if r["id"] in visible
|
||||
]
|
||||
@@ -60,8 +69,7 @@ def make_router(config: Config) -> APIRouter:
|
||||
# an unknown id). unlisted is readable by direct id.
|
||||
auth.require_project_readable(viewer, project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name, type, visibility, initial_state, config_json "
|
||||
"FROM projects WHERE id = ?",
|
||||
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -71,13 +79,16 @@ def make_router(config: Config) -> APIRouter:
|
||||
except (ValueError, TypeError):
|
||||
cfg = {}
|
||||
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
|
||||
# §22 three-tier: type + initial_state moved down to the (default)
|
||||
# collection in migration 029.
|
||||
cid = collections_mod.default_collection_id(row["id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"tagline": (dep["tagline"] if dep else "") or "",
|
||||
"type": row["type"],
|
||||
"type": collections_mod.collection_type(cid),
|
||||
"visibility": row["visibility"],
|
||||
"initial_state": row["initial_state"],
|
||||
"initial_state": collections_mod.collection_initial_state(cid),
|
||||
"theme": cfg.get("theme") or {},
|
||||
}
|
||||
|
||||
@@ -86,21 +97,27 @@ def make_router(config: Config) -> APIRouter:
|
||||
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
|
||||
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
|
||||
# before the SPA's index.html fallback.
|
||||
# §22 three-tier (S1): the canonical entry route now carries the collection
|
||||
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
|
||||
# the default project's default collection.
|
||||
@router.get("/rfc/{slug}")
|
||||
async def redirect_old_rfc(slug: str) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
|
||||
|
||||
@router.get("/rfc/{slug}/pr/{pr_number}")
|
||||
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(
|
||||
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
|
||||
)
|
||||
|
||||
@router.get("/proposals/{pr_number}")
|
||||
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
|
||||
default_id = projects_mod.resolved_default_id(config)
|
||||
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
|
||||
cid = collections_mod.default_collection_id(default_id)
|
||||
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
|
||||
|
||||
return router
|
||||
|
||||
@@ -252,7 +252,7 @@ def _require_rfc_readable(slug: str, viewer):
|
||||
entries refuse reads of every shape — same rule `_require_rfc_with_repo`
|
||||
in `api_branches.py` follows."""
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
|
||||
@@ -574,7 +574,7 @@ def make_router(
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _require_super_draft(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members.
|
||||
@@ -584,7 +584,7 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_retirable(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] not in ("super-draft", "active"):
|
||||
@@ -592,7 +592,7 @@ def make_router(
|
||||
return row
|
||||
|
||||
def _require_retired(slug: str):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "retired":
|
||||
|
||||
@@ -380,7 +380,7 @@ def _require_rfc(slug: str, viewer):
|
||||
visibility gate is subtractive: a gated project's entries 404 to
|
||||
non-members (§22.7)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
"SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
|
||||
@@ -213,7 +213,7 @@ def make_router(config: Config) -> APIRouter:
|
||||
@router.post("/api/rfcs/{slug}/watch")
|
||||
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.require_user(request)
|
||||
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if rfc is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
||||
@@ -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(project_id, user_id, rfc_slug) DO UPDATE SET
|
||||
ON CONFLICT(collection_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(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
|
||||
ON CONFLICT(collection_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(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
ON CONFLICT(collection_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(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET
|
||||
ON CONFLICT(collection_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
|
||||
@@ -662,7 +662,7 @@ def make_router(
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_rfc(slug: str, viewer):
|
||||
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
|
||||
|
||||
+27
-10
@@ -337,24 +337,41 @@ def project_visibility(project_id: str) -> str:
|
||||
|
||||
|
||||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The user's *explicit* §22.6 project_members role in this project, or
|
||||
None. This is the stored row only — it does not fold in the deployment tier
|
||||
or the implicit-on-public baseline (those live in the helpers below)."""
|
||||
"""The user's *explicit* §22.6 membership role at this project, or None.
|
||||
|
||||
§22 three-tier (S1): M2's `project_members` rows migrated into the unified
|
||||
`memberships` table at the project's default collection (and the project
|
||||
tier is freshly grantable). The unified `{owner, contributor}` roles are
|
||||
mapped back to the legacy `project_admin`/`project_contributor` strings the
|
||||
S1 project-grain authz still speaks; the four-layer scope resolver lands in
|
||||
S3. Reads the stored grant only (project OR default-collection scope) — it
|
||||
does not fold in the deployment tier or the implicit-on-public baseline."""
|
||||
if user is None:
|
||||
return None
|
||||
from . import collections as collections_mod
|
||||
cid = collections_mod.default_collection_id(project_id)
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
|
||||
(project_id, user.user_id),
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ((scope_type = 'project' AND scope_id = ?) "
|
||||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, project_id, cid),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
if row is None:
|
||||
return None
|
||||
return "project_admin" if row["role"] == "owner" else "project_contributor"
|
||||
|
||||
|
||||
def project_of_rfc(rfc_slug: str) -> str:
|
||||
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to
|
||||
the default project when the slug isn't cached or the column is unset — the
|
||||
same N=1 default migration 026 backfills."""
|
||||
"""The project an RFC belongs to, via its collection
|
||||
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
|
||||
Falls back to the default project when the slug isn't cached — the same N=1
|
||||
default migration 026 backfills."""
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||||
"SELECT c.project_id AS project_id "
|
||||
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
|
||||
"WHERE r.slug = ?",
|
||||
(rfc_slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return DEFAULT_PROJECT_ID
|
||||
|
||||
+14
-9
@@ -54,6 +54,11 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||
|
||||
|
||||
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||
# §22 S1: the corpus grain is the collection. The mirror is project-grained
|
||||
# (reads rfcs/ at the repo root = the project's default collection); resolve
|
||||
# that collection once and key cached_rfcs by it.
|
||||
from . import collections as collections_mod
|
||||
collection_id = collections_mod.default_collection_id(project_id)
|
||||
try:
|
||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||
except GiteaError as e:
|
||||
@@ -77,7 +82,7 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
||||
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
|
||||
continue
|
||||
seen_slugs.add(entry.slug)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
|
||||
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
|
||||
|
||||
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
||||
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||
@@ -85,14 +90,14 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
|
||||
existing = {
|
||||
row["slug"]
|
||||
for row in db.conn().execute(
|
||||
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
|
||||
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
|
||||
)
|
||||
}
|
||||
for missing in existing - seen_slugs:
|
||||
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
|
||||
|
||||
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
|
||||
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
|
||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||
@@ -105,10 +110,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||
models_json, funder_login, body, body_sha,
|
||||
unreviewed, reviewed_at, reviewed_by, project_id,
|
||||
unreviewed, reviewed_at, reviewed_by, collection_id,
|
||||
last_entry_commit_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(project_id, slug) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, slug) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
state = excluded.state,
|
||||
rfc_id = excluded.rfc_id,
|
||||
@@ -150,7 +155,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
|
||||
1 if entry.unreviewed else 0,
|
||||
entry.reviewed_at,
|
||||
entry.reviewed_by,
|
||||
project_id,
|
||||
collection_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -210,7 +215,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(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_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
|
||||
@@ -385,7 +390,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(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_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
|
||||
@@ -407,7 +412,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(project_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
|
||||
head_sha = excluded.head_sha,
|
||||
last_commit_at = excluded.last_commit_at
|
||||
""",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""§22 collection grain — resolution helpers beneath the project tier.
|
||||
|
||||
In S1 each project has exactly one collection (the default). These helpers
|
||||
recover the collection for a project and read the per-corpus fields (`type`,
|
||||
`initial_state`) that moved down from `projects` in migration 029. Project-grain
|
||||
authz (auth.py) recovers a row's project by joining `collections` on
|
||||
`collection_id`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import db
|
||||
|
||||
DEFAULT_COLLECTION_ID = "default"
|
||||
|
||||
|
||||
def default_collection_id(project_id: str) -> str:
|
||||
"""The id of a project's default (S1: sole) collection. Falls back to the
|
||||
literal 'default' when the project has no collection row yet."""
|
||||
row = db.conn().execute(
|
||||
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
|
||||
(project_id,),
|
||||
).fetchone()
|
||||
return row["id"] if row else DEFAULT_COLLECTION_ID
|
||||
|
||||
|
||||
def project_of_collection(collection_id: str) -> str | None:
|
||||
"""The project a collection belongs to, or None if unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["project_id"] if row else None
|
||||
|
||||
|
||||
def collection_initial_state(collection_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a collection. 'super-draft'
|
||||
default for an unknown/unset row (today's safe flow)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
|
||||
|
||||
def collection_type(collection_id: str) -> str:
|
||||
"""The collection's immutable §22.4a type. 'document' default for unknown."""
|
||||
row = db.conn().execute(
|
||||
"SELECT type FROM collections WHERE id = ?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["type"] if row and row["type"] else "document"
|
||||
@@ -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(project_id, user_id, rfc_slug) DO NOTHING
|
||||
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
|
||||
""",
|
||||
(user_id, slug),
|
||||
)
|
||||
|
||||
+11
-9
@@ -43,8 +43,11 @@ def restamp_default_project(config: Config) -> None:
|
||||
if target == DEFAULT_PROJECT_ID:
|
||||
return
|
||||
conn = db.conn()
|
||||
# §22 three-tier: the entry-corpus tables key on collection_id now; detect a
|
||||
# lingering bootstrap project by the project-grain `collections.project_id`
|
||||
# (the PRAGMA scan below still renames every project_id column dynamically).
|
||||
has_rows = conn.execute(
|
||||
"SELECT 1 FROM cached_rfcs WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
"SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
).fetchone()
|
||||
stale_proj = conn.execute(
|
||||
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
|
||||
@@ -110,11 +113,10 @@ def content_repo(project_id: str) -> str | None:
|
||||
|
||||
|
||||
def project_initial_state(project_id: str) -> str:
|
||||
"""§22.4b landing state for new entries in a project. Defaults to
|
||||
'super-draft' for an unknown/unset row (the safe, today's-flow default)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT initial_state FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
if row is None or not row["initial_state"]:
|
||||
return "super-draft"
|
||||
return row["initial_state"]
|
||||
"""§22.4b landing state for new entries in a project's default collection
|
||||
(the per-corpus field moved down to the collection in migration 029).
|
||||
Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
|
||||
from . import collections as collections_mod
|
||||
return collections_mod.collection_initial_state(
|
||||
collections_mod.default_collection_id(project_id)
|
||||
)
|
||||
|
||||
+51
-20
@@ -114,44 +114,74 @@ def parse_registry(text: str) -> RegistryDoc:
|
||||
)
|
||||
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
|
||||
"""Upsert the parsed registry into projects + deployment. Idempotent.
|
||||
def _default_collection_id(project_id: str, default_id: str) -> str:
|
||||
"""The id of a project's default collection. The deployment's primary
|
||||
project (== `default_id`, the §22.13 resolved default) gets the stable
|
||||
literal `'default'` — matching migration 029's seed so the upsert *merges*
|
||||
onto the migration-seeded row rather than duplicating it (critical on a
|
||||
fresh deploy where the bootstrap `default` project is later restamped to the
|
||||
configured id). Any additional project keys its default collection by its own
|
||||
id, keeping the collection PK globally unique (pre-S5 multi-project)."""
|
||||
return "default" if project_id == default_id else project_id
|
||||
|
||||
§22.4a: `type` is immutable — a change against an existing row is rejected
|
||||
(skip + log), never applied. Projects absent from the registry are left in
|
||||
place (archival is out of scope for M3; they simply stop refreshing).
|
||||
|
||||
def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None:
|
||||
"""Upsert the parsed registry into projects + their default collections +
|
||||
the deployment singleton. Idempotent.
|
||||
|
||||
§22 three-tier (S1): a project carries the grouping-tier fields (name,
|
||||
content_repo, visibility, config); the per-corpus fields (`type`,
|
||||
`initial_state`) live on the project's default collection. §22.4a: `type` is
|
||||
immutable — a change against an existing collection is rejected (skip the
|
||||
type change + log), never applied. Projects absent from the registry are
|
||||
left in place (archival is out of scope for M3; they stop refreshing).
|
||||
"""
|
||||
with db.tx() as conn:
|
||||
for e in doc.projects:
|
||||
cid = _default_collection_id(e.id, default_id)
|
||||
existing = conn.execute(
|
||||
"SELECT type FROM projects WHERE id = ?", (e.id,)
|
||||
"SELECT type FROM collections WHERE id = ?", (cid,)
|
||||
).fetchone()
|
||||
if existing is not None and existing["type"] != e.type:
|
||||
type_locked = existing is not None and existing["type"] != e.type
|
||||
if type_locked:
|
||||
log.error(
|
||||
"registry: refusing immutable type change on project %s (%s -> %s)",
|
||||
e.id, existing["type"], e.type,
|
||||
"registry: refusing immutable type change on collection %s (%s -> %s)",
|
||||
cid, existing["type"], e.type,
|
||||
)
|
||||
continue
|
||||
# The project (grouping tier) always refreshes.
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO projects
|
||||
(id, name, type, content_repo, visibility, initial_state,
|
||||
config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
(id, name, content_repo, visibility, config_json, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
type = excluded.type,
|
||||
content_repo = excluded.content_repo,
|
||||
visibility = excluded.visibility,
|
||||
initial_state = excluded.initial_state,
|
||||
config_json = excluded.config_json,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(
|
||||
e.id, e.name, e.type, e.content_repo, e.visibility,
|
||||
e.initial_state, json.dumps(e.config), registry_sha,
|
||||
),
|
||||
(e.id, e.name, e.content_repo, e.visibility, json.dumps(e.config), registry_sha),
|
||||
)
|
||||
# The default collection (corpus tier). On an immutable-type
|
||||
# conflict, keep the stored type but still refresh the rest.
|
||||
effective_type = existing["type"] if type_locked else e.type
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collections
|
||||
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
|
||||
VALUES (?, ?, ?, '', ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
project_id = excluded.project_id,
|
||||
type = excluded.type,
|
||||
initial_state = excluded.initial_state,
|
||||
visibility = excluded.visibility,
|
||||
name = excluded.name,
|
||||
registry_sha = excluded.registry_sha,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(cid, e.id, effective_type, e.initial_state, e.visibility, e.name, registry_sha),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -181,5 +211,6 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
|
||||
# includes it on the contents response); fall back to the blob sha.
|
||||
sha = item.get("last_commit_sha") or item.get("sha") or ""
|
||||
doc = parse_registry(text)
|
||||
apply_registry(doc, sha)
|
||||
from . import projects as projects_mod
|
||||
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
|
||||
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
|
||||
|
||||
Reference in New Issue
Block a user