fix: verify resource key schema drift

This commit is contained in:
2026-08-21 10:32:25 +09:00
parent 85d1815dcf
commit 41cd2d044a
+191
View File
@@ -76,6 +76,22 @@ const OWNED_TABLES: &[&str] = &[
"typed_ticket_relations", "typed_ticket_relations",
"typed_ticket_orchestration_plans", "typed_ticket_orchestration_plans",
"typed_ticket_artifacts", "typed_ticket_artifacts",
"workspace_resource_keys",
];
const RESOURCE_KEY_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("resource_kind", "TEXT", true, 2),
column("resource_id", "TEXT", true, 3),
column("sequence", "INTEGER", true, 0),
column("resource_key", "TEXT", true, 0),
column("allocated_at", "TEXT", true, 0),
];
const RESOURCE_KEY_COUNTER_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("resource_kind", "TEXT", true, 2),
column("next_sequence", "INTEGER", true, 0),
]; ];
const MIGRATION_COLUMNS: &[ExpectedColumn] = &[ const MIGRATION_COLUMNS: &[ExpectedColumn] = &[
@@ -460,6 +476,49 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
} }
collect_column_diagnostics(
connection,
"workspace_resource_keys",
RESOURCE_KEY_COLUMNS,
&mut diagnostics,
);
collect_column_diagnostics(
connection,
"workspace_resource_key_counters",
RESOURCE_KEY_COUNTER_COLUMNS,
&mut diagnostics,
);
collect_index_diagnostics(
connection,
"workspace_resource_keys",
None,
true,
&["workspace_id", "resource_kind", "sequence"],
&mut diagnostics,
);
collect_index_diagnostics(
connection,
"workspace_resource_keys",
None,
true,
&["workspace_id", "resource_key"],
&mut diagnostics,
);
collect_index_diagnostics(
connection,
"workspace_resource_keys",
Some("idx_workspace_resource_keys_reverse"),
false,
&["workspace_id", "resource_kind", "resource_key"],
&mut diagnostics,
);
for legacy_table in [
"workspace_resource_human_keys",
"workspace_resource_human_key_counters",
] {
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
}
for table in OWNED_TABLES { for table in OWNED_TABLES {
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
} }
@@ -826,6 +885,106 @@ fn verify_table(
} }
} }
fn table_exists(connection: &Connection, table: &str) -> Result<bool> {
connection
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|_| Ok(()),
)
.optional()
.map(|value| value.is_some())
.map_err(sqlite_err)
}
fn collect_absent_table_diagnostic(
connection: &Connection,
table: &str,
diagnostics: &mut Vec<String>,
) {
match table_exists(connection, table) {
Ok(false) => {}
Ok(true) => diagnostics.push(format!("legacy table `{table}` is still present")),
Err(error) => {
diagnostics.push(format!("failed to inspect legacy table `{table}`: {error}"))
}
}
}
fn collect_index_diagnostics(
connection: &Connection,
table: &str,
expected_name: Option<&str>,
expected_unique: bool,
expected_columns: &[&str],
diagnostics: &mut Vec<String>,
) {
let sql = format!("PRAGMA index_list({table})");
let mut statement = match connection.prepare(&sql) {
Ok(statement) => statement,
Err(error) => {
diagnostics.push(format!("failed to inspect indexes for `{table}`: {error}"));
return;
}
};
let rows = match statement.query_map([], |row| {
Ok((row.get::<_, String>(1)?, row.get::<_, i64>(2)? != 0))
}) {
Ok(rows) => rows,
Err(error) => {
diagnostics.push(format!("failed to read indexes for `{table}`: {error}"));
return;
}
};
let indexes = match rows.collect::<std::result::Result<Vec<_>, _>>() {
Ok(indexes) => indexes,
Err(error) => {
diagnostics.push(format!("failed to decode indexes for `{table}`: {error}"));
return;
}
};
for (name, unique) in indexes {
if expected_name.is_some_and(|expected| expected != name) || unique != expected_unique {
continue;
}
let sql = format!("PRAGMA index_info({name})");
let mut statement = match connection.prepare(&sql) {
Ok(statement) => statement,
Err(error) => {
diagnostics.push(format!("failed to inspect index `{name}`: {error}"));
return;
}
};
let rows = match statement.query_map([], |row| row.get::<_, String>(2)) {
Ok(rows) => rows,
Err(error) => {
diagnostics.push(format!("failed to read index `{name}`: {error}"));
return;
}
};
match rows.collect::<std::result::Result<Vec<_>, _>>() {
Ok(columns) if columns == expected_columns => return,
Ok(_) => {}
Err(error) => {
diagnostics.push(format!("failed to decode index `{name}`: {error}"));
return;
}
}
}
let identity = expected_name
.map(|name| format!("named `{name}`"))
.unwrap_or_else(|| "unnamed".to_string());
diagnostics.push(format!(
"table `{table}` is missing {identity} {} index on ({})",
if expected_unique {
"unique"
} else {
"non-unique"
},
expected_columns.join(", ")
));
}
fn collect_table_diagnostics( fn collect_table_diagnostics(
connection: &Connection, connection: &Connection,
table: &str, table: &str,
@@ -1270,6 +1429,38 @@ mod tests {
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 7);
} }
#[test]
fn verifier_rejects_resource_key_schema_drift() {
for (drift, expected) in [
(
"DROP TABLE workspace_resource_key_counters",
"workspace_resource_key_counters",
),
(
"ALTER TABLE workspace_resource_keys RENAME COLUMN resource_key TO human_key",
"missing column \"resource_key\"",
),
(
"DROP INDEX idx_workspace_resource_keys_reverse",
"idx_workspace_resource_keys_reverse",
),
(
"CREATE TABLE workspace_resource_human_keys (value TEXT)",
"legacy table `workspace_resource_human_keys` is still present",
),
] {
let connection = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute_batch(drift).unwrap();
let error = verify_sqlite_ticket_schema(&connection).unwrap_err();
assert!(
error.to_string().contains(expected),
"expected {expected:?} after {drift:?}, got {error}"
);
}
}
#[test] #[test]
fn verified_backend_open_fails_on_drift_without_repairing_request_schema() { fn verified_backend_open_fails_on_drift_without_repairing_request_schema() {
let directory = tempdir().unwrap(); let directory = tempdir().unwrap();