pub mod reader;
#[cfg(test)]
mod tests;
use std::{collections::HashMap, path::PathBuf};
use anyhow::{Context, Result};
use fraiseql_core::schema::{CompiledSchema, SourceDefinition};
use serde::Serialize;
use self::reader::{CursorRow, SourceCursorReader};
use crate::commands::migrate::resolve_database_url;
pub struct SourcesArgs {
pub schema: PathBuf,
pub database: Option<String>,
pub json: bool,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct RunAsStatus {
pub configured: bool,
pub roles: Vec<String>,
pub scopes: Vec<String>,
pub tenant: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct CursorStatus {
pub state: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub age_seconds: Option<f64>,
}
impl CursorStatus {
const fn unknown() -> Self {
Self {
state: "unknown",
version: None,
value: None,
updated_at: None,
age_seconds: None,
}
}
const fn never_advanced() -> Self {
Self {
state: "never_advanced",
version: None,
value: None,
updated_at: None,
age_seconds: None,
}
}
fn advanced(row: &CursorRow) -> Self {
Self {
state: "advanced",
version: Some(row.version),
value: row.value.clone(),
updated_at: Some(row.updated_at.clone()),
age_seconds: Some(row.age_seconds),
}
}
}
#[derive(Debug, Serialize)]
pub struct SourceStatus {
pub name: String,
pub schedule: String,
pub function: String,
pub enabled: bool,
pub cursor_name: String,
pub run_as: RunAsStatus,
pub cursor: CursorStatus,
}
fn build_status(
sources: &[SourceDefinition],
cursors: Option<&HashMap<String, CursorRow>>,
) -> Vec<SourceStatus> {
sources
.iter()
.map(|source| {
let cursor = match cursors {
None => CursorStatus::unknown(),
Some(map) => match map.get(source.cursor_name()) {
Some(row) => CursorStatus::advanced(row),
None => CursorStatus::never_advanced(),
},
};
SourceStatus {
name: source.name.clone(),
schedule: source.schedule.clone(),
function: source.function.clone(),
enabled: source.enabled,
cursor_name: source.cursor_name().to_string(),
run_as: run_as_status(source),
cursor,
}
})
.collect()
}
fn run_as_status(source: &SourceDefinition) -> RunAsStatus {
match &source.run_as {
None => RunAsStatus {
configured: false,
roles: Vec::new(),
scopes: Vec::new(),
tenant: None,
},
Some(run_as) => RunAsStatus {
configured: true,
roles: run_as.roles.clone(),
scopes: run_as.scopes.clone(),
tenant: run_as.tenant.clone(),
},
}
}
#[must_use]
pub fn render_text(statuses: &[SourceStatus], database_connected: bool) -> String {
use std::fmt::Write as _;
if statuses.is_empty() {
return "No sources declared in the compiled schema.\n".to_string();
}
let mut out = String::new();
let _ = writeln!(out, "Sources ({})\n", statuses.len());
for status in statuses {
let marker = if status.enabled { '●' } else { '○' };
let enabled = if status.enabled {
"enabled"
} else {
"DISABLED"
};
let _ = writeln!(
out,
"{marker} {name} schedule {schedule} {enabled}",
name = status.name,
schedule = status.schedule,
);
let _ = writeln!(out, " function {}", status.function);
let _ = writeln!(out, " run_as {}", render_run_as(&status.run_as));
let _ =
writeln!(out, " cursor {}", render_cursor(&status.cursor, database_connected));
out.push('\n');
}
out
}
fn render_run_as(run_as: &RunAsStatus) -> String {
if !run_as.configured {
return "(none — fail-closed: mutations are denied until a run_as ceiling is granted)"
.to_string();
}
let roles = if run_as.roles.is_empty() {
"[]".to_string()
} else {
format!("{:?}", run_as.roles)
};
let scopes = if run_as.scopes.is_empty() {
"[]".to_string()
} else {
format!("{:?}", run_as.scopes)
};
let tenant = run_as.tenant.as_deref().unwrap_or("(per-message / global)");
format!("roles={roles} scopes={scopes} tenant={tenant}")
}
fn render_cursor(cursor: &CursorStatus, database_connected: bool) -> String {
match cursor.state {
"advanced" => {
let version = cursor.version.unwrap_or_default();
let age = cursor.age_seconds.unwrap_or_default();
let when = cursor.updated_at.as_deref().unwrap_or("");
let value =
cursor.value.as_ref().map_or_else(|| "null".to_string(), ToString::to_string);
format!("v{version} · advanced {age:.0}s ago · {when} · value {value}")
},
"never_advanced" => "never advanced (no watermark yet)".to_string(),
_ if !database_connected => {
"(no database connection — pass --db-url to read cursor state)".to_string()
},
_ => "unknown".to_string(),
}
}
pub async fn run(args: SourcesArgs) -> Result<()> {
let text = std::fs::read_to_string(&args.schema)
.with_context(|| format!("cannot read compiled schema {}", args.schema.display()))?;
let schema = CompiledSchema::from_json(&text, false).map_err(|e| {
anyhow::anyhow!("cannot parse compiled schema {}: {e}", args.schema.display())
})?;
let (cursors, database_connected) = match resolve_database_url(args.database.as_deref()) {
Ok(db_url) => {
let reader = SourceCursorReader::connect(&db_url)?;
let rows = reader.load_cursors().await?;
let map: HashMap<String, CursorRow> =
rows.into_iter().map(|row| (row.source_name.clone(), row)).collect();
(Some(map), true)
},
Err(_) => (None, false),
};
let statuses = build_status(&schema.sources, cursors.as_ref());
if args.json {
let payload = serde_json::json!({
"database_connected": database_connected,
"sources": statuses,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
print!("{}", render_text(&statuses, database_connected));
}
Ok(())
}