diff --git a/.claude/rules/asset-pipeline.md b/.claude/rules/asset-pipeline.md index 7a5ff8025..24289ebf1 100644 --- a/.claude/rules/asset-pipeline.md +++ b/.claude/rules/asset-pipeline.md @@ -44,12 +44,21 @@ After every successful non-dry-run, each generator writes a row to the `meta` ta ```sql CREATE TABLE meta ( 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) 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 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 @@ -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 -savegame system is built (Phase 5+), a save file can record which systems.db snapshot -it derives from, enabling forward migration without branching the DB file itself. +`meta.schema_version` now stores a monotonic semver string (#888). When the savegame +system is built (Phase 5+), a save file records its `schema_version` string; the +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. diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index 23ea5ebf1..2c2e9c9b4 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -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_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. --- schema_version: SHA-1 of server/data/systems-schema.sql content at generation time --- generator_sha: SHA-1 of the generator source file(s) content +-- schema_version: monotonic semver string (e.g. "1.0.0") — bump on backwards-incompatible changes. +-- 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 -- -- 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) -- /pr-push skill — triggers make regen-db if stale (#858) CREATE TABLE IF NOT EXISTS meta ( - generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' | 'generate_brands' - schema_version TEXT NOT NULL, -- SHA-1 hex of systems-schema.sql content + generator_name TEXT PRIMARY KEY, -- 'import_economics' | 'generate_atlas' + 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 generated_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/server/data/systems.db b/server/data/systems.db index feabcec23..ede8b6a83 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index cb44341c1..095f8542f 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -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: " diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 77df10fb2..035be62e0 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -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"), ] diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 03fede765..3b8ec9a58 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -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: