use std::{net::TcpStream, path::Path, sync::Arc, time::Duration};
use fraiseql_core::{
db::postgres::PostgresAdapter,
runtime::{Executor, RuntimeConfig},
schema::{ArgumentDefinition, CompiledSchema, MutationDefinition, QueryDefinition},
};
use fraiseql_observers::migrations::ENTITY_CHANGE_LOG_CONTRACT;
use serde::{Deserialize, Serialize};
use crate::{
config::toml_schema::TomlSchema,
schema::{
database_validator::{create_introspector, validate_schema_against_database},
mutation_contract::{ContractReport, Severity, validate_mutation_contract},
pg_catalog::{
CaptureFnSecurity, ChangeLogActorStats, ChangeLogRlsStatus, LiveColumn, PgCatalog,
PlpgsqlCheckOutcome, PublicGrant, SecurityInvokerAudit,
},
},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorCheck {
pub name: &'static str,
pub status: CheckStatus,
pub detail: String,
pub hint: Option<String>,
}
impl DoctorCheck {
pub(crate) fn pass(name: &'static str, detail: impl Into<String>) -> Self {
Self {
name,
status: CheckStatus::Pass,
detail: detail.into(),
hint: None,
}
}
pub(crate) fn warn(
name: &'static str,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Warn,
detail: detail.into(),
hint: Some(hint.into()),
}
}
pub(crate) fn fail(
name: &'static str,
detail: impl Into<String>,
hint: impl Into<String>,
) -> Self {
Self {
name,
status: CheckStatus::Fail,
detail: detail.into(),
hint: Some(hint.into()),
}
}
}
pub fn check_schema_exists(path: &Path) -> DoctorCheck {
if path.exists() {
DoctorCheck::pass("Schema file exists", path.display().to_string())
} else {
DoctorCheck::fail(
"Schema file exists",
format!("not found: {}", path.display()),
"Run `fraiseql compile fraiseql.toml` to generate schema.compiled.json",
)
}
}
pub fn check_schema_parses(path: &Path) -> DoctorCheck {
match std::fs::read_to_string(path) {
Err(e) => DoctorCheck::fail(
"Schema parses",
format!("cannot read: {e}"),
"Check file permissions or run `fraiseql compile`",
),
Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
Err(e) => DoctorCheck::fail(
"Schema parses",
format!("JSON parse error: {e}"),
"Run `fraiseql compile fraiseql.toml` to regenerate the schema",
),
Ok(schema) => {
let types = schema.get("types").and_then(|v| v.as_array()).map_or(0, Vec::len);
let queries = schema.get("queries").and_then(|v| v.as_array()).map_or(0, Vec::len);
let mutations =
schema.get("mutations").and_then(|v| v.as_array()).map_or(0, Vec::len);
DoctorCheck::pass(
"Schema parses",
format!("types={types}, queries={queries}, mutations={mutations}"),
)
},
},
}
}
pub fn check_schema_version(path: &Path) -> DoctorCheck {
const LABEL: &str = "Compiled schema build";
let Ok(content) = std::fs::read_to_string(path) else {
return DoctorCheck::warn(
LABEL,
"could not read schema file",
"Ensure schema.compiled.json is readable",
);
};
let Ok(schema) = serde_json::from_str::<serde_json::Value>(&content) else {
return DoctorCheck::warn(
LABEL,
"schema is not valid JSON — build check skipped",
"Run `fraiseql compile` to regenerate",
);
};
let current = fraiseql_core::schema::CURRENT_FRAISEQL_VERSION;
let recompile = format!("Recompile it with fraiseql-cli {current}");
match schema.get("fraiseql_version").and_then(serde_json::Value::as_str) {
Some(v) if v == current => {
DoctorCheck::pass(LABEL, format!("fraiseql_version={v} (this build)"))
},
Some(v) => DoctorCheck::fail(
LABEL,
format!(
"produced by fraiseql {v}, but this is fraiseql {current} — the server refuses it"
),
recompile,
),
None => DoctorCheck::fail(
LABEL,
format!(
"no fraiseql_version stamp — the server refuses it (this is fraiseql {current})"
),
recompile,
),
}
}
pub fn check_toml_exists(path: &Path) -> DoctorCheck {
if path.exists() {
DoctorCheck::pass("fraiseql.toml found", path.display().to_string())
} else {
DoctorCheck::warn(
"fraiseql.toml found",
format!("not found: {} (using defaults)", path.display()),
"Create fraiseql.toml with `fraiseql init` or provide --config",
)
}
}
#[must_use]
pub fn effective_db_url<'a>(
db_url: Option<&'a str>,
against_db: Option<&'a str>,
) -> Option<&'a str> {
db_url.or(against_db)
}
#[must_use]
pub fn check_config_toml(config_path: &Path, schema_path: &Path) -> DoctorCheck {
if schema_is_compiled_json(schema_path) {
check_toml_syntax(config_path)
} else {
check_toml_parses(config_path)
}
}
fn schema_is_compiled_json(schema_path: &Path) -> bool {
schema_path.extension().is_some_and(|e| e.eq_ignore_ascii_case("json"))
}
fn check_toml_syntax(path: &Path) -> DoctorCheck {
const NAME: &str = "config TOML syntax valid";
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return DoctorCheck::fail(NAME, format!("cannot read: {e}"), "Check file permissions");
},
};
match toml::from_str::<toml::Value>(&content) {
Ok(_) => DoctorCheck::pass(NAME, ""),
Err(e) => {
let short = e.to_string().lines().next().unwrap_or("parse error").to_string();
DoctorCheck::fail(NAME, format!("parse error: {short}"), "Fix TOML syntax and retry")
},
}
}
pub fn check_toml_parses(path: &Path) -> DoctorCheck {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
return DoctorCheck::fail(
"TOML syntax valid",
format!("cannot read: {e}"),
"Check file permissions",
);
},
};
match TomlSchema::parse_toml(&content) {
Ok(_) => DoctorCheck::pass("TOML syntax valid", ""),
Err(e) => {
let first_line = e.to_string();
let short = first_line.lines().next().unwrap_or("parse error");
DoctorCheck::fail(
"TOML syntax valid",
format!("parse error: {short}"),
"Fix TOML syntax in fraiseql.toml and retry",
)
},
}
}
pub fn check_database_url_set(db_url_override: Option<&str>) -> DoctorCheck {
let val = db_url_override
.map(std::borrow::Cow::Borrowed)
.or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned));
if val.is_some() {
DoctorCheck::pass("DATABASE_URL set", "")
} else {
DoctorCheck::fail(
"DATABASE_URL set",
"not set",
"Set DATABASE_URL=postgres://user:pass@host:port/dbname in your environment",
)
}
}
pub fn check_db_reachable(db_url_override: Option<&str>) -> DoctorCheck {
let url_str = match db_url_override
.map(std::borrow::Cow::Borrowed)
.or_else(|| std::env::var("DATABASE_URL").ok().map(std::borrow::Cow::Owned))
{
Some(u) => u.into_owned(),
None => {
return DoctorCheck::fail(
"DATABASE_URL reachable",
"DATABASE_URL not set — cannot check connectivity",
"Set DATABASE_URL first",
);
},
};
match parse_host_port(&url_str) {
None => DoctorCheck::warn(
"DATABASE_URL reachable",
format!("could not parse host:port from URL: {url_str}"),
"Ensure DATABASE_URL is a valid postgres:// or mysql:// URL",
),
Some((host, port)) => {
let addr = format!("{host}:{port}");
match probe_tcp(&host, port) {
Ok(()) => DoctorCheck::pass("DATABASE_URL reachable", addr),
Err(ProbeError::Resolution(e)) => DoctorCheck::fail(
"DATABASE_URL reachable",
format!("host does not resolve ({addr}): {e}"),
format!("Check the hostname in DATABASE_URL — DNS lookup of '{host}' failed"),
),
Err(ProbeError::Connect(e)) => DoctorCheck::fail(
"DATABASE_URL reachable",
format!("connection refused ({addr}): {e}"),
format!(
"Check that the database is running: pg_isready -h {host} -p {port}\n\
Or set DATABASE_URL=postgres://user:pass@host:port/dbname"
),
),
}
},
}
}
enum ProbeError {
Resolution(String),
Connect(std::io::Error),
}
fn probe_tcp(host: &str, port: u16) -> std::result::Result<(), ProbeError> {
use std::net::ToSocketAddrs;
let addrs: Vec<std::net::SocketAddr> = (host, port)
.to_socket_addrs()
.map_err(|e| ProbeError::Resolution(e.to_string()))?
.collect();
if addrs.is_empty() {
return Err(ProbeError::Resolution("no addresses returned".to_string()));
}
let mut last_err: Option<std::io::Error> = None;
for addr in addrs {
match TcpStream::connect_timeout(&addr, Duration::from_secs(5)) {
Ok(_) => return Ok(()),
Err(e) => last_err = Some(e),
}
}
Err(ProbeError::Connect(last_err.unwrap_or_else(|| {
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "no address accepted")
})))
}
pub fn check_jwt_secret() -> DoctorCheck {
if std::env::var("FRAISEQL_JWT_SECRET").is_ok() {
DoctorCheck::pass("FRAISEQL_JWT_SECRET", "set")
} else {
DoctorCheck::warn(
"FRAISEQL_JWT_SECRET",
"not set (auth will reject all tokens)",
"Set FRAISEQL_JWT_SECRET in your environment or .env file",
)
}
}
pub fn check_redis_reachable() -> DoctorCheck {
let Ok(url_str) = std::env::var("REDIS_URL") else {
return DoctorCheck::pass("FRAISEQL_REDIS_URL", "not set (OK: cache disabled)");
};
match parse_host_port(&url_str) {
None => DoctorCheck::warn(
"FRAISEQL_REDIS_URL",
format!("could not parse host:port from REDIS_URL: {url_str}"),
"Ensure REDIS_URL is a valid redis:// URL",
),
Some((host, port)) => {
let addr = format!("{host}:{port}");
match probe_tcp(&host, port) {
Ok(()) => DoctorCheck::pass("FRAISEQL_REDIS_URL", format!("reachable ({addr})")),
Err(ProbeError::Resolution(e)) => DoctorCheck::fail(
"FRAISEQL_REDIS_URL",
format!("host does not resolve ({addr}): {e}"),
"Check the hostname in REDIS_URL, or unset REDIS_URL to disable caching",
),
Err(ProbeError::Connect(e)) => DoctorCheck::fail(
"FRAISEQL_REDIS_URL",
format!("set but not reachable ({addr}): {e}"),
"Check that Redis is running or unset REDIS_URL to disable caching",
),
}
},
}
}
pub fn check_tls(config_path: &Path) -> DoctorCheck {
let Ok(content) = std::fs::read_to_string(config_path) else {
return DoctorCheck::warn(
"TLS certificate",
format!("could not read {} — TLS not verified", config_path.display()),
"Point --config at a readable config file, or remove the flag if TLS is not used",
);
};
let Ok(value) = content.parse::<toml::Value>() else {
return DoctorCheck::warn(
"TLS certificate",
"config is not valid TOML — TLS not verified",
"Fix the TOML syntax so the TLS certificate check can run",
);
};
let tls = value.get("server").and_then(|s| s.get("tls"));
let enabled = tls
.and_then(|t| t.get("enabled"))
.and_then(toml::Value::as_bool)
.unwrap_or(false);
if !enabled {
return DoctorCheck::pass("TLS certificate", "not configured (OK: TLS disabled)");
}
let cert = tls
.and_then(|t| t.get("cert_file"))
.and_then(toml::Value::as_str)
.unwrap_or_default()
.to_string();
let cert = &cert;
if cert.is_empty() {
return DoctorCheck::fail(
"TLS certificate",
"TLS enabled but cert_file is empty",
"Set [server.tls] cert_file and key_file in fraiseql.toml",
);
}
if Path::new(cert).exists() {
DoctorCheck::pass("TLS certificate", format!("found: {cert}"))
} else {
DoctorCheck::fail(
"TLS certificate",
format!("TLS enabled but cert_file not found: {cert}"),
"Provide a valid PEM certificate at the configured path",
)
}
}
pub fn check_rls_cache_coherence(config_path: &Path) -> DoctorCheck {
let Ok(content) = std::fs::read_to_string(config_path) else {
return DoctorCheck::warn(
"Cache + auth coherence",
format!("could not read {} — coherence not verified", config_path.display()),
"Point --config at a readable config file",
);
};
let Ok(value) = content.parse::<toml::Value>() else {
return DoctorCheck::warn(
"Cache + auth coherence",
"config is not valid TOML — coherence not verified",
"Fix the TOML syntax so the cache/auth coherence check can run",
);
};
let caching_enabled = value
.get("caching")
.and_then(|c| c.get("enabled"))
.and_then(toml::Value::as_bool)
.unwrap_or(false);
let security = value.get("security");
let has_rls = security
.and_then(|s| s.get("rls"))
.and_then(|r| r.get("enabled"))
.and_then(toml::Value::as_bool)
.unwrap_or(false);
let has_roles = security
.and_then(|s| s.get("role_definitions"))
.and_then(toml::Value::as_array)
.is_some_and(|a| !a.is_empty());
let has_auth_policy = has_rls || has_roles;
match (caching_enabled, has_auth_policy) {
(false, _) => {
DoctorCheck::pass("Cache + auth coherence", "cache disabled — no cross-user risk")
},
(true, true) => {
DoctorCheck::pass("Cache + auth coherence", "caching + auth policy both configured")
},
(true, false) => DoctorCheck::warn(
"Cache + auth coherence",
"caching enabled without authorization policy — cached results may leak across users",
"Declare [security.rls] enabled = true (with policies in the database), or \
define [[security.role_definitions]] and mark fields `requires_scope`",
),
}
}
pub(crate) fn parse_host_port(url: &str) -> Option<(String, u16)> {
let after_scheme = url.split("://").nth(1)?;
let host_part = after_scheme.split('/').next()?;
let host_port = host_part.split('@').next_back()?;
if host_port.starts_with('[') {
let bracket_end = host_port.find(']')?;
let host = host_port[1..bracket_end].to_string();
let after_bracket = &host_port[bracket_end + 1..];
let port = after_bracket.trim_start_matches(':').parse::<u16>().ok()?;
return Some((host, port));
}
let mut parts = host_port.rsplitn(2, ':');
let port = parts.next()?.parse::<u16>().ok()?;
let host = parts.next().unwrap_or("localhost").to_string();
Some((host, port))
}
pub fn print_text_report(checks: &[DoctorCheck]) {
println!("\nChecking FraiseQL setup...\n");
for check in checks {
let symbol = match check.status {
CheckStatus::Pass => "✓",
CheckStatus::Warn => "!",
CheckStatus::Fail => "✗",
};
let detail = if check.detail.is_empty() {
String::new()
} else {
format!(" {}", check.detail)
};
println!(" [{symbol}] {:<30}{detail}", check.name);
if let Some(hint) = &check.hint {
for line in hint.lines() {
println!(" → {line}");
}
}
}
let errors = checks.iter().filter(|c| c.status == CheckStatus::Fail).count();
let warnings = checks.iter().filter(|c| c.status == CheckStatus::Warn).count();
println!();
match (errors, warnings) {
(0, 0) => println!("All checks passed."),
(0, w) => println!("Summary: 0 errors, {w} warning(s)"),
(e, 0) => println!("Summary: {e} error(s), 0 warnings"),
(e, w) => println!("Summary: {e} error(s), {w} warning(s)"),
}
}
pub fn print_json_report(checks: &[DoctorCheck]) {
let json = serde_json::to_string_pretty(checks).unwrap_or_else(|_| "[]".to_string());
println!("{json}");
}
pub fn run_checks(
config_path: &Path,
schema_path: &Path,
db_url_override: Option<&str>,
) -> Vec<DoctorCheck> {
let mut checks = Vec::new();
checks.push(check_schema_exists(schema_path));
if schema_path.exists() {
checks.push(check_schema_parses(schema_path));
checks.push(check_schema_version(schema_path));
}
checks.push(check_toml_exists(config_path));
if config_path.exists() {
checks.push(check_config_toml(config_path, schema_path));
}
checks.push(check_database_url_set(db_url_override));
checks.push(check_db_reachable(db_url_override));
checks.push(check_jwt_secret());
checks.push(check_redis_reachable());
checks.push(check_tls(config_path));
checks.push(check_rls_cache_coherence(config_path));
checks
}
pub async fn run_with_db_checks(
config: &Path,
schema: &Path,
db_url: Option<&str>,
against_db: Option<&str>,
runtime: bool,
schemas: &[String],
json: bool,
) -> bool {
let mut checks = run_checks(config, schema, effective_db_url(db_url, against_db));
if let Some(url) = against_db {
checks.extend(changelog_contract_checks(url).await);
checks.extend(changelog_rls_checks(url).await);
checks.extend(changelog_public_grants_checks(url).await);
checks.extend(changelog_actor_checks(url).await);
checks.extend(capture_fn_security_checks(url).await);
checks.extend(body_resolution_checks(url, schemas).await);
checks.extend(mutation_contract_checks(url, schema).await);
checks.extend(view_drift_checks(url, schema).await);
checks.extend(rls_security_invoker_checks(url, schema).await);
checks.extend(pagination_index_checks(url, schema).await);
}
if runtime {
checks.extend(runtime_probe_checks(against_db.or(db_url), schema).await);
}
if json {
print_json_report(&checks);
} else {
print_text_report(&checks);
}
checks.iter().all(|c| c.status != CheckStatus::Fail)
}
const PAGINATION_INDEX_NAME: &str = "Pagination index";
async fn pagination_index_checks(db_url: &str, schema_path: &Path) -> Vec<DoctorCheck> {
use crate::commands::pagination_index_advice::{Advice, advise};
let schema = match std::fs::read_to_string(schema_path)
.map_err(|e| format!("cannot read schema: {e}"))
.and_then(|c| {
serde_json::from_str::<CompiledSchema>(&c)
.map_err(|e| format!("schema parse error: {e}"))
}) {
Ok(s) => s,
Err(detail) => {
return vec![DoctorCheck::warn(
PAGINATION_INDEX_NAME,
format!("skipped — {detail}"),
"Run `fraiseql compile` to produce a valid schema.compiled.json",
)];
},
};
let introspector = match crate::commands::compile::build_postgres_introspector(db_url) {
Ok(i) => i,
Err(e) => {
return vec![DoctorCheck::fail(
PAGINATION_INDEX_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
let mut checks = Vec::new();
let mut examined = 0_usize;
for query in &schema.queries {
let (Some(view), true) = (query.sql_source.as_deref(), query.pagination_order.is_some())
else {
continue;
};
let bases = match introspector.resolve_base_relations(view).await {
Ok(b) => b,
Err(e) => {
checks.push(DoctorCheck::warn(
PAGINATION_INDEX_NAME,
format!("{}: could not resolve '{view}' — {e}", query.name),
"Check that the view exists and the role can read the catalog",
));
continue;
},
};
let [base] = bases.as_slice() else {
if bases.len() > 1 {
checks.push(DoctorCheck::warn(
PAGINATION_INDEX_NAME,
format!(
"{}: '{view}' reads {} relations — cannot attribute the page order to one",
query.name,
bases.len()
),
"Index the relation the ORDER BY resolves against, by hand",
));
}
continue;
};
let indexes = match introspector.get_index_definitions(base).await {
Ok(i) => i,
Err(e) => {
checks.push(DoctorCheck::warn(
PAGINATION_INDEX_NAME,
format!("{}: could not read indexes on '{base}' — {e}", query.name),
"Check that the role can read the catalog",
));
continue;
},
};
examined += 1;
for found in advise(query, base, &indexes) {
let detail = match &found.advice {
Advice::TieBreakUnindexed { tie_break } => format!(
"{}: nothing on '{base}' leads with {tie_break}, so every page sorts the whole relation",
query.name
),
Advice::MissingComposite {
sort_keys,
tie_break,
} => format!(
"{}: {} indexed on '{base}' without {tie_break} following — an incremental sort per page",
query.name,
sort_keys.join(", ")
),
};
checks.push(DoctorCheck::warn(PAGINATION_INDEX_NAME, detail, found.ddl.join(" ")));
}
}
if checks.is_empty() {
let subject = if examined == 1 {
"query is"
} else {
"queries are"
};
checks.push(DoctorCheck::pass(
PAGINATION_INDEX_NAME,
format!("{examined} paginated {subject} ordered by an index"),
));
}
checks
}
const RUNTIME_NAME: &str = "Runtime smoke";
const RUNTIME_QUERY_LABEL: &str = "Runtime query probe";
const RUNTIME_MUTATION_LABEL: &str = "Runtime mutation probe (dry-run)";
pub async fn runtime_probe_checks(db_url: Option<&str>, schema_path: &Path) -> Vec<DoctorCheck> {
let Some(url) = db_url else {
return vec![DoctorCheck::fail(
RUNTIME_NAME,
"no database URL provided for --runtime",
"Pass --against-db <postgres-url> (or --db-url) together with --runtime.",
)];
};
if !(url.starts_with("postgres://") || url.starts_with("postgresql://")) {
return vec![DoctorCheck::fail(
RUNTIME_NAME,
"runtime smoke supports PostgreSQL only",
"Pass a postgres:// URL to --against-db.",
)];
}
let schema = match load_compiled_schema(schema_path) {
Ok(schema) => schema,
Err(detail) => {
return vec![DoctorCheck::fail(
RUNTIME_NAME,
detail,
"Run `fraiseql compile` to (re)generate the compiled schema.",
)];
},
};
let adapter = match PostgresAdapter::new(url).await {
Ok(adapter) => Arc::new(adapter),
Err(e) => {
return vec![DoctorCheck::fail(
RUNTIME_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db.",
)];
},
};
let config = RuntimeConfig {
dry_run_mutations: true,
..RuntimeConfig::default()
};
let probe_schema = schema.clone();
let executor = Executor::with_config(schema, adapter, config);
let mut checks = Vec::new();
for query in &probe_schema.queries {
if let Some(op) = minimal_query_probe(query, &probe_schema) {
let result = executor.execute(&op, None).await;
checks.push(probe_outcome(RUNTIME_QUERY_LABEL, &query.name, &result));
} else {
checks.push(DoctorCheck::warn(
RUNTIME_QUERY_LABEL,
format!("{} skipped — requires arguments", query.name),
format!("Probe manually: fraiseql query '{{ {}(…) {{ … }} }}'", query.name),
));
}
}
for mutation in &probe_schema.mutations {
if let Some(op) = minimal_mutation_probe(mutation, &probe_schema) {
let result = executor.execute(&op, None).await;
checks.push(probe_outcome(RUNTIME_MUTATION_LABEL, &mutation.name, &result));
} else {
checks.push(DoctorCheck::warn(
RUNTIME_MUTATION_LABEL,
format!("{} skipped — requires arguments", mutation.name),
format!(
"Dry-run manually: fraiseql query --dry-run 'mutation {{ {}(…) {{ … }} }}'",
mutation.name
),
));
}
}
if checks.is_empty() {
checks.push(DoctorCheck::warn(
RUNTIME_NAME,
"no root queries or mutations to probe",
"Compile a schema that declares queries or mutations.",
));
}
checks
}
fn load_compiled_schema(path: &Path) -> Result<CompiledSchema, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
CompiledSchema::from_json(&text, false)
.map_err(|e| format!("cannot parse {}: {e}", path.display()))
}
fn probe_outcome(
label: &'static str,
op_name: &str,
result: &fraiseql_core::error::Result<serde_json::Value>,
) -> DoctorCheck {
match result {
Ok(value) if !crate::commands::query::has_errors(value) => {
DoctorCheck::pass(label, format!("{op_name} resolves"))
},
Ok(value) => DoctorCheck::fail(
label,
format!(
"{op_name}: {}",
first_error_message(value).unwrap_or_else(|| "resolution error".to_string())
),
"Inspect with `fraiseql query`.",
),
Err(e) => {
DoctorCheck::fail(label, format!("{op_name}: {e}"), "Inspect with `fraiseql query`.")
},
}
}
fn first_error_message(value: &serde_json::Value) -> Option<String> {
value
.get("errors")?
.as_array()?
.first()?
.get("message")?
.as_str()
.map(ToString::to_string)
}
pub(crate) fn probe_requires_arguments(args: &[ArgumentDefinition]) -> bool {
args.iter().any(|a| !a.nullable && a.default_value.is_none())
}
pub(crate) fn minimal_selection_for_type(
type_name: &str,
schema: &CompiledSchema,
) -> Option<String> {
if let Some(type_def) = schema.types.iter().find(|t| t.name.as_str() == type_name) {
let field = type_def.fields.iter().find(|f| f.field_type.is_scalar());
return Some(
field.map_or_else(|| "__typename".to_string(), |f| f.name.as_str().to_string()),
);
}
if schema.unions.iter().any(|u| u.name.as_str() == type_name) {
return Some("__typename".to_string());
}
None
}
pub(crate) fn minimal_query_probe(
query: &QueryDefinition,
schema: &CompiledSchema,
) -> Option<String> {
if probe_requires_arguments(&query.arguments) {
return None;
}
Some(match minimal_selection_for_type(&query.return_type, schema) {
Some(sel) => format!("{{ {} {{ {sel} }} }}", query.name),
None => format!("{{ {} }}", query.name),
})
}
pub(crate) fn minimal_mutation_probe(
mutation: &MutationDefinition,
schema: &CompiledSchema,
) -> Option<String> {
if probe_requires_arguments(&mutation.arguments) {
return None;
}
Some(match minimal_selection_for_type(&mutation.return_type, schema) {
Some(sel) => format!("mutation {{ {} {{ {sel} }} }}", mutation.name),
None => format!("mutation {{ {} }}", mutation.name),
})
}
async fn body_resolution_checks(db_url: &str, schemas: &[String]) -> Vec<DoctorCheck> {
const NAME: &str = "PL/pgSQL body resolution";
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match catalog.plpgsql_check_unresolved_calls(schemas).await {
Err(e) => vec![DoctorCheck::fail(
NAME,
format!("pass failed: {e}"),
"Ensure the connecting role may CREATE EXTENSION plpgsql_check",
)],
Ok(PlpgsqlCheckOutcome::Unavailable) => vec![DoctorCheck::warn(
NAME,
"plpgsql_check not installed — skipped",
"Install the plpgsql_check extension to enable internal-call checking",
)],
Ok(PlpgsqlCheckOutcome::Ran { errors }) if errors.is_empty() => vec![DoctorCheck::pass(
NAME,
format!("all internal calls resolve in {}", schemas.join(", ")),
)],
Ok(PlpgsqlCheckOutcome::Ran { errors }) => errors
.iter()
.map(|e| {
let location = e.lineno.map_or_else(String::new, |l| format!(" (line {l})"));
DoctorCheck::fail(
NAME,
format!("{}{location}: {}", e.caller, e.message),
"A migration changed a function signature — update its internal callers",
)
})
.collect(),
}
}
const MUTATION_CONTRACT_NAME: &str = "Mutation→function contract";
pub(crate) fn mutation_contract_drift(report: &ContractReport) -> Vec<DoctorCheck> {
if report.mutations.is_empty() {
return vec![DoctorCheck::pass(
MUTATION_CONTRACT_NAME,
format!(
"all {} DB-backed mutation(s) resolve to a matching function ({} skipped)",
report.checked, report.skipped
),
)];
}
report
.mutations
.iter()
.flat_map(|m| {
m.violations.iter().map(move |v| {
let detail = format!("{} (sql_source: {}): {v}", m.mutation, m.sql_source);
match v.severity() {
Severity::Error => DoctorCheck::fail(
MUTATION_CONTRACT_NAME,
detail,
"Declared mutation does not match its backing function — create or fix \
the function, or correct the schema",
),
Severity::Warn => DoctorCheck::warn(
MUTATION_CONTRACT_NAME,
detail,
"The backing function differs from the declaration (non-fatal)",
),
}
})
})
.collect()
}
async fn mutation_contract_checks(db_url: &str, schema_path: &Path) -> Vec<DoctorCheck> {
let schema = match std::fs::read_to_string(schema_path)
.map_err(|e| format!("cannot read schema: {e}"))
.and_then(|c| {
serde_json::from_str::<CompiledSchema>(&c)
.map_err(|e| format!("schema parse error: {e}"))
}) {
Ok(s) => s,
Err(detail) => {
return vec![DoctorCheck::warn(
MUTATION_CONTRACT_NAME,
format!("skipped — {detail}"),
"Run `fraiseql compile` to produce a valid schema.compiled.json",
)];
},
};
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
MUTATION_CONTRACT_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match validate_mutation_contract(&schema, &catalog).await {
Ok(report) => mutation_contract_drift(&report),
Err(e) => vec![DoctorCheck::fail(
MUTATION_CONTRACT_NAME,
format!("contract check failed: {e}"),
"Ensure the connecting role can read pg_catalog for the function schema",
)],
}
}
const VIEW_DRIFT_NAME: &str = "View drift";
async fn view_drift_checks(db_url: &str, schema_path: &Path) -> Vec<DoctorCheck> {
let schema = match std::fs::read_to_string(schema_path)
.map_err(|e| format!("cannot read schema: {e}"))
.and_then(|c| {
serde_json::from_str::<CompiledSchema>(&c)
.map_err(|e| format!("schema parse error: {e}"))
}) {
Ok(s) => s,
Err(detail) => {
return vec![DoctorCheck::warn(
VIEW_DRIFT_NAME,
format!("skipped — {detail}"),
"Run `fraiseql compile` to produce a valid schema.compiled.json",
)];
},
};
let introspector = match create_introspector(db_url).await {
Ok(i) => i,
Err(e) => {
return vec![DoctorCheck::fail(
VIEW_DRIFT_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match validate_schema_against_database(&schema, &introspector).await {
Ok(report) if report.warnings.is_empty() => {
vec![DoctorCheck::pass(
VIEW_DRIFT_NAME,
"no schema↔database drift found",
)]
},
Ok(report) => report
.warnings
.iter()
.map(|w| match w.severity() {
Severity::Error => DoctorCheck::fail(
VIEW_DRIFT_NAME,
w.to_string(),
"Fix the declaration or the database object it names",
),
Severity::Warn => DoctorCheck::warn(
VIEW_DRIFT_NAME,
w.to_string(),
"Advisory — see `compile --database` for the same finding at build time",
),
})
.collect(),
Err(e) => vec![DoctorCheck::fail(
VIEW_DRIFT_NAME,
format!("view-drift check failed: {e}"),
"Ensure the connecting role can read the relation catalogs",
)],
}
}
const CHANGELOG_CONTRACT_NAME: &str = "Change-log contract";
async fn changelog_contract_checks(db_url: &str) -> Vec<DoctorCheck> {
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
CHANGELOG_CONTRACT_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match catalog.table_columns("core", "tb_entity_change_log").await {
Ok(live) => changelog_contract_drift(&live),
Err(e) => vec![DoctorCheck::fail(
CHANGELOG_CONTRACT_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read information_schema for the core schema",
)],
}
}
const CHANGELOG_RLS_NAME: &str = "Change-log RLS";
async fn changelog_rls_checks(db_url: &str) -> Vec<DoctorCheck> {
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
CHANGELOG_RLS_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match catalog.change_log_rls_status().await {
Ok(status) => vec![changelog_rls_check(status.as_ref())],
Err(e) => vec![DoctorCheck::fail(
CHANGELOG_RLS_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read pg_class and pg_roles",
)],
}
}
pub(crate) fn changelog_rls_check(status: Option<&ChangeLogRlsStatus>) -> DoctorCheck {
let Some(s) = status else {
return DoctorCheck::pass(
CHANGELOG_RLS_NAME,
"core.tb_entity_change_log not present — skipped",
);
};
if !s.rls_enabled {
return DoctorCheck::pass(
CHANGELOG_RLS_NAME,
"Row-Level Security is not enabled on core.tb_entity_change_log (single-tenant or \
pre-migration-12). Apply 12_enable_change_log_rls.sql to fail-close cross-tenant reads.",
);
}
if s.can_bypass || s.is_owner {
let how = if s.is_owner {
"owns the table"
} else {
"has BYPASSRLS"
};
return DoctorCheck::pass(
CHANGELOG_RLS_NAME,
format!(
"RLS enabled; the connecting role '{}' {how} — change-log consumers read all tenants.",
s.role_name
),
);
}
DoctorCheck::warn(
CHANGELOG_RLS_NAME,
format!(
"RLS is enabled on core.tb_entity_change_log but the connecting role '{}' is neither \
the table owner nor BYPASSRLS — the change-log poller, the NATS bridges, and the admin \
change-log query will silently read ZERO rows.",
s.role_name
),
"Grant BYPASSRLS to this role (ALTER ROLE … BYPASSRLS) or connect the change-log consumers \
as the table owner.",
)
}
const CHANGELOG_ACTOR_NAME: &str = "Change-log actor attribution";
async fn changelog_actor_checks(db_url: &str) -> Vec<DoctorCheck> {
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
CHANGELOG_ACTOR_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
let tokens: Vec<String> = fraiseql_core::security::ActorType::ALL
.iter()
.map(|a| a.as_str().to_string())
.collect();
match catalog.change_log_actor_stats(&tokens).await {
Ok(stats) => changelog_actor_check(stats.as_ref()),
Err(e) => vec![DoctorCheck::fail(
CHANGELOG_ACTOR_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read core.tb_entity_change_log and pg_constraint",
)],
}
}
pub(crate) fn changelog_actor_check(stats: Option<&ChangeLogActorStats>) -> Vec<DoctorCheck> {
let Some(s) = stats else {
return vec![DoctorCheck::pass(
CHANGELOG_ACTOR_NAME,
"core.tb_entity_change_log not present — skipped",
)];
};
let mut checks = Vec::new();
if s.unknown_values.is_empty() {
if !s.constraint_installed {
checks.push(DoctorCheck::warn(
CHANGELOG_ACTOR_NAME,
"chk_entity_change_log_actor_type is not installed — an arbitrary string can \
reach actor_type",
"Re-apply migration 08 (08_create_entity_change_log_contract.sql) or re-run \
`fraiseql setup`",
));
}
} else {
checks.push(DoctorCheck::fail(
CHANGELOG_ACTOR_NAME,
format!(
"out-of-contract actor_type values in core.tb_entity_change_log: {} — a rogue \
or pre-constraint writer{}",
s.unknown_values.join(", "),
if s.constraint_installed {
" (the CHECK constraint now blocks new ones)"
} else {
"; chk_entity_change_log_actor_type is also missing, so new ones are NOT \
blocked"
}
),
"Identify the writer; valid tokens are human_user, service_account, ai_agent, \
system_job. Re-apply migration 08 if the constraint is missing",
));
}
if s.null_rows > 0 {
checks.push(DoctorCheck::warn(
CHANGELOG_ACTOR_NAME,
format!(
"{} change-log row(s) carry no actor attribution (actor_type IS NULL) — \
pre-actor-model rows, or writes made without an authenticated security context",
s.null_rows
),
"New writes through the mutation runner are stamped from the SecurityContext; \
unauthenticated deployments record NULL. Enable authentication to attribute every \
action",
));
}
if checks.is_empty() {
checks.push(DoctorCheck::pass(
CHANGELOG_ACTOR_NAME,
"every change-log row carries a canonical actor_type and the domain CHECK \
constraint is installed",
));
}
checks
}
const CHANGELOG_PUBLIC_GRANTS_NAME: &str = "Change-log PUBLIC grants";
async fn changelog_public_grants_checks(db_url: &str) -> Vec<DoctorCheck> {
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
CHANGELOG_PUBLIC_GRANTS_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match catalog.change_log_public_grants().await {
Ok(grants) => vec![changelog_public_grants_check(&grants)],
Err(e) => vec![DoctorCheck::fail(
CHANGELOG_PUBLIC_GRANTS_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read pg_class.relacl for the core schema",
)],
}
}
pub(crate) fn changelog_public_grants_check(grants: &[PublicGrant]) -> DoctorCheck {
if grants.is_empty() {
return DoctorCheck::pass(
CHANGELOG_PUBLIC_GRANTS_NAME,
"change-log table/views not present — skipped",
);
}
let exposed: Vec<String> = grants
.iter()
.filter(|g| !g.privileges.is_empty())
.map(|g| {
let mut privs = g.privileges.clone();
privs.sort();
format!("core.{} ({})", g.relname, privs.join(", "))
})
.collect();
if exposed.is_empty() {
return DoctorCheck::pass(
CHANGELOG_PUBLIC_GRANTS_NAME,
"no PUBLIC privileges on the change-log table or views (least-privilege baseline \
intact)",
);
}
DoctorCheck::warn(
CHANGELOG_PUBLIC_GRANTS_NAME,
format!(
"PUBLIC can still access the change-log — every tenant's before/after payload is \
world-readable: {}",
exposed.join("; ")
),
"REVOKE ALL ON <relation> FROM PUBLIC (or re-run migration \
12_enable_change_log_rls.sql) so only explicitly granted roles read the change-log.",
)
}
const SECURITY_INVOKER_NAME: &str = "RLS view security_invoker";
async fn rls_security_invoker_checks(db_url: &str, schema_path: &Path) -> Vec<DoctorCheck> {
let schema = match load_compiled_schema(schema_path) {
Ok(s) => s,
Err(e) => {
return vec![DoctorCheck::fail(
SECURITY_INVOKER_NAME,
format!("cannot load compiled schema: {e}"),
"Compile the schema before running --against-db.",
)];
},
};
let mut views: Vec<String> = schema
.types
.iter()
.map(|t| t.sql_source.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.rsplit('.').next().unwrap_or(s).to_string())
.collect();
views.sort();
views.dedup();
if views.is_empty() {
return vec![DoctorCheck::pass(
SECURITY_INVOKER_NAME,
"no sql_source views to audit",
)];
}
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
SECURITY_INVOKER_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db.",
)];
},
};
match catalog.security_invoker_audit(&views).await {
Ok(audit) => vec![security_invoker_check(&audit)],
Err(e) => vec![DoctorCheck::fail(
SECURITY_INVOKER_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read pg_class and pg_policies.",
)],
}
}
pub(crate) fn security_invoker_check(audit: &SecurityInvokerAudit) -> DoctorCheck {
if !audit.rls_in_use {
return DoctorCheck::pass(
SECURITY_INVOKER_NAME,
"No RLS policies in the database — security_invoker views are not required for \
row isolation.",
);
}
if audit.views_without_invoker.is_empty() {
return DoctorCheck::pass(
SECURITY_INVOKER_NAME,
"RLS is in use and every sql_source view is security_invoker — base-table RLS is \
honoured on reads and in cascades.",
);
}
DoctorCheck::warn(
SECURITY_INVOKER_NAME,
format!(
"RLS is in use, but these sql_source views are NOT security_invoker, so they run as \
the view owner and BYPASS the caller's RLS — a cross-tenant leak on ordinary reads \
AND in cascades: {}",
audit.views_without_invoker.join(", ")
),
"Recreate each as `CREATE VIEW <v> WITH (security_invoker = true) AS …` (or \
`ALTER VIEW <v> SET (security_invoker = true)`), PostgreSQL 15+.",
)
}
const CAPTURE_FN_SECURITY_NAME: &str = "Change-log capture function";
async fn capture_fn_security_checks(db_url: &str) -> Vec<DoctorCheck> {
let catalog = match PgCatalog::connect(db_url) {
Ok(c) => c,
Err(e) => {
return vec![DoctorCheck::fail(
CAPTURE_FN_SECURITY_NAME,
format!("cannot connect: {e}"),
"Pass a reachable postgres:// URL to --against-db",
)];
},
};
match catalog.capture_fn_security().await {
Ok(status) => vec![capture_fn_security_check(status.as_ref())],
Err(e) => vec![DoctorCheck::fail(
CAPTURE_FN_SECURITY_NAME,
format!("introspection failed: {e}"),
"Ensure the connecting role can read pg_proc for the core schema",
)],
}
}
pub(crate) fn capture_fn_security_check(status: Option<&CaptureFnSecurity>) -> DoctorCheck {
let Some(s) = status else {
return DoctorCheck::pass(
CAPTURE_FN_SECURITY_NAME,
"core.fn_entity_change_log_capture not present — skipped",
);
};
if !s.security_definer {
return DoctorCheck::warn(
CAPTURE_FN_SECURITY_NAME,
"core.fn_entity_change_log_capture is SECURITY INVOKER — under change-log RLS, an \
external write by a NOBYPASSRLS role runs the capture as that role instead of the \
table owner, so the capture row is not written.",
"Re-apply migration 11_create_change_log_capture_trigger.sql so the capture function \
is SECURITY DEFINER with a pinned search_path.",
);
}
match &s.search_path {
None => DoctorCheck::warn(
CAPTURE_FN_SECURITY_NAME,
"core.fn_entity_change_log_capture is SECURITY DEFINER but has no pinned search_path — \
a DEFINER function reachable from a trigger on any schema with a mutable search_path \
is a privilege-escalation vector.",
"ALTER FUNCTION core.fn_entity_change_log_capture() SET search_path = pg_catalog, core \
(or re-apply migration 11).",
),
Some(sp) => DoctorCheck::pass(
CAPTURE_FN_SECURITY_NAME,
format!("SECURITY DEFINER with pinned search_path = {sp}"),
),
}
}
pub(crate) fn changelog_contract_drift(live: &[LiveColumn]) -> Vec<DoctorCheck> {
if live.is_empty() {
return vec![DoctorCheck::warn(
CHANGELOG_CONTRACT_NAME,
"core.tb_entity_change_log not found",
"Run `fraiseql setup` (installs the change-log contract) or `fraiseql migrate up`. \
Every default mutation writes this table via its transactional-outbox CTE, so \
without it the first mutation fails at prepare time (#569).",
)];
}
let mut checks = Vec::new();
for contract in ENTITY_CHANGE_LOG_CONTRACT {
let Some(col) = live.iter().find(|c| c.name == contract.name) else {
continue;
};
if col.udt_name == contract.udt {
continue;
}
checks.push(DoctorCheck::fail(
CHANGELOG_CONTRACT_NAME,
format!(
"column `{}` is `{}`, contract expects `{}`",
contract.name, col.udt_name, contract.udt
),
format!(
"The additive migration cannot retype an existing column; ALTER it manually, \
e.g. ALTER TABLE core.tb_entity_change_log ALTER COLUMN {name} TYPE {udt} \
USING {name}::{udt};",
name = contract.name,
udt = contract.udt,
),
));
}
let missing: Vec<&str> = ENTITY_CHANGE_LOG_CONTRACT
.iter()
.map(|c| c.name)
.filter(|name| !live.iter().any(|c| c.name == *name))
.collect();
if !missing.is_empty() {
checks.push(DoctorCheck::warn(
CHANGELOG_CONTRACT_NAME,
format!("{} contract column(s) missing: {}", missing.len(), missing.join(", ")),
"Run `fraiseql migrate up` — the additive migration adds these columns",
));
}
let extra: Vec<&str> = live
.iter()
.map(|c| c.name.as_str())
.filter(|name| !ENTITY_CHANGE_LOG_CONTRACT.iter().any(|c| c.name == *name))
.collect();
if !extra.is_empty() {
checks.push(DoctorCheck::warn(
CHANGELOG_CONTRACT_NAME,
format!("{} non-contract column(s) present: {}", extra.len(), extra.join(", ")),
"Left untouched by the migration — app-specific columns are safe to keep",
));
}
if checks.is_empty() {
checks.push(DoctorCheck::pass(
CHANGELOG_CONTRACT_NAME,
format!(
"all {} contract columns present and correctly typed",
ENTITY_CHANGE_LOG_CONTRACT.len()
),
));
}
checks
}