fraiseql_cli/commands/sources/
mod.rs1pub mod reader;
22
23#[cfg(test)]
24mod tests;
25
26use std::{collections::HashMap, path::PathBuf};
27
28use anyhow::{Context, Result};
29use fraiseql_core::schema::{CompiledSchema, SourceDefinition};
30use serde::Serialize;
31
32use self::reader::{CursorRow, SourceCursorReader};
33use crate::commands::migrate::resolve_database_url;
34
35pub struct SourcesArgs {
37 pub schema: PathBuf,
39 pub database: Option<String>,
43 pub json: bool,
45}
46
47#[derive(Debug, Serialize, PartialEq, Eq)]
49pub struct RunAsStatus {
50 pub configured: bool,
53 pub roles: Vec<String>,
55 pub scopes: Vec<String>,
57 pub tenant: Option<String>,
60}
61
62#[derive(Debug, Serialize, PartialEq)]
64pub struct CursorStatus {
65 pub state: &'static str,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub version: Option<i64>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub value: Option<serde_json::Value>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub updated_at: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub age_seconds: Option<f64>,
81}
82
83impl CursorStatus {
84 const fn unknown() -> Self {
86 Self {
87 state: "unknown",
88 version: None,
89 value: None,
90 updated_at: None,
91 age_seconds: None,
92 }
93 }
94
95 const fn never_advanced() -> Self {
97 Self {
98 state: "never_advanced",
99 version: None,
100 value: None,
101 updated_at: None,
102 age_seconds: None,
103 }
104 }
105
106 fn advanced(row: &CursorRow) -> Self {
108 Self {
109 state: "advanced",
110 version: Some(row.version),
111 value: row.value.clone(),
112 updated_at: Some(row.updated_at.clone()),
113 age_seconds: Some(row.age_seconds),
114 }
115 }
116}
117
118#[derive(Debug, Serialize)]
120pub struct SourceStatus {
121 pub name: String,
123 pub schedule: String,
125 pub function: String,
127 pub enabled: bool,
130 pub cursor_name: String,
132 pub run_as: RunAsStatus,
134 pub cursor: CursorStatus,
136}
137
138fn build_status(
145 sources: &[SourceDefinition],
146 cursors: Option<&HashMap<String, CursorRow>>,
147) -> Vec<SourceStatus> {
148 sources
149 .iter()
150 .map(|source| {
151 let cursor = match cursors {
152 None => CursorStatus::unknown(),
153 Some(map) => match map.get(&source.name) {
154 Some(row) => CursorStatus::advanced(row),
155 None => CursorStatus::never_advanced(),
156 },
157 };
158 SourceStatus {
159 name: source.name.clone(),
160 schedule: source.schedule.clone(),
161 function: source.function.clone(),
162 enabled: source.enabled,
163 cursor_name: source.cursor_name().to_string(),
164 run_as: run_as_status(source),
165 cursor,
166 }
167 })
168 .collect()
169}
170
171fn run_as_status(source: &SourceDefinition) -> RunAsStatus {
173 match &source.run_as {
174 None => RunAsStatus {
175 configured: false,
176 roles: Vec::new(),
177 scopes: Vec::new(),
178 tenant: None,
179 },
180 Some(run_as) => RunAsStatus {
181 configured: true,
182 roles: run_as.roles.clone(),
183 scopes: run_as.scopes.clone(),
184 tenant: run_as.tenant.clone(),
185 },
186 }
187}
188
189#[must_use]
191pub fn render_text(statuses: &[SourceStatus], database_connected: bool) -> String {
192 use std::fmt::Write as _;
193
194 if statuses.is_empty() {
195 return "No sources declared in the compiled schema.\n".to_string();
196 }
197
198 let mut out = String::new();
199 let _ = writeln!(out, "Sources ({})\n", statuses.len());
200 for status in statuses {
201 let marker = if status.enabled { '●' } else { '○' };
202 let enabled = if status.enabled {
203 "enabled"
204 } else {
205 "DISABLED"
206 };
207 let _ = writeln!(
208 out,
209 "{marker} {name} schedule {schedule} {enabled}",
210 name = status.name,
211 schedule = status.schedule,
212 );
213 let _ = writeln!(out, " function {}", status.function);
214 let _ = writeln!(out, " run_as {}", render_run_as(&status.run_as));
215 let _ =
216 writeln!(out, " cursor {}", render_cursor(&status.cursor, database_connected));
217 out.push('\n');
218 }
219 out
220}
221
222fn render_run_as(run_as: &RunAsStatus) -> String {
224 if !run_as.configured {
225 return "(none — fail-closed: mutations are denied until a run_as ceiling is granted)"
226 .to_string();
227 }
228 let roles = if run_as.roles.is_empty() {
229 "[]".to_string()
230 } else {
231 format!("{:?}", run_as.roles)
232 };
233 let scopes = if run_as.scopes.is_empty() {
234 "[]".to_string()
235 } else {
236 format!("{:?}", run_as.scopes)
237 };
238 let tenant = run_as.tenant.as_deref().unwrap_or("(per-message / global)");
239 format!("roles={roles} scopes={scopes} tenant={tenant}")
240}
241
242fn render_cursor(cursor: &CursorStatus, database_connected: bool) -> String {
244 match cursor.state {
245 "advanced" => {
246 let version = cursor.version.unwrap_or_default();
247 let age = cursor.age_seconds.unwrap_or_default();
248 let when = cursor.updated_at.as_deref().unwrap_or("");
249 let value =
250 cursor.value.as_ref().map_or_else(|| "null".to_string(), ToString::to_string);
251 format!("v{version} · advanced {age:.0}s ago · {when} · value {value}")
252 },
253 "never_advanced" => "never advanced (no watermark yet)".to_string(),
254 _ if !database_connected => {
255 "(no database connection — pass --db-url to read cursor state)".to_string()
256 },
257 _ => "unknown".to_string(),
258 }
259}
260
261pub async fn run(args: SourcesArgs) -> Result<()> {
269 let text = std::fs::read_to_string(&args.schema)
270 .with_context(|| format!("cannot read compiled schema {}", args.schema.display()))?;
271 let schema = CompiledSchema::from_json(&text, false).map_err(|e| {
272 anyhow::anyhow!("cannot parse compiled schema {}: {e}", args.schema.display())
273 })?;
274
275 let (cursors, database_connected) = match resolve_database_url(args.database.as_deref()) {
280 Ok(db_url) => {
281 let reader = SourceCursorReader::connect(&db_url)?;
282 let rows = reader.load_cursors().await?;
283 let map: HashMap<String, CursorRow> =
284 rows.into_iter().map(|row| (row.source_name.clone(), row)).collect();
285 (Some(map), true)
286 },
287 Err(_) => (None, false),
288 };
289
290 let statuses = build_status(&schema.sources, cursors.as_ref());
291
292 if args.json {
293 let payload = serde_json::json!({
294 "database_connected": database_connected,
295 "sources": statuses,
296 });
297 println!("{}", serde_json::to_string_pretty(&payload)?);
298 } else {
299 print!("{}", render_text(&statuses, database_connected));
300 }
301 Ok(())
302}