Merge pull request 'docs(design): M3-backend Plan B design (§22)' (#13) from docs/m3-backend-planb-design into main
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
# 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)
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user