fix: make the migration chain runnable from an empty database
0001 called Base.metadata.create_all(), building the schema from the current models, so on a fresh PostgreSQL 0002 collided on datasets.raw_sha256 and `alembic upgrade head` could never run from zero. 0001 is now an explicit, frozen baseline: the seven base tables exactly as the real history created them, before 0002/0006 extended them. Databases already past 0001 never re-run it. Proof: a fresh `upgrade head` produces a schema identical (pg_dump --schema-only) to the long-lived dev database. 0019 repairs a latent bug found by that comparison: 0006 used server_default="now()" as a string, which PostgreSQL froze at the instant the column was created, so every Agent and Job got the same created_at. It also adds three indexes the models declare but no migration created. central/tests/test_migration_baseline.py guards (via AST) against migrations importing the models or using string timestamp defaults. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""Guards for the migration chain being runnable from an empty database.
|
||||
|
||||
0001 used to build the schema from the *current* ORM models, so every later
|
||||
migration collided with it on a fresh database. These checks run offline (no
|
||||
PostgreSQL) and inspect code, not comments, so they fail if that pattern
|
||||
comes back.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
VERSIONS = ROOT / "central" / "alembic" / "versions"
|
||||
|
||||
|
||||
def _migrations():
|
||||
return {path.name: ast.parse(path.read_text(encoding="utf-8")) for path in sorted(VERSIONS.glob("*.py"))}
|
||||
|
||||
|
||||
def _script_directory():
|
||||
config = Config(str(ROOT / "central" / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(ROOT / "central" / "alembic"))
|
||||
return ScriptDirectory.from_config(config)
|
||||
|
||||
|
||||
def test_no_migration_builds_schema_from_current_models():
|
||||
offenders = []
|
||||
for name, tree in _migrations().items():
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Attribute) and node.attr in ("create_all", "drop_all"):
|
||||
offenders.append((name, node.attr))
|
||||
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("central.app"):
|
||||
offenders.append((name, node.module))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_chain_is_linear_with_a_single_head():
|
||||
script = _script_directory()
|
||||
assert len(script.get_heads()) == 1
|
||||
revisions = list(script.walk_revisions())
|
||||
assert revisions[-1].revision == "0001"
|
||||
assert revisions[-1].down_revision is None
|
||||
|
||||
|
||||
def test_timestamp_defaults_are_sql_functions_not_string_literals():
|
||||
# A plain string such as "now()" is rendered as a quoted literal that
|
||||
# PostgreSQL freezes at column creation time. 0006 did that and 0019
|
||||
# repairs it; no other migration may repeat it.
|
||||
offenders = []
|
||||
for name, tree in _migrations().items():
|
||||
if name.startswith("0006_"):
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.keyword)
|
||||
and node.arg == "server_default"
|
||||
and isinstance(node.value, ast.Constant)
|
||||
and re.fullmatch(r"\s*(now\s*\(\s*\)|now|current_timestamp|localtimestamp)\s*", str(node.value.value), re.IGNORECASE)
|
||||
):
|
||||
offenders.append(name)
|
||||
assert offenders == []
|
||||
Reference in New Issue
Block a user