From dc2bffc57e857f9042cbac2e96df84eb73a5a3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 10 Oct 2026 19:34:52 +0500 Subject: [PATCH] Keep complete schema diagnostics across update rollback --- .../20261010-schema-validation-report.md | 7 ++++ crates/coa-core/src/schema_check.rs | 38 ++++++++++++++++++- crates/coa-core/src/update.rs | 8 ++-- tools/test_module_sql.py | 16 ++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 changelog.d/20261010-schema-validation-report.md diff --git a/changelog.d/20261010-schema-validation-report.md b/changelog.d/20261010-schema-validation-report.md new file mode 100644 index 0000000..3c2d05a --- /dev/null +++ b/changelog.d/20261010-schema-validation-report.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Failed database checks keep a complete report after rollback +--- +Schema errors show readable default values and save every detected difference for troubleshooting, including after the server is restored. diff --git a/crates/coa-core/src/schema_check.rs b/crates/coa-core/src/schema_check.rs index 0bb5e5c..385297a 100644 --- a/crates/coa-core/src/schema_check.rs +++ b/crates/coa-core/src/schema_check.rs @@ -39,6 +39,34 @@ pub fn require_release_contract(root: &Path) -> Result { #[derive(Debug, Serialize)] pub struct Problem { pub database: String, pub table: String, pub column: String, pub detail: String } +/// Keep all differences for support; never change a database to match a contract automatically. +pub fn check_with_report(db: &Db, root: &Path) -> Result> { + let problems = check(db, root)?; + let report = serde_json::json!({ + "schema": 1, "checkedAt": chrono::Utc::now().to_rfc3339(), + "realm": db.realm().name(), "problems": problems, + }); + let path = crate::registry::metadata_dir_for(root)?.join("diagnostics") + .join(format!("schema-validation-{}.json", db.realm().name())); + // A log write failure must neither hide the original mismatch nor accept an invalid schema. + if let Err(error) = fsx::atomic_write_json(&path, &report) { + tracing::warn!(%error, path = %path.display(), "Could not save database schema report"); + } + Ok(problems) +} + +fn describe_signature(signature: &str) -> String { + let fields: Vec<_> = signature.split('|').collect(); + if fields.len() != 4 { return signature.into(); } + let decode = |value: &str| -> String { + if value == "" { return "no default".into(); } + if value == "" { return "none".into(); } + hex::decode(value).ok().and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|text| format!("{text:?}")).unwrap_or_else(|| value.into()) + }; + format!("type={}, nullable={}, default={}, extra={}", fields[0], fields[1], decode(fields[2]), decode(fields[3])) +} + fn read_columns(db: &Db, kind: &str) -> Result>> { let schema = db.realm_schema(crate::db::schema_of(kind)?); let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?; @@ -98,7 +126,7 @@ pub fn check(db: &Db, root: &Path) -> Result> { for (column, want) in cols { let found = actual[table].get(column); if found.is_none() || (!want.is_empty() && found != Some(want)) { - problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {want}, found {s}")).unwrap_or_else(|| "Missing column".into()) }); + problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {}, found {}", describe_signature(want), describe_signature(s))).unwrap_or_else(|| "Missing column".into()) }); } } } @@ -147,6 +175,14 @@ fn missing_coa_starts(rows: &str) -> Vec { mod tests { use super::*; + #[test] + fn schema_defaults_are_readable_and_not_conflated() { + assert_eq!(describe_signature("int unsigned|NO|30|"), "type=int unsigned, nullable=NO, default=\"0\", extra=none"); + assert!(describe_signature("int unsigned|NO||").contains("default=no default")); + assert!(describe_signature("varchar(8)|YES||").contains("default=\"\"")); + assert_eq!(describe_signature("legacy"), "legacy"); + } + #[test] fn a_release_cannot_use_a_partial_or_empty_schema_contract() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 6ab2cac..232d6be 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -900,7 +900,7 @@ impl Env for RepackEnv<'_> { let mut modes = vec![crate::realms::Mode::Coa]; if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { - if let Some(problem) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + if let Some(problem) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() { return Err(Error::Invalid(format!("The installed {} database does not match its upgrade source: {}.{}: {}", mode.name(), problem.database, problem.table, problem.detail))); } } @@ -968,13 +968,13 @@ impl Env for RepackEnv<'_> { result.failed = extra.failed; if result.snapshot.is_none() { result.snapshot = extra.snapshot; } if result.failed.is_none() { - if let Some(p) = crate::schema_check::check(&other_db, root)?.first() { + if let Some(p) = crate::schema_check::check_with_report(&other_db, root)?.first() { return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail))); } } } if result.failed.is_none() { - let problems = crate::schema_check::check(db, root)?; + let problems = crate::schema_check::check_with_report(db, root)?; if let Some(p) = problems.first() { return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len()))); } @@ -995,7 +995,7 @@ impl Env for RepackEnv<'_> { let mut modes = vec![crate::realms::Mode::Coa]; if crate::realms::state(self.root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); } for mode in modes { - if let Some(p) = crate::schema_check::check(&db.clone().for_realm(mode), self.root)?.first() { + if let Some(p) = crate::schema_check::check_with_report(&db.clone().for_realm(mode), self.root)?.first() { return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", mode.name(), p.database, p.table, p.detail))); } } diff --git a/tools/test_module_sql.py b/tools/test_module_sql.py index 2ed16d5..dfa10bc 100644 --- a/tools/test_module_sql.py +++ b/tools/test_module_sql.py @@ -12,6 +12,7 @@ def main(): parser.add_argument('--mysql-bin', required=True, type=Path) parser.add_argument('--core', required=True, type=Path) parser.add_argument('--before', required=True) + parser.add_argument('--display-repair', type=Path) args = parser.parse_args() suffix = '.exe' if (args.mysql_bin / 'mysqld.exe').exists() else '' server = args.mysql_bin / ('mysqld' + suffix) @@ -66,6 +67,21 @@ def main(): effects = sql('SELECT effect_d0,effect_d1,effect_d2,effect_d3 FROM coa_boss_schedule WHERE entry=12264 AND idx=6;', schema).stdout.strip() assert effects == '2105613\t2105614\t2105615\t2105616', effects print('PASS: original SQL reproduces ERROR 1136 after migration; fixed SQL supports fresh, repeated and old-layout initialization') + if args.display_repair: + repair = args.display_repair.read_text() + sql('CREATE DATABASE display_fixture;') + sql(repair, 'display_fixture') # An absent table is not created by a default repair. + sql('CREATE TABLE creature_display_preset (entry INT UNSIGNED NOT NULL, display_id INT UNSIGNED NOT NULL, PRIMARY KEY(entry,display_id)); INSERT INTO creature_display_preset VALUES(42,123);', 'display_fixture') + before_rows = sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout + sql(repair, 'display_fixture') + sql(repair, 'display_fixture') + default = sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() + assert default == '0', default + assert sql('SELECT * FROM creature_display_preset;', 'display_fixture').stdout == before_rows + sql('ALTER TABLE creature_display_preset ALTER COLUMN display_id SET DEFAULT 99;', 'display_fixture') + sql(repair, 'display_fixture') + assert sql("SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='creature_display_preset' AND COLUMN_NAME='display_id';", 'display_fixture').stdout.strip() == '99' + print('PASS: display default repair is repeatable, keeps rows and custom defaults, and tolerates absent tables') finally: subprocess.run([str(admin), *connection, 'shutdown'], capture_output=True, creationflags=flags) try: