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
+20 -5
View File
@@ -44,12 +44,21 @@ After every successful non-dry-run, each generator writes a row to the `meta` ta
```sql ```sql
CREATE TABLE meta ( CREATE TABLE meta (
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
schema_version TEXT NOT NULL, -- SHA-1 of server/data/systems-schema.sql at generation time schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
schema_sha TEXT, -- SHA-1 of server/data/systems-schema.sql (tamper detection)
generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s) generator_sha TEXT NOT NULL, -- SHA-1 of the generator source file(s)
generated_at TEXT NOT NULL DEFAULT (datetime('now')) generated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
``` ```
`schema_version` is a **monotonic semver string** (e.g. `"1.0.0"`), not a hash.
It is defined as the `SCHEMA_VERSION` constant in `tooling/economy-db/import_economics.py`
and must be bumped manually whenever the schema changes in a backwards-incompatible way.
Unlike a SHA-1 hash, semver strings are orderable — this enables savegame migration
lineage in Phase 5+: a save file can record which schema version it derives from and
determine exactly which migrations to apply (#888). The old SHA-1 is preserved in
`schema_sha` for tamper detection alongside the semver.
The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's The `generator_sha` is the SHA-1 of the concatenated bytes of the generator's
source files (sorted by path, so order is deterministic). If any source file source files (sorted by path, so order is deterministic). If any source file
changes and `make regen-db` is not re-run, the stamped SHA will differ from the changes and `make regen-db` is not re-run, the stamped SHA will differ from the
@@ -166,8 +175,14 @@ no hand-edit path that survives regen.
--- ---
## Future: savegame migration lineage ## Savegame migration lineage (Phase 5+)
The `meta.schema_version` field records the schema SHA at generation time. When the `meta.schema_version` now stores a monotonic semver string (#888). When the savegame
savegame system is built (Phase 5+), a save file can record which systems.db snapshot system is built (Phase 5+), a save file records its `schema_version` string; the
it derives from, enabling forward migration without branching the DB file itself. loader can determine which migrations to apply by comparing that version to the
current one. `meta.schema_sha` retains the old SHA-1 for tamper detection.
**When to bump `SCHEMA_VERSION`:** edit the `SCHEMA_VERSION = "1.0.0"` constant in
`tooling/economy-db/import_economics.py` whenever a schema change is backwards-incompatible
(column removed, type changed, FK constraint added, table dropped). Additive changes
(new nullable columns, new tables, new indexes) do not require a bump.
+9 -5
View File
@@ -481,10 +481,13 @@ CREATE INDEX IF NOT EXISTS idx_star_systems_currency ON star_systems(currency_zo
CREATE INDEX IF NOT EXISTS idx_gate_links_from ON gate_links(from_system_id); CREATE INDEX IF NOT EXISTS idx_gate_links_from ON gate_links(from_system_id);
CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id); CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id);
-- Generator metadata stamp (#855, #856) -- Generator metadata stamp (#855, #856, #888)
-- One row per generator, updated on each successful non-dry-run. -- One row per generator, updated on each successful non-dry-run.
-- schema_version: SHA-1 of server/data/systems-schema.sql content at generation time -- schema_version: monotonic semver string (e.g. "1.0.0") — bump on backwards-incompatible changes.
-- generator_sha: SHA-1 of the generator source file(s) content -- Orderable, enabling savegame migration lineage (Phase 5+).
-- Defined as SCHEMA_VERSION constant in tooling/economy-db/import_economics.py.
-- schema_sha: SHA-1 hex of systems-schema.sql content at generation time (tamper detection).
-- generator_sha: SHA-1 hex of the generator source file(s) content
-- generated_at: ISO-8601 UTC timestamp of the run -- generated_at: ISO-8601 UTC timestamp of the run
-- --
-- Used by: -- Used by:
@@ -492,8 +495,9 @@ CREATE INDEX IF NOT EXISTS idx_gate_links_to ON gate_links(to_system_id);
-- .config/hooks/pre-push — rejects pushes with stale DB (#857) -- .config/hooks/pre-push — rejects pushes with stale DB (#857)
-- /pr-push skill — triggers make regen-db if stale (#858) -- /pr-push skill — triggers make regen-db if stale (#858)
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' | 'generate_brands' generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas'
schema_version TEXT NOT NULL, -- SHA-1 hex of systems-schema.sql content schema_version TEXT NOT NULL, -- monotonic semver string (e.g. "1.0.0") — see #888
schema_sha TEXT, -- SHA-1 hex of systems-schema.sql content (tamper detection)
generator_sha TEXT NOT NULL, -- SHA-1 hex of generator source file(s) content generator_sha TEXT NOT NULL, -- SHA-1 hex of generator source file(s) content
generated_at TEXT NOT NULL DEFAULT (datetime('now')) generated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
Binary file not shown.
+21 -2
View File
@@ -20,10 +20,14 @@ Decision refs: #855 (generator versioning), #857 (pre-push hook)
""" """
import hashlib import hashlib
import re
import sqlite3 import sqlite3
import sys import sys
from pathlib import Path 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 REPO_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
@@ -73,7 +77,7 @@ def check(verbose: bool = False) -> int:
try: try:
conn = sqlite3.connect(str(DB_PATH)) conn = sqlite3.connect(str(DB_PATH))
rows = conn.execute( rows = conn.execute(
"SELECT generator_name, generator_sha FROM meta" "SELECT generator_name, schema_version, generator_sha FROM meta"
).fetchall() ).fetchall()
conn.close() conn.close()
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -91,7 +95,17 @@ def check(verbose: bool = False) -> int:
stale: list[str] = [] stale: list[str] = []
unknown: 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) sources = GENERATOR_SOURCES.get(generator_name)
if sources is None: if sources is None:
# Unknown generator — fail closed (T6). A future branch adding a # 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}" 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: if unknown:
print( print(
"check-systems-db-stamp: UNKNOWN generator(s) in meta table: " "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" 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: def _file_sha1(*paths: Path) -> str:
"""Return SHA-1 hex of the concatenated content of one or more files. """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) schema_sha = _file_sha1(SCHEMA_SQL)
generator_sha = _file_sha1(*source_files) generator_sha = _file_sha1(*source_files)
conn.execute( conn.execute(
"""INSERT OR REPLACE INTO meta (generator_name, schema_version, generator_sha, generated_at) """INSERT OR REPLACE INTO meta
VALUES (?, ?, ?, datetime('now'))""", (generator_name, schema_version, schema_sha, generator_sha, generated_at)
(generator_name, schema_sha, generator_sha), VALUES (?, ?, ?, ?, datetime('now'))""",
(generator_name, SCHEMA_VERSION, schema_sha, generator_sha),
) )
@@ -291,6 +299,7 @@ COLUMN_MIGRATIONS = [
("corporations", "supply_chain_role", "TEXT"), ("corporations", "supply_chain_role", "TEXT"),
("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"), ("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"),
("brand_products", "price_tier", "TEXT"), ("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) # 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: def _file_sha1(*paths: Path) -> str:
"""Return SHA-1 hex of the concatenated content of one or more files. """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__)) generator_sha = _file_sha1(Path(__file__))
conn.execute( conn.execute(
"""INSERT OR REPLACE INTO meta """INSERT OR REPLACE INTO meta
(generator_name, schema_version, generator_sha, generated_at) (generator_name, schema_version, schema_sha, generator_sha, generated_at)
VALUES ('generate_atlas', ?, ?, datetime('now'))""", VALUES ('generate_atlas', ?, ?, ?, datetime('now'))""",
(schema_sha, generator_sha), (SCHEMA_VERSION, schema_sha, generator_sha),
) )
@@ -172,11 +177,17 @@ def ensure_atlas_schema(conn: sqlite3.Connection) -> None:
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
generator_name TEXT PRIMARY KEY, generator_name TEXT PRIMARY KEY,
schema_version TEXT NOT NULL, schema_version TEXT NOT NULL,
schema_sha TEXT,
generator_sha TEXT NOT NULL, generator_sha TEXT NOT NULL,
generated_at TEXT NOT NULL DEFAULT (datetime('now')) 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: def _first_int(values, default: int = 0) -> int: