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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
@@ -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: "
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user