From 33c67ccc09054b4c1b29c24b6a417cd7cc7d78a4 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 07:45:10 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722.13=20step=201:=20default-project-id=20?= =?UTF-8?q?re-stamp=20(v0.39.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projects.restamp_default_project(config): at startup after the registry mirror, renames project_id from the M1 bootstrap 'default' to the configured DEFAULT_PROJECT_ID across every project-scoped table (discovered by column) and drops the stale 'default' projects row, so a deployment's original corpus lands at a meaningful /p// and 'default' is never a public URL. FK off for the rename (parent+children move together) + foreign_key_check backstop. Idempotent; no-op unless DEFAULT_PROJECT_ID is set to a non-'default' value. test_restamp_default_project.py (3 tests). 450 backend green. This is the last framework piece for OHM's clean /p/ohm/ cutover. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 30 ++++++++ VERSION | 2 +- backend/app/main.py | 5 ++ backend/app/projects.py | 69 +++++++++++++++++++ backend/tests/test_restamp_default_project.py | 64 +++++++++++++++++ frontend/package.json | 2 +- 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_restamp_default_project.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f1a862..46325b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,36 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.39.0 — 2026-06-04 + +**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can +move its original corpus off the bootstrap `default` id onto a meaningful slug +(e.g. `ohm`) so it lands at `/p//` and `default` is never a public URL. +No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.** + +Added: + +- **`projects.restamp_default_project(config)`** — at startup, after the + registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and + bootstrap-stamped rows still exist, it renames `project_id` from `default` to + the configured id across **every** project-scoped table (discovered by + column, so it stays correct as the schema grows) and drops the stale + `default` `projects` row (its data has moved to the configured row the + registry mirror created). The rename runs with FK enforcement off — parent + and child rows move together, so the composite FKs stay consistent — with a + `foreign_key_check` backstop before commit. Idempotent. +- **Tests:** `test_restamp_default_project.py` — data + composite-FK children + move to the new id, the stale row is dropped, FK integrity holds, the second + call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place. + 450 backend green. + +Upgrade steps: + +1. **MAY** set `DEFAULT_PROJECT_ID=` in the backend overlay and add the + matching project (same `id`) to `projects.yaml`. On the next deploy the + re-stamp moves the original corpus onto `` once; `default` URLs never + become public. Leave it unset to keep the `default` id (no change). + ## 0.38.0 — 2026-06-04 **Minor — §22 M3-backend Plan B (write path, propose): a new entry can be diff --git a/VERSION b/VERSION index ca75280..4ef2eb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.0 +0.39.0 diff --git a/backend/app/main.py b/backend/app/main.py index 7ecd3f1..953caf5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,11 @@ async def lifespan(app: FastAPI): raise RuntimeError( f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}" ) from e + # §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the + # deployment's configured id (DEFAULT_PROJECT_ID) once the registry row + # exists, so the original corpus lands at a meaningful /p// and + # 'default' is never a public URL. Idempotent no-op once done. + projects.restamp_default_project(config) if projects.default_content_repo(config) is None: raise RuntimeError( f"registry does not describe the default project " diff --git a/backend/app/projects.py b/backend/app/projects.py index 4b99fc6..c76609d 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -7,9 +7,13 @@ the registry mirror (`registry.refresh_registry`) is authoritative. """ from __future__ import annotations +import logging + from . import db from .config import Config +log = logging.getLogger(__name__) + DEFAULT_PROJECT_ID = "default" @@ -20,6 +24,71 @@ def resolved_default_id(config: Config) -> str: return config.default_project_id.strip() or DEFAULT_PROJECT_ID +def restamp_default_project(config: Config) -> None: + """§22.13 step 1 — one-time rename of the M1 bootstrap project id + (DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id + (the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original + corpus lands at a meaningful `/p//` and `default` is never a public URL. + + Renames `project_id` across every project-scoped table (discovered by + column, so it stays correct as the schema grows), then drops the stale + bootstrap `projects` row (its data has moved to the configured row, which + the registry mirror already created). Idempotent and a no-op when the + configured id is still 'default' or no bootstrap rows remain. Runs at + startup after the registry mirror, with FK enforcement off for the rename + (the composite FKs are kept consistent because parent and child rows are + renamed together) and a foreign_key_check backstop before commit. + """ + target = resolved_default_id(config) + if target == DEFAULT_PROJECT_ID: + return + conn = db.conn() + has_rows = conn.execute( + "SELECT 1 FROM cached_rfcs WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) + ).fetchone() + stale_proj = conn.execute( + "SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) + ).fetchone() + if not has_rows and not stale_proj: + return + if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None: + log.warning("restamp: target project %r not in registry yet; skipping", target) + return + + tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")] + pid_tables = [ + t for t in tables + if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})")) + ] + conn.execute("PRAGMA foreign_keys = OFF") + try: + conn.execute("BEGIN") + for t in pid_tables: + conn.execute( + f"UPDATE {t} SET project_id = ? WHERE project_id = ?", + (target, DEFAULT_PROJECT_ID), + ) + # The bootstrap row's data has moved to the configured (registry) row. + conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,)) + violations = conn.execute("PRAGMA foreign_key_check").fetchall() + if violations: + conn.execute("ROLLBACK") + raise RuntimeError( + f"restamp left foreign-key violations: {[tuple(v) for v in violations]}" + ) + conn.execute("COMMIT") + except Exception: + try: + conn.execute("ROLLBACK") + except Exception: + pass + raise + finally: + conn.execute("PRAGMA foreign_keys = ON") + log.info("restamp: renamed bootstrap project %r -> %r across %d tables", + DEFAULT_PROJECT_ID, target, len(pid_tables)) + + def default_content_repo(config: Config) -> str | None: """The content repo the single-corpus mirror reads, from the default project's row (filled by the registry mirror). Replaces the retired diff --git a/backend/tests/test_restamp_default_project.py b/backend/tests/test_restamp_default_project.py new file mode 100644 index 0000000..9b3c69c --- /dev/null +++ b/backend/tests/test_restamp_default_project.py @@ -0,0 +1,64 @@ +"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured +DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs +kept intact and the stale 'default' projects row dropped. Idempotent.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +import app.db as db +from app import projects + + +class _Cfg: + def __init__(self, path, default_id): + self.database_path = path + self.default_project_id = default_id + + +def _setup(monkeypatch, default_id="ohm"): + path = str(Path(tempfile.mkdtemp()) / "t.db") + cfg = _Cfg(path, default_id) + db.run_migrations(cfg) + monkeypatch.setattr(db, "_CONN", db.connect(path)) + conn = db.conn() + # M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp. + conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) " + "VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')") + conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) " + "VALUES ('ohm','Open Human Model','document','ohm-content','public','super-draft')") + conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')") + # default-stamped data with a composite-FK child + conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')") + conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) " + "VALUES ('human',1,'contributor','default')") + conn.execute("INSERT INTO stars (user_id,rfc_slug,project_id) VALUES (1,'human','default')") + return cfg, conn + + +def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch): + cfg, conn = _setup(monkeypatch, default_id="ohm") + projects.restamp_default_project(cfg) + assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0 + assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm" + assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" + assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" + # stale bootstrap projects row removed; 'ohm' remains + assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None + assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None + # FK integrity intact after the rename + assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] + + +def test_restamp_is_idempotent(monkeypatch): + cfg, conn = _setup(monkeypatch, default_id="ohm") + projects.restamp_default_project(cfg) + projects.restamp_default_project(cfg) # second call: no rows left → no-op + assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1 + + +def test_restamp_noop_when_default_id_unchanged(monkeypatch): + cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default' + projects.restamp_default_project(cfg) + # nothing renamed; bootstrap data + row still present + assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default" diff --git a/frontend/package.json b/frontend/package.json index 363dd52..3a523eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.38.0", + "version": "0.39.0", "type": "module", "scripts": { "dev": "vite",