fix(§22): heal mig-029 vs registry-mirror collection-id divergence

On a deployment that already had >=2 projects when migration 029 ran, 029
seeds the default project's collection id as the project id (e.g. 'ohm'),
but the registry mirror expects 'default' -> it inserted a duplicate empty
'default' collection, orphaning the entries.

Adds projects.reconcile_default_collection_id() -- the collection-grain twin
of restamp_default_project -- run at startup BEFORE the mirror so it merges
onto the canonical 'default' collection instead of duplicating. Renames the
divergent collection id and cascades collection_id across all keyed tables
(FK-off atomic rename + foreign_key_check). Idempotent; no-op on fresh /
single-project / already-aligned deploys. Seam tests show the duplicate forms
without the fix and merges with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 21:54:52 -07:00
parent 4ac3955056
commit 620926b834
3 changed files with 251 additions and 0 deletions
+6
View File
@@ -102,6 +102,12 @@ async def lifespan(app: FastAPI):
db.run_migrations(config)
db.init(config)
gitea = Gitea(config)
# §22 framework heal: reconcile a divergent default-project collection id
# (migration 029's ≥2-projects seed names it after the project, e.g. 'ohm',
# but the mirror expects 'default') BEFORE the mirror runs, so the mirror
# merges onto the canonical 'default' collection instead of duplicating it.
# Idempotent no-op on fresh / single-project / already-aligned deployments.
projects.reconcile_default_collection_id(config)
# §22.2: mirror the registry before anything reads projects/content_repo.
# First boot has no last-good rows, so a missing/invalid registry is fatal
# (loud-fail per separation-of-concerns); the reconciler sweep keeps it
+89
View File
@@ -92,6 +92,95 @@ def restamp_default_project(config: Config) -> None:
DEFAULT_PROJECT_ID, target, len(pid_tables))
def reconcile_default_collection_id(config: Config) -> None:
"""Heal the §22 migration-029 vs registry-mirror divergence for the default
project's collection id on a multi-project deployment.
Migration 029 seeds each project's default collection id as the literal
'default' only when the DB holds a single project at migration time; with
2 projects it falls back to the *project id* (avoiding a PK collision
029 can't read DEFAULT_PROJECT_ID, there is no env in SQL). But the registry
mirror (`registry._default_collection_id`) expects the deployment's default
project to own the collection id 'default'. On an upgrade whose DB already
held 2 projects when 029 ran, the default project's collection is therefore
named after the project (e.g. 'ohm'), and the next mirror would INSERT a
second, empty 'default' collection instead of merging duplicating the
default corpus and orphaning the entries (which point at 'ohm').
This is the collection-grain twin of `restamp_default_project`. Run at
startup BEFORE the registry mirror so the canonical 'default' collection
already exists when the mirror upserts (merge, not duplicate). Renames the
divergent collection's id to 'default' and cascades `collection_id` across
every collection-keyed table, with FK enforcement off for the atomic rename
and a `foreign_key_check` backstop before commit. Idempotent; a no-op on
fresh / single-project / already-aligned deployments.
"""
from .collections import DEFAULT_COLLECTION_ID
target = resolved_default_id(config)
if target == DEFAULT_COLLECTION_ID: # default project already owns 'default'
return
conn = db.conn()
# The 029 ≥2-projects seed names the default project's collection after the
# project itself; the canonical id the mirror expects is 'default'.
divergent = conn.execute(
"SELECT 1 FROM collections WHERE id = ? AND project_id = ? LIMIT 1",
(target, target),
).fetchone()
if not divergent:
return
if conn.execute(
"SELECT 1 FROM collections WHERE id = ? LIMIT 1", (DEFAULT_COLLECTION_ID,)
).fetchone():
# A 'default' collection already exists (e.g. a prior buggy mirror left a
# duplicate). Don't auto-merge data — that needs care; leave both for
# operator cleanup and log loudly.
log.warning(
"reconcile: default project %r owns both a %r and a 'default' "
"collection; skipping auto-rename (manual merge required)",
target, target,
)
return
cid_tables = [
t["name"]
for t in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if any(c["name"] == "collection_id"
for c in conn.execute(f"PRAGMA table_info({t['name']})"))
]
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
conn.execute(
"UPDATE collections SET id = ? WHERE id = ?",
(DEFAULT_COLLECTION_ID, target),
)
for t in cid_tables:
conn.execute(
f"UPDATE {t} SET collection_id = ? WHERE collection_id = ?",
(DEFAULT_COLLECTION_ID, target),
)
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"reconcile 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(
"reconcile: renamed default-project collection %r -> 'default' across %d tables",
target, len(cid_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