chore(db): switch meta.schema_version to monotonic semver (#888)

Replace SHA-1 hash in meta.schema_version with an orderable semver
string ("1.0.0"). SHA preserved in new schema_sha column for tamper
detection. Enables savegame migration lineage in Phase 5+ — saves can
record their schema version and determine which migrations to apply.

Updated both generators, check-systems-db-stamp validation (rejects
old SHA-hex values), schema DDL, and asset-pipeline docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 10:09:13 +02:00
co-authored by Claude Opus 4.6
parent ffa638a46c
commit 5aa998cb86
6 changed files with 76 additions and 18 deletions
+21 -2
View File
@@ -20,10 +20,14 @@ Decision refs: #855 (generator versioning), #857 (pre-push hook)
"""
import hashlib
import re
import sqlite3
import sys
from pathlib import Path
# semver pattern: MAJOR.MINOR.PATCH (no pre-release or build metadata)
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
REPO_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
@@ -73,7 +77,7 @@ def check(verbose: bool = False) -> int:
try:
conn = sqlite3.connect(str(DB_PATH))
rows = conn.execute(
"SELECT generator_name, generator_sha FROM meta"
"SELECT generator_name, schema_version, generator_sha FROM meta"
).fetchall()
conn.close()
except sqlite3.OperationalError:
@@ -91,7 +95,17 @@ def check(verbose: bool = False) -> int:
stale: list[str] = []
unknown: list[str] = []
for generator_name, stored_sha in rows:
bad_version: list[str] = []
for generator_name, schema_version, stored_sha in rows:
# Validate schema_version is a semver string (#888).
# Old DBs may still carry a SHA-1 hex (40-char) — flag them as stale
# so the user knows to run make regen-db rather than getting a silent pass.
if not _SEMVER_RE.match(schema_version or ""):
bad_version.append(
f"{generator_name}: schema_version='{schema_version}' "
f"(expected semver like '1.0.0' — run make regen-db)"
)
sources = GENERATOR_SOURCES.get(generator_name)
if sources is None:
# Unknown generator — fail closed (T6). A future branch adding a
@@ -118,6 +132,11 @@ def check(verbose: bool = False) -> int:
f"\n current: {current_sha}"
)
if bad_version:
for msg in bad_version:
print(f"check-systems-db-stamp: BAD schema_version — {msg}", file=sys.stderr)
return 1
if unknown:
print(
"check-systems-db-stamp: UNKNOWN generator(s) in meta table: "
+12 -3
View File
@@ -51,6 +51,13 @@ GENERATE_BRANDS_NAMES_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_bran
GENERATE_BRANDS_WRAPPER = REPO_ROOT / "tooling" / "generate-brands"
# Monotonic schema version — bump manually on any backwards-incompatible schema change.
# Stored in meta.schema_version so future savegame migration lineage can order snapshots.
# (SHA-1 hashes cannot be ordered; semver can.) The SHA is preserved in meta.schema_sha
# for tamper detection alongside the semver (#888).
SCHEMA_VERSION = "1.0.0"
def _file_sha1(*paths: Path) -> str:
"""Return SHA-1 hex of the concatenated content of one or more files.
@@ -97,9 +104,10 @@ def _write_stamp(conn: sqlite3.Connection, generator_name: str, *source_files: P
schema_sha = _file_sha1(SCHEMA_SQL)
generator_sha = _file_sha1(*source_files)
conn.execute(
"""INSERT OR REPLACE INTO meta (generator_name, schema_version, generator_sha, generated_at)
VALUES (?, ?, ?, datetime('now'))""",
(generator_name, schema_sha, generator_sha),
"""INSERT OR REPLACE INTO meta
(generator_name, schema_version, schema_sha, generator_sha, generated_at)
VALUES (?, ?, ?, ?, datetime('now'))""",
(generator_name, SCHEMA_VERSION, schema_sha, generator_sha),
)
@@ -291,6 +299,7 @@ COLUMN_MIGRATIONS = [
("corporations", "supply_chain_role", "TEXT"),
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
("brand_products", "price_tier", "TEXT"),
("meta", "schema_sha", "TEXT"),
]
+14 -3
View File
@@ -92,6 +92,11 @@ _ATLAS_SCHEMA_END_MARKER = "-- END ATLAS INDEX"
# Generator metadata stamp (#855, #856)
# ---------------------------------------------------------------------------
# Monotonic schema version — shared constant with import_economics.py (#888).
# Bump manually on any backwards-incompatible schema change.
SCHEMA_VERSION = "1.0.0"
def _file_sha1(*paths: Path) -> str:
"""Return SHA-1 hex of the concatenated content of one or more files.
@@ -124,9 +129,9 @@ def _write_stamp(conn: sqlite3.Connection) -> None:
generator_sha = _file_sha1(Path(__file__))
conn.execute(
"""INSERT OR REPLACE INTO meta
(generator_name, schema_version, generator_sha, generated_at)
VALUES ('generate_atlas', ?, ?, datetime('now'))""",
(schema_sha, generator_sha),
(generator_name, schema_version, schema_sha, generator_sha, generated_at)
VALUES ('generate_atlas', ?, ?, ?, datetime('now'))""",
(SCHEMA_VERSION, schema_sha, generator_sha),
)
@@ -172,11 +177,17 @@ def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
CREATE TABLE IF NOT EXISTS meta (
generator_name TEXT PRIMARY KEY,
schema_version TEXT NOT NULL,
schema_sha TEXT,
generator_sha TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
)
# Add schema_sha column to existing DBs that pre-date #888 (#888 migration).
try:
conn.execute("ALTER TABLE meta ADD COLUMN schema_sha TEXT")
except Exception:
pass # column already exists
def _first_int(values, default: int = 0) -> int: