"""Project registry — the §22 multi-project layer. A deployment hosts one or more projects (§22.1). The git registry mirror that lets a deployment declare projects lands in M3 and drives this module. `seed_default_project` (the §22.13 META_REPO backfill) is retired in M3; 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" def resolved_default_id(config: Config) -> str: """The id of the deployment's bootstrap/default project. Plan A: always 'default' (the re-stamp to a config slug rides Plan B). The config knob is read here so Plan B can flip the resolution without touching call sites.""" 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() # §22 three-tier: the entry-corpus tables key on collection_id now; detect a # lingering bootstrap project by the project-grain `collections.project_id` # (the PRAGMA scan below still renames every project_id column dynamically). has_rows = conn.execute( "SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) ).fetchone() stale_proj = conn.execute( "SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) ).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 META_REPO. None until the registry mirror has run.""" row = db.conn().execute( "SELECT content_repo FROM projects WHERE id = ?", (resolved_default_id(config),), ).fetchone() return row["content_repo"] if row and row["content_repo"] else None def content_repo(project_id: str) -> str | None: """The content repo for a specific project (§22.3). None if unknown/unset. The per-project successor to `default_content_repo` for the write path.""" row = db.conn().execute( "SELECT content_repo FROM projects WHERE id = ?", (project_id,) ).fetchone() return row["content_repo"] if row and row["content_repo"] else None def project_initial_state(project_id: str) -> str: """§22.4b landing state for new entries in a project's default collection (the per-corpus field moved down to the collection in migration 029). Defaults to 'super-draft' for an unknown/unset row (today's-flow default).""" from . import collections as collections_mod return collections_mod.collection_initial_state( collections_mod.default_collection_id(project_id) )