§22.13 step 1: default-project-id re-stamp (v0.39.0)
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/<id>/ 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
release's steps in order — no A-to-B path is pre-computed beyond
|
||||||
that.
|
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/<id>/` 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=<slug>` 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 `<slug>` once; `default` URLs never
|
||||||
|
become public. Leave it unset to keep the `default` id (no change).
|
||||||
|
|
||||||
## 0.38.0 — 2026-06-04
|
## 0.38.0 — 2026-06-04
|
||||||
|
|
||||||
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
|
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ async def lifespan(app: FastAPI):
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
|
||||||
) from 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/<id>/ 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:
|
if projects.default_content_repo(config) is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"registry does not describe the default project "
|
f"registry does not describe the default project "
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ the registry mirror (`registry.refresh_registry`) is authoritative.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_PROJECT_ID = "default"
|
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
|
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/<id>/` 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:
|
def default_content_repo(config: Config) -> str | None:
|
||||||
"""The content repo the single-corpus mirror reads, from the default
|
"""The content repo the single-corpus mirror reads, from the default
|
||||||
project's row (filled by the registry mirror). Replaces the retired
|
project's row (filled by the registry mirror). Replaces the retired
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.38.0",
|
"version": "0.39.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user