Merge pull request 'v0.46.1 — fix migration 029 for the §22.13 re-stamp aftermath (OHM deploy fault)' (#27) from fix/migration-029-orphan-collection into main

This commit is contained in:
Ben Stull
2026-06-06 19:03:20 +00:00
5 changed files with 136 additions and 2 deletions
+27
View File
@@ -23,6 +23,33 @@ 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.46.1 — 2026-06-06
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
crash deploying the three-tier series (v0.40.0+) onto a deployment that went
through the v0.39.0 `default``<id>` project re-stamp: the re-stamp updated
`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at
the stale `project_id` that migration 029's per-project collection backfill could
not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus
stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale
rows whose entry no longer exists. No operator action; **no schema change** — 029
gains a repair prologue only.
Fixed:
- **Migration 029 repair prologue** — before rekeying, each entry-satellite table
(`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has
its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose
entry no longer exists are dropped (stale cache, rebuildable from gitea), and
stale rows that duplicate an already-correctly-stamped row are dropped (keeping
the fresh copy). A no-op on a clean/fresh deployment (empty or already-
consistent satellites), so fresh installs are unaffected — the existing 029
test suite passes unchanged, plus a new regression test for the stale/dup/
orphan shape.
No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair
only mutates the rebuildable `cached_*` caches.
## 0.46.0 — 2026-06-06
**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder):
+1 -1
View File
@@ -1 +1 @@
0.46.0
0.46.1
+47
View File
@@ -26,6 +26,53 @@
-- they carry a project-grain tag, untouched in S1. See
-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
-- ── §22.13 repair: re-stamp stale satellite project_id before rekeying ──────
-- The §22.13 default→ohm re-stamp (v0.39.0, `projects.restamp_default_project`)
-- updated `cached_rfcs.project_id` but NOT the entry-satellite tables, leaving
-- rows with a stale `project_id` (e.g. 'default') that the per-project collection
-- backfill below cannot map — the subquery returns NULL and the NOT NULL rebuild
-- fails (`cached_branches__new.collection_id`). Before rebuilding, re-derive each
-- satellite's `project_id` from its entry (`cached_rfcs`, joined by slug — slugs
-- are unique per collection and, pre-rebuild, globally), and drop rows whose
-- entry no longer exists (stale cache; the `cached_*` tables are rebuildable from
-- gitea). On a clean/fresh deployment every satellite is empty or already
-- consistent, so this whole block is a no-op. (Discovered on the OHM data:
-- ~1.3k `cached_branches` rows stranded at project_id='default'.)
-- First drop stale rows that DUPLICATE an already-correctly-stamped row (the same
-- branch cached under both the stale and the real project_id) — re-stamping them
-- would collide on the (project_id, rfc_slug, branch_name) key. The correctly-
-- stamped copy is kept (it carries the current head_sha / visibility). Only the
-- branch-keyed tables can hold such a pair; the others key on (rfc_slug,user_id)
-- /(scope,pr_number) and have no stale data here, so they need no dedup.
DELETE FROM cached_branches WHERE project_id NOT IN (SELECT id FROM projects)
AND EXISTS (SELECT 1 FROM cached_branches o WHERE o.rfc_slug = cached_branches.rfc_slug AND o.branch_name = cached_branches.branch_name AND o.project_id IN (SELECT id FROM projects));
DELETE FROM branch_visibility WHERE project_id NOT IN (SELECT id FROM projects)
AND EXISTS (SELECT 1 FROM branch_visibility o WHERE o.rfc_slug = branch_visibility.rfc_slug AND o.branch_name = branch_visibility.branch_name AND o.project_id IN (SELECT id FROM projects));
UPDATE rfc_invitations SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_invitations.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM rfc_invitations WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE cached_branches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = cached_branches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM cached_branches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_visibility SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_visibility.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_visibility WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_contribute_grants SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_contribute_grants.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_contribute_grants WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE stars SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = stars.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM stars WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE watches SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = watches.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM watches WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE pr_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = pr_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM pr_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE branch_chat_seen SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = branch_chat_seen.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM branch_chat_seen WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE funder_consents SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = funder_consents.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM funder_consents WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE rfc_collaborators SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = rfc_collaborators.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM rfc_collaborators WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE contribution_requests SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = contribution_requests.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM contribution_requests WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
UPDATE proposed_use_cases SET project_id = (SELECT r.project_id FROM cached_rfcs r WHERE r.slug = proposed_use_cases.rfc_slug) WHERE rfc_slug IN (SELECT slug FROM cached_rfcs);
DELETE FROM proposed_use_cases WHERE rfc_slug NOT IN (SELECT slug FROM cached_rfcs);
-- ── collections: the new typed-corpus grain beneath projects ───────────────
CREATE TABLE collections (
id TEXT NOT NULL,
@@ -137,3 +137,63 @@ def test_memberships_table_replaces_project_members():
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')")
# ── regression: §22.13 satellite re-stamp repair (the OHM-data deploy fault) ──
# Reproduces the shape that crashed the v0.46.0 deploy: the default→ohm re-stamp
# updated cached_rfcs but left cached_branches at the stale project_id='default',
# with (a) a stale row duplicating a freshly-stamped one, (b) a stale row with no
# fresh counterpart, and (c) a stale row whose RFC no longer exists. 029 must
# repair all three rather than hit NOT NULL / UNIQUE on the rebuild.
def _apply_through(path, ceiling):
conn = sqlite3.connect(path, isolation_level=None)
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))")
done = {r["version"] for r in conn.execute("SELECT version FROM schema_migrations")}
for p in sorted(db.MIGRATIONS_DIR.glob("*.sql")):
v = p.stem
if v in done or v > ceiling:
continue
sql = p.read_text()
if "-- migrate:no-foreign-keys" in sql:
conn.execute("PRAGMA foreign_keys = OFF")
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("PRAGMA foreign_keys = ON")
else:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
return conn
def test_029_repairs_stale_duplicate_and_orphan_satellite_rows():
d = tempfile.mkdtemp()
path = str(Path(d) / "t.db")
conn = _apply_through(path, "028_project_scoped_keys")
# simulate the §22.13 re-stamp having renamed the default project + its RFCs
# to 'ohm', but NOT the satellite tables (the actual prod fault).
conn.execute("UPDATE projects SET id='ohm' WHERE id='default'")
conn.execute("INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES ('human','Human','active','ohm')")
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','ohm')") # fresh/correct
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','main','default')") # stale DUP of the fresh one
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('human','edit-1','default')")# stale, unique -> re-stamp+keep
conn.execute("INSERT INTO cached_branches (rfc_slug, branch_name, project_id) VALUES ('ghost','main','default')") # no live RFC -> drop
conn.close()
# apply 029+ (the patched migration). Must NOT raise.
db.run_migrations(_Cfg(path))
conn = db.connect(path)
rows = conn.execute(
"SELECT rfc_slug, branch_name, collection_id FROM cached_branches"
).fetchall()
got = {(r["rfc_slug"], r["branch_name"]) for r in rows}
# every surviving row mapped to a collection (the single-project 'default' one)
assert all(r["collection_id"] is not None for r in rows)
assert {r["collection_id"] for r in rows} == {"default"}
# the duplicate collapsed to exactly one human/main
assert len([r for r in rows if (r["rfc_slug"], r["branch_name"]) == ("human", "main")]) == 1
# the unique stale row survived (re-stamped)
assert ("human", "edit-1") in got
# the no-RFC stale row was dropped
assert ("ghost", "main") not in got
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.46.0",
"version": "0.46.1",
"type": "module",
"scripts": {
"dev": "vite",