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_configured_database;
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.cursor_name()) {
157 Some(row) => CursorStatus::advanced(row),
158 None => CursorStatus::never_advanced(),
159 },
160 };
161 SourceStatus {
162 name: source.name.clone(),
163 schedule: source.schedule.clone(),
164 function: source.function.clone(),
165 enabled: source.enabled,
166 cursor_name: source.cursor_name().to_string(),
167 run_as: run_as_status(source),
168 cursor,
169 }
170 })
171 .collect()
172}
173
174fn run_as_status(source: &SourceDefinition) -> RunAsStatus {
176 match &source.run_as {
177 None => RunAsStatus {
178 configured: false,
179 roles: Vec::new(),
180 scopes: Vec::new(),
181 tenant: None,
182 },
183 Some(run_as) => RunAsStatus {
184 configured: true,
185 roles: run_as.roles.clone(),
186 scopes: run_as.scopes.clone(),
187 tenant: run_as.tenant.clone(),
188 },
189 }
190}
191
192#[must_use]
194pub fn render_text(statuses: &[SourceStatus], database_connected: bool) -> String {
195 use std::fmt::Write as _;
196
197 if statuses.is_empty() {
198 return "No sources declared in the compiled schema.\n".to_string();
199 }
200
201 let mut out = String::new();
202 let _ = writeln!(out, "Sources ({})\n", statuses.len());
203 for status in statuses {
204 let marker = if status.enabled { '●' } else { '○' };
205 let enabled = if status.enabled {
206 "enabled"
207 } else {
208 "DISABLED"
209 };
210 let _ = writeln!(
211 out,
212 "{marker} {name} schedule {schedule} {enabled}",
213 name = status.name,
214 schedule = status.schedule,
215 );
216 let _ = writeln!(out, " function {}", status.function);
217 let _ = writeln!(out, " run_as {}", render_run_as(&status.run_as));
218 let _ =
219 writeln!(out, " cursor {}", render_cursor(&status.cursor, database_connected));
220 out.push('\n');
221 }
222 out
223}
224
225fn render_run_as(run_as: &RunAsStatus) -> String {
227 if !run_as.configured {
228 return "(none — fail-closed: mutations are denied until a run_as ceiling is granted)"
229 .to_string();
230 }
231 let roles = if run_as.roles.is_empty() {
232 "[]".to_string()
233 } else {
234 format!("{:?}", run_as.roles)
235 };
236 let scopes = if run_as.scopes.is_empty() {
237 "[]".to_string()
238 } else {
239 format!("{:?}", run_as.scopes)
240 };
241 let tenant = run_as.tenant.as_deref().unwrap_or("(per-message / global)");
242 format!("roles={roles} scopes={scopes} tenant={tenant}")
243}
244
245fn render_cursor(cursor: &CursorStatus, database_connected: bool) -> String {
247 match cursor.state {
248 "advanced" => {
249 let version = cursor.version.unwrap_or_default();
250 let age = cursor.age_seconds.unwrap_or_default();
251 let when = cursor.updated_at.as_deref().unwrap_or("");
252 let value =
253 cursor.value.as_ref().map_or_else(|| "null".to_string(), ToString::to_string);
254 format!("v{version} · advanced {age:.0}s ago · {when} · value {value}")
255 },
256 "never_advanced" => "never advanced (no watermark yet)".to_string(),
257 _ if !database_connected => {
258 "(no database connection — pass --db-url to read cursor state)".to_string()
259 },
260 _ => "unknown".to_string(),
261 }
262}
263
264pub async fn run(args: SourcesArgs) -> Result<()> {
272 let text = std::fs::read_to_string(&args.schema)
273 .with_context(|| format!("cannot read compiled schema {}", args.schema.display()))?;
274 let schema = CompiledSchema::from_json(&text, false).map_err(|e| {
275 anyhow::anyhow!("cannot parse compiled schema {}: {e}", args.schema.display())
276 })?;
277
278 let (cursors, database_connected) = match resolve_configured_database(args.database.as_deref())?
283 {
284 Some(db) => {
285 let reader = SourceCursorReader::connect(&db.url, &db.tls).await?;
286 let rows = reader.load_cursors().await?;
287 let map: HashMap<String, CursorRow> =
288 rows.into_iter().map(|row| (row.source_name.clone(), row)).collect();
289 (Some(map), true)
290 },
291 None => (None, false),
292 };
293
294 let statuses = build_status(&schema.sources, cursors.as_ref());
295
296 if args.json {
297 let payload = serde_json::json!({
298 "database_connected": database_connected,
299 "sources": statuses,
300 });
301 println!("{}", serde_json::to_string_pretty(&payload)?);
302 } else {
303 print!("{}", render_text(&statuses, database_connected));
304 }
305 Ok(())
306}