Skip to main content

fraiseql_cli/commands/sources/
mod.rs

1//! `fraiseql sources` — a read-only status view over scheduled ingress sources
2//! (#573).
3//!
4//! Lists each compiled source with its schedule, `run_as` authority ceiling, and —
5//! when a database is reachable — its durable cursor: the value, the compare-and-swap
6//! version, and how stale the watermark is (seconds since the last advance). It is
7//! the operator's answer to "read its cursor / measure lag"; the fire/skip/error
8//! signals live in the `fraiseql_source_*` Prometheus metrics and the poller's
9//! structured logs, since those are per-firing and per-replica, whereas the cursor is
10//! the durable, cross-replica source of truth for progress.
11//!
12//! Leadership and last-fire are deliberately **not** reported here: a PostgreSQL
13//! advisory lock exposes no holder, the single-firing lease is released between
14//! ticks, and "last fired on this replica" is not "last fired" in a multi-replica
15//! deployment — so the honest, durable signal is the cursor, and single-firing health
16//! is the fleet-wide `fraiseql_source_skips_not_leader_total` metric.
17//!
18//! Architecture mirrors `perf`: a thin PostgreSQL [`reader`] plus pure, unit-tested
19//! merge/format functions, so correctness is covered without a live database.
20
21pub 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
35/// Arguments for `fraiseql sources`.
36pub struct SourcesArgs {
37    /// Path to the compiled schema (the source definitions).
38    pub schema:   PathBuf,
39    /// Explicit PostgreSQL URL for cursor reads; falls back to `fraiseql.toml` /
40    /// `DATABASE_URL`. When no URL resolves, definitions are listed without cursor
41    /// state.
42    pub database: Option<String>,
43    /// Emit machine-readable JSON instead of the human report.
44    pub json:     bool,
45}
46
47/// The `run_as` authority ceiling of a source, flattened for display.
48#[derive(Debug, Serialize, PartialEq, Eq)]
49pub struct RunAsStatus {
50    /// Whether a `run_as` ceiling is configured at all. `false` ⇒ the source is
51    /// fail-closed: its background mutations are denied until a ceiling is granted.
52    pub configured: bool,
53    /// The granted roles (RBAC ceiling).
54    pub roles:      Vec<String>,
55    /// The granted scopes.
56    pub scopes:     Vec<String>,
57    /// The pinned tenant, if any. `None` ⇒ global/system or a per-message
58    /// multi-tenant source.
59    pub tenant:     Option<String>,
60}
61
62/// The durable cursor state of a source, if known.
63#[derive(Debug, Serialize, PartialEq)]
64pub struct CursorStatus {
65    /// `"unknown"` (no database read), `"never_advanced"` (no row yet), or
66    /// `"advanced"` (a watermark exists).
67    pub state:       &'static str,
68    /// The compare-and-swap generation counter (present only when advanced).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub version:     Option<i64>,
71    /// The opaque cursor value (present only when advanced).
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub value:       Option<serde_json::Value>,
74    /// Last-advance timestamp (present only when advanced).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub updated_at:  Option<String>,
77    /// Seconds since the last advance — the staleness/lag (present only when
78    /// advanced).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub age_seconds: Option<f64>,
81}
82
83impl CursorStatus {
84    /// No database was reachable, so the cursor state is unknown.
85    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    /// The database was read but the source has no cursor row yet.
96    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    /// A durable watermark exists.
107    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/// One source's full status: its static definition plus its durable cursor.
119#[derive(Debug, Serialize)]
120pub struct SourceStatus {
121    /// The source name (also the cursor key the runtime advances under).
122    pub name:        String,
123    /// The cron schedule the source fires on.
124    pub schedule:    String,
125    /// The connector function the source runs.
126    pub function:    String,
127    /// Whether the source is enabled (a disabled source is compiled but not
128    /// scheduled).
129    pub enabled:     bool,
130    /// The declared cursor name. Equal to `name` unless an explicit `cursor` was set.
131    pub cursor_name: String,
132    /// The `run_as` authority ceiling.
133    pub run_as:      RunAsStatus,
134    /// The durable cursor state.
135    pub cursor:      CursorStatus,
136}
137
138/// Merge the compiled source definitions with the cursor rows read from the
139/// database into a per-source status list.
140///
141/// `cursors` is `None` when no database was reachable (every cursor is `unknown`),
142/// or `Some(map)` keyed by the source name the runtime advances under — so a source
143/// with no row reads as `never_advanced` and one with a row as `advanced`.
144fn 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                // Keyed on `cursor_name()`, the row the runtime advances. Keying on
154                // `source.name` printed a `cursor_name` beside a watermark read from a
155                // different key whenever an explicit `cursor` was declared (#868 item 4).
156                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
174/// Flatten a source's optional `run_as` into a display-ready [`RunAsStatus`].
175fn 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/// Render the status list as a human-readable report.
193#[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
225/// Render a `run_as` ceiling, calling out the fail-closed (unconfigured) case.
226fn 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
245/// Render a cursor's durable state.
246fn 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
264/// Run `fraiseql sources`: load the compiled schema, optionally read cursor state
265/// from the database, and print the merged status as text or JSON.
266///
267/// # Errors
268///
269/// Returns an error if the compiled schema cannot be read/parsed, or if a database
270/// URL resolves but the cursor read fails.
271pub 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    // A database read is best-effort: when no URL resolves, list definitions with
279    // `unknown` cursor state rather than failing — the definitions are still useful.
280    // When a URL *does* resolve, a read failure is a real error (the operator asked
281    // for cursor state).
282    let (cursors, database_connected) = match resolve_database_url(args.database.as_deref()) {
283        Ok(db_url) => {
284            let reader = SourceCursorReader::connect(&db_url)?;
285            let rows = reader.load_cursors().await?;
286            let map: HashMap<String, CursorRow> =
287                rows.into_iter().map(|row| (row.source_name.clone(), row)).collect();
288            (Some(map), true)
289        },
290        Err(_) => (None, false),
291    };
292
293    let statuses = build_status(&schema.sources, cursors.as_ref());
294
295    if args.json {
296        let payload = serde_json::json!({
297            "database_connected": database_connected,
298            "sources": statuses,
299        });
300        println!("{}", serde_json::to_string_pretty(&payload)?);
301    } else {
302        print!("{}", render_text(&statuses, database_connected));
303    }
304    Ok(())
305}