diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 02daae4..5ca6351 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -21,14 +21,17 @@ //! `apply_schema` catalog-validator closure that is not object-safe. //! Same one-body-two-impls collapse, less ceremony. +use std::io::Write; + use color_eyre::Result; +use color_eyre::eyre::bail; use omnigraph::db::{Omnigraph, ReadTarget}; use omnigraph_api_types::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput, - IngestOutput, IngestRequest, ReadOutput, ReadRequest, SchemaApplyOutput, SchemaApplyRequest, - SchemaOutput, SnapshotOutput, commit_output, ingest_output, read_output, schema_apply_output, - snapshot_payload, + ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput, + ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output, + ingest_output, read_output, schema_apply_output, snapshot_payload, }; use omnigraph_compiler::catalog::Catalog; use reqwest::Method; @@ -36,7 +39,7 @@ use serde_json::Value; use crate::cli::CliLoadMode; use crate::helpers::{ - ResolvedCliGraph, apply_server_flag, build_http_client, is_remote_uri, + ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri, legacy_change_request_body, open_local_db_with_policy, query_params_from_json, remote_branch_url, remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token, select_named_query, @@ -615,4 +618,86 @@ impl GraphClient { } } } + + /// `export` — stream the branch as JSONL into `writer`. The streaming + /// shape (a `W: Write`, not a returned DTO) is why this lands in 3c + /// rather than 3b. Opens WITHOUT policy (like reads), so it is reached + /// via `resolve()`; the Embedded arm opens bare. The Remote arm streams + /// the chunked response body straight through (no buffering the whole + /// export in memory). + pub(crate) async fn export( + &self, + branch: &str, + type_names: &[String], + table_keys: &[String], + writer: &mut W, + ) -> Result<()> { + match self { + GraphClient::Remote { + http, + base_url, + token, + } => { + let request = apply_bearer_token( + http.request(Method::POST, remote_url(base_url, "/export")), + token.as_deref(), + ) + .json(&ExportRequest { + branch: Some(branch.to_string()), + type_names: type_names.to_vec(), + table_keys: table_keys.to_vec(), + }); + let mut response = request.send().await?; + let status = response.status(); + if !status.is_success() { + let text = response.text().await?; + if let Ok(error) = serde_json::from_str::(&text) { + bail!(error.error); + } + bail!("server returned {}: {}", status, text); + } + while let Some(chunk) = response.chunk().await? { + writer.write_all(&chunk)?; + } + writer.flush()?; + Ok(()) + } + GraphClient::Embedded { uri, .. } => { + let db = Omnigraph::open(uri).await?; + db.export_jsonl_to_writer(branch, type_names, table_keys, writer) + .await?; + writer.flush()?; + Ok(()) + } + } + } + + /// `graphs list` — enumerate the graphs a remote multi-graph server + /// serves (`GET /graphs`). Remote-only by design: there is no local + /// enumeration endpoint, so the Embedded arm fails loudly pointing the + /// operator at `omnigraph.yaml`. Routing it through the enum still buys + /// the shared `resolve()` addressing/token preamble. + pub(crate) async fn list_graphs(&self) -> Result { + match self { + GraphClient::Remote { + http, + base_url, + token, + } => { + remote_json( + http, + Method::GET, + remote_url(base_url, "/graphs"), + None, + token.as_deref(), + ) + .await + } + GraphClient::Embedded { .. } => bail!( + "`omnigraph graphs list` requires a remote multi-graph server URL \ + (http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \ + directly." + ), + } + } } diff --git a/crates/omnigraph-cli/src/helpers.rs b/crates/omnigraph-cli/src/helpers.rs index e9dfcc1..84c8867 100644 --- a/crates/omnigraph-cli/src/helpers.rs +++ b/crates/omnigraph-cli/src/helpers.rs @@ -678,22 +678,6 @@ pub(crate) fn normalize_legacy_alias_uri( } -pub(crate) fn inferred_config_path(uri: &str) -> Result { - if uri.contains("://") { - return Ok(omnigraph_server::config::default_config_path()); - } - - let path = Path::new(uri); - let base = if path.is_absolute() { - path.parent() - .map(Path::to_path_buf) - .unwrap_or(std::env::current_dir()?) - } else { - std::env::current_dir()?.join(path.parent().unwrap_or_else(|| Path::new("."))) - }; - Ok(base.join(omnigraph_server::config::DEFAULT_CONFIG_FILE)) -} - pub(crate) fn read_target_from_cli(branch: Option, snapshot: Option) -> ReadTarget { if let Some(snapshot) = snapshot { ReadTarget::snapshot(SnapshotId::new(snapshot)) @@ -998,55 +982,6 @@ pub(crate) fn legacy_change_request_body( body } -pub(crate) async fn execute_export_to_writer( - uri: &str, - branch: &str, - type_names: &[String], - table_keys: &[String], - writer: &mut W, -) -> Result<()> { - let db = Omnigraph::open(uri).await?; - db.export_jsonl_to_writer(branch, type_names, table_keys, writer) - .await?; - writer.flush()?; - Ok(()) -} - -pub(crate) async fn execute_export_remote_to_writer( - client: &reqwest::Client, - uri: &str, - branch: &str, - type_names: &[String], - table_keys: &[String], - bearer_token: Option<&str>, - writer: &mut W, -) -> Result<()> { - let request = apply_bearer_token( - client.request(Method::POST, remote_url(uri, "/export")), - bearer_token, - ) - .json(&ExportRequest { - branch: Some(branch.to_string()), - type_names: type_names.to_vec(), - table_keys: table_keys.to_vec(), - }); - let mut response = request.send().await?; - let status = response.status(); - if !status.is_success() { - let text = response.text().await?; - if let Ok(error) = serde_json::from_str::(&text) { - bail!(error.error); - } - bail!("server returned {}: {}", status, text); - } - - while let Some(chunk) = response.chunk().await? { - writer.write_all(&chunk)?; - } - writer.flush()?; - Ok(()) -} - pub(crate) fn rewrite_deprecated_argv(args: Vec) -> Vec { if args.len() >= 3 { let sub = args[1].to_str(); diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 53eb4c7..fd67fb3 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -23,8 +23,8 @@ use omnigraph_compiler::{ json_params_to_param_map, lint_query_file, }; use omnigraph_api_types::{ - ChangeOutput, CommitOutput, ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, - ReadOutput, SchemaApplyOutput, SnapshotTableOutput, + ChangeOutput, CommitOutput, ErrorOutput, IngestOutput, ReadOutput, SchemaApplyOutput, + SnapshotTableOutput, }; use omnigraph_server::queries::{QueryRegistry, check, format_check_breakages}; use omnigraph_server::{ @@ -525,11 +525,13 @@ async fn main() -> Result<()> { table_keys, } => { let config = load_cli_config(config.as_ref())?; - let uri = - apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; - let bearer_token = - resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; - let uri = resolve_uri(&config, uri, target.as_deref())?; + let client = client::GraphClient::resolve( + &config, + cli.server.as_deref(), + cli.graph.as_deref(), + uri, + target.as_deref(), + )?; let branch = resolve_branch(&config, branch, None, "main"); if jsonl { eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL"); @@ -537,21 +539,9 @@ async fn main() -> Result<()> { let stdout = io::stdout(); let mut stdout = stdout.lock(); - if is_remote_uri(&uri) { - execute_export_remote_to_writer( - &http_client, - &uri, - &branch, - &type_names, - &table_keys, - bearer_token.as_deref(), - &mut stdout, - ) + client + .export(&branch, &type_names, &table_keys, &mut stdout) .await?; - } else { - execute_export_to_writer(&uri, &branch, &type_names, &table_keys, &mut stdout) - .await?; - } } Command::Query { uri, @@ -1047,26 +1037,14 @@ async fn main() -> Result<()> { json, } => { let config = load_cli_config(config.as_ref())?; - let uri = - apply_server_flag(cli.server.as_deref(), cli.graph.as_deref(), uri, target.as_deref())?; - let bearer_token = - resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; - let uri = resolve_uri(&config, uri, target.as_deref())?; - if !is_remote_uri(&uri) { - bail!( - "`omnigraph graphs list` requires a remote multi-graph server URL \ - (http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \ - directly." - ); - } - let payload = remote_json::( - &http_client, - Method::GET, - remote_url(&uri, "/graphs"), - None, - bearer_token.as_deref(), - ) - .await?; + let client = client::GraphClient::resolve( + &config, + cli.server.as_deref(), + cli.graph.as_deref(), + uri, + target.as_deref(), + )?; + let payload = client.list_graphs().await?; if json { print_json(&payload)?; } else { diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index c6acd32..446c6ca 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -812,10 +812,6 @@ pub(crate) fn print_policy_explain(decision: &PolicyDecision, actor_id: &str, re println!("message: {}", decision.message); } -pub(crate) fn yaml_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - #[derive(serde::Serialize)] pub(crate) struct QueriesIssue { pub(crate) query: String, diff --git a/crates/omnigraph-cli/tests/parity_matrix.rs b/crates/omnigraph-cli/tests/parity_matrix.rs index 75ba49e..b65c46e 100644 --- a/crates/omnigraph-cli/tests/parity_matrix.rs +++ b/crates/omnigraph-cli/tests/parity_matrix.rs @@ -179,6 +179,35 @@ fn parity_load() { assert_parity("load", &l, &r); } +#[test] +fn parity_export() { + let p = parity(); + let (l, r) = p.run(&["export"]); + // export emits a JSONL STREAM, not a single `--json` document, so the + // scrubbed-single-doc `assert_parity` doesn't apply — compare line-wise. + // The twin graphs are byte-copies of one loaded fixture, so rows carry + // identical ids/versions and need no scrubbing; sort the lines so any + // cross-arm row-ordering difference doesn't masquerade as a divergence. + assert_eq!( + l.status.code(), + r.status.code(), + "export: exit codes diverge\nlocal {l:?}\nremote {r:?}" + ); + assert!(l.status.success(), "export local arm failed: {l:?}"); + let mut local_lines: Vec<&str> = std::str::from_utf8(&l.stdout).unwrap().lines().collect(); + let mut remote_lines: Vec<&str> = std::str::from_utf8(&r.stdout).unwrap().lines().collect(); + assert!( + !local_lines.is_empty(), + "export produced no rows — the parity check would be vacuous" + ); + local_lines.sort_unstable(); + remote_lines.sort_unstable(); + assert_eq!( + local_lines, remote_lines, + "export: JSONL streams diverge (left=local, right=remote)" + ); +} + // ---- error parity: exit codes must match for shared failure cases ---- #[test]