Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 455ef33b29 | |||
| 9f548a340d | |||
| 569066ef48 | |||
| a117fbb521 | |||
| d5213fc2da | |||
| 380e1f9782 |
@@ -23,6 +23,93 @@ 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.37.0 — 2026-06-04
|
||||||
|
|
||||||
|
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
|
||||||
|
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
|
||||||
|
namespace. No upgrade steps; single-project deployments are unaffected.**
|
||||||
|
|
||||||
|
This is the slice that makes "multiple projects" *observable*: M3-frontend
|
||||||
|
(v0.35.0) shipped the shell with a "not served" guard for any non-default
|
||||||
|
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
|
||||||
|
#2's corpus.
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
|
||||||
|
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
|
||||||
|
(entry by `(project_id, slug)`), both behind the §22.5 read gate
|
||||||
|
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
|
||||||
|
the default-project compat path.
|
||||||
|
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
|
||||||
|
iterates every `projects` row and mirrors each project's `content_repo` into
|
||||||
|
`cached_rfcs` stamped with that `project_id` (was: the default project only).
|
||||||
|
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
|
||||||
|
isolation (same slug under two projects resolves distinctly; a slug present
|
||||||
|
only in one 404s under the other), gated-project 404. 445 backend green.
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
|
||||||
|
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
|
||||||
|
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
|
||||||
|
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
|
||||||
|
|
||||||
|
Known limitation (next slice): the **write** path (propose / branch / PR /
|
||||||
|
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
|
||||||
|
project-scoped — write affordances still target the default project, so a
|
||||||
|
non-default project is effectively read-only until Plan B's write slice. (No
|
||||||
|
live impact: no deployment runs a second project yet.) Per
|
||||||
|
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
+83
-1
@@ -708,6 +708,88 @@ def make_router(
|
|||||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
|
||||||
|
# scoped to one project, identified by its own slug namespace
|
||||||
|
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
|
||||||
|
# default-project compat path; the frontend reads these scoped routes so a
|
||||||
|
# second project's corpus renders under /p/<id>/.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/projects/{project_id}/rfcs")
|
||||||
|
async def list_project_rfcs(
|
||||||
|
project_id: str, request: Request, unreviewed: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
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)
|
||||||
|
viewer_id = viewer.user_id if viewer else None
|
||||||
|
unreviewed_clause = ""
|
||||||
|
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
|
||||||
|
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 = ?{unreviewed_clause}
|
||||||
|
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
|
||||||
|
""",
|
||||||
|
(project_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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"slug": r["slug"],
|
||||||
|
"title": r["title"],
|
||||||
|
"state": r["state"],
|
||||||
|
"id": r["rfc_id"],
|
||||||
|
"repo": r["repo"],
|
||||||
|
"owners": json.loads(r["owners_json"] or "[]"),
|
||||||
|
"arbiters": json.loads(r["arbiters_json"] or "[]"),
|
||||||
|
"tags": json.loads(r["tags_json"] or "[]"),
|
||||||
|
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
|
||||||
|
"starred_by_me": r["slug"] in starred,
|
||||||
|
"has_open_prs": False,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
@router.get("/api/projects/{project_id}/rfcs/{slug}")
|
||||||
|
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)
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
|
||||||
|
(project_id, slug),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
|
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
|
||||||
|
raise HTTPException(404, "Not found")
|
||||||
|
payload = _serialize_rfc(row)
|
||||||
|
uc = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT use_case FROM proposed_use_cases
|
||||||
|
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
|
||||||
|
ORDER BY id DESC LIMIT 1
|
||||||
|
""",
|
||||||
|
(slug, project_id),
|
||||||
|
).fetchone()
|
||||||
|
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||||
|
return payload
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -934,7 +1016,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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+37
-23
@@ -35,20 +35,29 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
||||||
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
|
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
|
||||||
|
|
||||||
Idempotent. Safe to call on every meta-repo webhook and on every
|
§22 (Plan B): a deployment has N projects (§22.1), each with its own
|
||||||
reconciler sweep.
|
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
|
||||||
|
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
|
||||||
|
safe on every content-repo webhook and reconciler sweep.
|
||||||
"""
|
"""
|
||||||
org = config.gitea_org
|
org = config.gitea_org
|
||||||
repo = projects_mod.default_content_repo(config)
|
rows = db.conn().execute(
|
||||||
if not repo:
|
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
|
||||||
log.warning("refresh_meta_repo: default project has no content_repo yet; skipping")
|
).fetchall()
|
||||||
|
if not rows:
|
||||||
|
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
|
||||||
return
|
return
|
||||||
|
for prow in rows:
|
||||||
|
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
|
||||||
try:
|
try:
|
||||||
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
|
||||||
except GiteaError as e:
|
except GiteaError as e:
|
||||||
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
|
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
|
||||||
return
|
return
|
||||||
|
|
||||||
seen_slugs: set[str] = set()
|
seen_slugs: set[str] = set()
|
||||||
@@ -62,24 +71,28 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
|
|||||||
try:
|
try:
|
||||||
entry = entry_mod.parse(text)
|
entry = entry_mod.parse(text)
|
||||||
except Exception as parse_err:
|
except Exception as parse_err:
|
||||||
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
|
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
|
||||||
continue
|
continue
|
||||||
if not entry.slug:
|
if not entry.slug:
|
||||||
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
|
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
|
||||||
continue
|
continue
|
||||||
seen_slugs.add(entry.slug)
|
seen_slugs.add(entry.slug)
|
||||||
_upsert_cached_rfc(entry, body_sha=sha)
|
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
|
||||||
|
|
||||||
# Mark entries removed from the meta repo as withdrawn-without-trace.
|
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
|
||||||
# In practice the spec keeps withdrawn entries in rfcs/ as historical
|
# as historical record (§3), so this fires only for out-of-band deletes;
|
||||||
# record (§3), so this branch fires only for entries deleted out of
|
# leave the row, scoped to this project, for reconciler attention.
|
||||||
# band. We leave the row but flag it for reconciler attention.
|
existing = {
|
||||||
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
|
row["slug"]
|
||||||
|
for row in db.conn().execute(
|
||||||
|
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
|
||||||
|
)
|
||||||
|
}
|
||||||
for missing in existing - seen_slugs:
|
for missing in existing - seen_slugs:
|
||||||
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
|
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) -> None:
|
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
|
||||||
# §6.6: models_json stays NULL when the frontmatter key is absent
|
# §6.6: models_json stays NULL when the frontmatter key is absent
|
||||||
# (inherit operator universe) and '[]' for the explicit opt-out.
|
# (inherit operator universe) and '[]' for the explicit opt-out.
|
||||||
models_json = json.dumps(entry.models) if entry.models is not None else None
|
models_json = json.dumps(entry.models) if entry.models is not None else None
|
||||||
@@ -92,10 +105,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
|||||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||||
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
|
||||||
models_json, funder_login, body, body_sha,
|
models_json, funder_login, body, body_sha,
|
||||||
unreviewed, reviewed_at, reviewed_by,
|
unreviewed, reviewed_at, reviewed_by, project_id,
|
||||||
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,
|
||||||
@@ -137,6 +150,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
|
|||||||
1 if entry.unreviewed else 0,
|
1 if entry.unreviewed else 0,
|
||||||
entry.reviewed_at,
|
entry.reviewed_at,
|
||||||
entry.reviewed_by,
|
entry.reviewed_by,
|
||||||
|
project_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,7 +210,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 +385,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 +407,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
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -48,6 +48,28 @@ def run_migrations(config: Config) -> None:
|
|||||||
if version in applied:
|
if version in applied:
|
||||||
continue
|
continue
|
||||||
sql = path.read_text()
|
sql = path.read_text()
|
||||||
|
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.executescript("BEGIN; " + sql + "; COMMIT;")
|
||||||
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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,67 @@
|
|||||||
|
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
|
||||||
|
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
|
||||||
|
from the default project and gated by §22.5 visibility."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_project(pid, name, vis="public"):
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
|
||||||
|
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
|
||||||
|
(pid, name, pid + "-content", vis),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_rfc(slug, title, pid, state="active"):
|
||||||
|
from app import db
|
||||||
|
db.conn().execute(
|
||||||
|
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
|
||||||
|
(slug, title, state, pid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_add_project("ecomm", "Ecomm")
|
||||||
|
_add_rfc("intro", "Default Intro", "default")
|
||||||
|
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||||
|
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||||
|
|
||||||
|
d = client.get("/api/projects/default/rfcs").json()["items"]
|
||||||
|
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
|
||||||
|
d_slugs = {i["slug"] for i in d}
|
||||||
|
e_slugs = {i["slug"] for i in e}
|
||||||
|
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
|
||||||
|
assert {"intro", "only-ecomm"} <= e_slugs
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_is_isolated_by_project(app_with_fake_gitea):
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_add_project("ecomm", "Ecomm")
|
||||||
|
_add_rfc("intro", "Default Intro", "default")
|
||||||
|
_add_rfc("intro", "Ecomm Intro", "ecomm")
|
||||||
|
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
|
||||||
|
|
||||||
|
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
|
||||||
|
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
|
||||||
|
# a slug that exists only in ecomm 404s under default
|
||||||
|
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
|
||||||
|
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
_add_project("secret", "Secret", vis="gated")
|
||||||
|
_add_rfc("hush", "Hush", "secret")
|
||||||
|
assert client.get("/api/projects/secret/rfcs").status_code == 404
|
||||||
|
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
|
||||||
@@ -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.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.34.0",
|
"version": "0.36.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.34.0",
|
"version": "0.36.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/unified": "^1.1.9",
|
"@amplitude/unified": "^1.1.9",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.35.0",
|
"version": "0.37.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+12
-4
@@ -182,12 +182,20 @@ export async function getProject(projectId) {
|
|||||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRFCs() {
|
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
||||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
// project-scoped routes so a non-default project's corpus renders; without
|
||||||
|
// one, fall back to the default-project compat path.
|
||||||
|
export async function listRFCs(projectId) {
|
||||||
|
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||||
|
return jsonOrThrow(await fetch(url))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRFC(slug) {
|
export async function getRFC(projectId, slug) {
|
||||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
|
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||||
|
if (slug === undefined) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
||||||
|
}
|
||||||
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listProposals() {
|
export async function listProposals() {
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||||
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
|
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||||
}, [version])
|
}, [version, pid])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase()
|
const needle = search.trim().toLowerCase()
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
// §22.10 (M3) — the guard placeholder. A project that exists in the registry
|
|
||||||
// but whose corpus the backend does not yet serve (everything except the
|
|
||||||
// corpus-served default, until Plan B lands per-project RFC serving) renders
|
|
||||||
// this instead of mislabeled default-project content.
|
|
||||||
export default function NotServedPlaceholder({ project }) {
|
|
||||||
const name = project?.name || 'This project'
|
|
||||||
return (
|
|
||||||
<main className="chrome-pane">
|
|
||||||
<div className="welcome">
|
|
||||||
<h1>{name}</h1>
|
|
||||||
<p>
|
|
||||||
This project is registered, but its content isn't being served
|
|
||||||
here yet. Per-project content arrives in a later release.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,8 +16,6 @@ import { createContext, useContext, useEffect, useState } from 'react'
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { getProject } from '../api'
|
import { getProject } from '../api'
|
||||||
import { brandTitle } from '../lib/brand'
|
import { brandTitle } from '../lib/brand'
|
||||||
import { useDeployment } from '../context/DeploymentProvider'
|
|
||||||
import NotServedPlaceholder from './NotServedPlaceholder.jsx'
|
|
||||||
|
|
||||||
const ProjectContext = createContext(null)
|
const ProjectContext = createContext(null)
|
||||||
|
|
||||||
@@ -42,7 +40,6 @@ const THEME_TOKENS = {
|
|||||||
|
|
||||||
export default function ProjectLayout({ children }) {
|
export default function ProjectLayout({ children }) {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const deployment = useDeployment()
|
|
||||||
const [project, setProject] = useState(null)
|
const [project, setProject] = useState(null)
|
||||||
const [status, setStatus] = useState('loading') // loading | ready | notfound
|
const [status, setStatus] = useState('loading') // loading | ready | notfound
|
||||||
|
|
||||||
@@ -90,10 +87,11 @@ export default function ProjectLayout({ children }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const served = projectId === deployment.defaultProjectId
|
// §22.4 (Plan B): every registry project now serves its own corpus, so
|
||||||
|
// there is no "not served" guard — a readable project renders its children.
|
||||||
return (
|
return (
|
||||||
<ProjectContext.Provider value={{ project, projectId, served, noun: entryNoun(project?.type) }}>
|
<ProjectContext.Provider value={{ project, projectId, noun: entryNoun(project?.type) }}>
|
||||||
{served ? children : <NotServedPlaceholder project={project} />}
|
{children}
|
||||||
</ProjectContext.Provider>
|
</ProjectContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,10 @@ describe('ProjectLayout', () => {
|
|||||||
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('')
|
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the not-served placeholder for a non-default (Plan-B) project', async () => {
|
it('renders the corpus for a non-default project too (Plan B: every project serves)', async () => {
|
||||||
getProject.mockResolvedValue({ id: 'other', name: 'Ecomm', type: 'bdd', theme: {} })
|
getProject.mockResolvedValue({ id: 'other', name: 'Ecomm', type: 'bdd', theme: {} })
|
||||||
renderAt('/p/other/')
|
renderAt('/p/other/')
|
||||||
expect(await screen.findByText(/isn.t being served here yet/i)).toBeInTheDocument()
|
expect(await screen.findByTestId('corpus')).toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('corpus')).not.toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a not-found message when the project 404s', async () => {
|
it('shows a not-found message when the project 404s', async () => {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default function RFCView({ viewer }) {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getRFC(slug).then(entry => {
|
getRFC(pid, slug).then(entry => {
|
||||||
setEntry(entry)
|
setEntry(entry)
|
||||||
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
// v0.15.0 — analytics: fire RFC Viewed once per slug load.
|
||||||
// We key on the slug param rather than the loaded entry so a
|
// We key on the slug param rather than the loaded entry so a
|
||||||
@@ -140,7 +140,7 @@ export default function RFCView({ viewer }) {
|
|||||||
setSelectedModel(def || models?.[0]?.id || '')
|
setSelectedModel(def || models?.[0]?.id || '')
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [slug])
|
}, [slug, pid])
|
||||||
|
|
||||||
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||||
// the same surface as active RFCs — the bot, the chat, the change
|
// the same surface as active RFCs — the bot, the chat, the change
|
||||||
@@ -179,7 +179,7 @@ export default function RFCView({ viewer }) {
|
|||||||
setActionError(null)
|
setActionError(null)
|
||||||
try {
|
try {
|
||||||
const res = await unretireRFC(slug)
|
const res = await unretireRFC(slug)
|
||||||
getRFC(slug).then(setEntry).catch(() => {})
|
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||||||
if (res?.state) navigate(entryPath(pid, slug))
|
if (res?.state) navigate(entryPath(pid, slug))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err.message)
|
setActionError(err.message)
|
||||||
@@ -957,7 +957,7 @@ export default function RFCView({ viewer }) {
|
|||||||
onCompleted={() => {
|
onCompleted={() => {
|
||||||
setShowGraduateDialog(false)
|
setShowGraduateDialog(false)
|
||||||
// The catalog row and the RFC view now reflect `active`.
|
// The catalog row and the RFC view now reflect `active`.
|
||||||
getRFC(slug).then(setEntry).catch(() => {})
|
getRFC(pid, slug).then(setEntry).catch(() => {})
|
||||||
getRFCMain(slug).then(setMainView).catch(() => {})
|
getRFCMain(slug).then(setMainView).catch(() => {})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user