diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 2cb4373..b7e3041 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -1,3 +1,4 @@ +use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::path::Path; @@ -17,9 +18,9 @@ use omnigraph_compiler::{ }; use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, - BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, - CommitOutput, ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, - ReadOutput, ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, + BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput, + ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput, + ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, SnapshotTableOutput, commit_output, ingest_output, read_output, schema_apply_output, snapshot_payload, }; @@ -127,10 +128,30 @@ enum Command { #[command(subcommand)] command: SchemaCommand, }, - /// Query validation and linting - Query { - #[command(subcommand)] - command: QueryCommand, + /// Validate queries against a schema (offline) or repo (repo-backed). + /// + /// Canonical name is `lint` (matches the `omnigraph_compiler::lint` + /// module and the `OG-XXX-NNN` lint-code vocabulary). Replaces the + /// deprecated `omnigraph query lint` / `omnigraph query check` / + /// `omnigraph check` invocations — each is kept as an argv-level + /// shim that prints a one-line stderr warning and rewrites to + /// `omnigraph lint`. Aliases are deliberately *not* exposed via + /// clap's `visible_alias` because that would advertise two + /// equivalent canonical names, which agents emit interchangeably + /// (see MR-981). + Lint { + /// Graph URI + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + query: PathBuf, + #[arg(long)] + schema: Option, + #[arg(long)] + json: bool, }, /// Show graph snapshot Snapshot { @@ -167,8 +188,13 @@ enum Command { #[command(subcommand)] command: CommitCommand, }, - /// Execute a read query against a branch or snapshot - Read { + /// Execute a read query against a branch or snapshot. + /// + /// Canonical read endpoint. The previous name `omnigraph read` is + /// kept as a visible alias and prints a one-line deprecation warning + /// when used. Pairs with `omnigraph mutate` on the write side. + #[command(visible_alias = "read")] + Query { /// Graph URI #[arg(long)] uri: Option, @@ -178,10 +204,13 @@ enum Command { target: Option, #[arg(long)] config: Option, - #[arg(long)] + #[arg(long, conflicts_with_all = ["query", "query_string"])] alias: Option, - #[arg(long)] + #[arg(long, conflicts_with_all = ["alias", "query_string"])] query: Option, + /// Inline GQ source — alternative to `--query ` and `--alias `. + #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] + query_string: Option, #[arg(long)] name: Option, #[command(flatten)] @@ -197,8 +226,13 @@ enum Command { #[arg()] alias_args: Vec, }, - /// Execute a graph change query against a branch - Change { + /// Execute a graph mutation query against a branch. + /// + /// Canonical mutation endpoint. The previous name `omnigraph change` + /// is kept as a visible alias and prints a one-line deprecation + /// warning when used. Pairs with `omnigraph query` on the read side. + #[command(visible_alias = "change")] + Mutate { /// Graph URI #[arg(long)] uri: Option, @@ -208,10 +242,13 @@ enum Command { target: Option, #[arg(long)] config: Option, - #[arg(long)] + #[arg(long, conflicts_with_all = ["query", "query_string"])] alias: Option, - #[arg(long)] + #[arg(long, conflicts_with_all = ["alias", "query_string"])] query: Option, + /// Inline GQ source — alternative to `--query ` and `--alias `. + #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])] + query_string: Option, #[arg(long)] name: Option, #[command(flatten)] @@ -408,26 +445,7 @@ enum SchemaCommand { } #[derive(Debug, Subcommand)] -enum QueryCommand { - /// Validate queries and report higher-level drift warnings - #[command(visible_alias = "check")] - Lint { - /// Graph URI - uri: Option, - #[arg(long)] - target: Option, - #[arg(long)] - config: Option, - #[arg(long)] - query: PathBuf, - #[arg(long)] - schema: Option, - #[arg(long)] - json: bool, - }, -} -#[derive(Debug, Subcommand)] enum CommitCommand { /// List graph commits List { @@ -945,7 +963,9 @@ fn resolve_query_path( .map(PathBuf::from) .or_else(|| alias_query.map(PathBuf::from)) .ok_or_else(|| { - color_eyre::eyre::eyre!("exactly one of --query or --alias must be provided") + color_eyre::eyre::eyre!( + "exactly one of --query, --query-string, or --alias must be provided" + ) }) .and_then(|query_path| config.resolve_query_path(&query_path)) } @@ -953,8 +973,15 @@ fn resolve_query_path( fn resolve_query_source( config: &OmnigraphConfig, explicit_query: Option<&PathBuf>, + inline_query: Option<&str>, alias_query: Option<&str>, ) -> Result { + if let Some(inline) = inline_query { + if inline.trim().is_empty() { + bail!("--query-string must not be empty"); + } + return Ok(inline.to_string()); + } Ok(fs::read_to_string(resolve_query_path( config, explicit_query, @@ -1652,6 +1679,33 @@ async fn execute_change( }) } +/// Build the JSON body for `POST /change` using the legacy wire shape. +/// +/// `ChangeRequest`'s Rust field names are now `query` / `name` (the canonical +/// wire shape going forward), but old `omnigraph-server` builds still require +/// the legacy `query_source` / `query_name` keys on `/change`. Hand-rolling +/// the JSON with the legacy names keeps a newer CLI talking to an older +/// server intact -- the same byte-stability contract we apply to +/// `execute_read_remote` against `/read`. +fn legacy_change_request_body( + query_source: &str, + query_name: Option<&str>, + branch: &str, + params_json: Option<&Value>, +) -> Value { + let mut body = serde_json::json!({ + "query_source": query_source, + "branch": branch, + }); + if let Some(name) = query_name { + body["query_name"] = Value::String(name.to_string()); + } + if let Some(params) = params_json { + body["params"] = params.clone(); + } + body +} + async fn execute_change_remote( client: &reqwest::Client, uri: &str, @@ -1665,12 +1719,12 @@ async fn execute_change_remote( client, Method::POST, remote_url(uri, "/change"), - Some(serde_json::to_value(ChangeRequest { - query_source: query_source.to_string(), - query_name: query_name.map(ToOwned::to_owned), - params: params_json.cloned(), - branch: Some(branch.to_string()), - })?), + Some(legacy_change_request_body( + query_source, + query_name, + branch, + params_json, + )), bearer_token, ) .await @@ -1725,10 +1779,74 @@ async fn execute_export_remote_to_writer( Ok(()) } +/// Rewrite deprecated CLI invocations into their canonical form. +/// +/// The current rename pass moves four subcommands: +/// - `omnigraph read` -> `omnigraph query` (clap `visible_alias` handles parsing; we warn) +/// - `omnigraph change` -> `omnigraph mutate` (clap `visible_alias` handles parsing; we warn) +/// - `omnigraph check` -> `omnigraph lint` (rewrite required; no visible_alias by design) +/// - `omnigraph query lint` -> `omnigraph lint` (rewrite required; `query` is now the read-runner) +/// - `omnigraph query check` -> `omnigraph lint` (rewrite required) +/// +/// `check` is *not* a clap visible_alias on `lint` even though they're +/// semantically equivalent. Visible aliases create two canonical names +/// that agents emit interchangeably depending on training-data drift +/// (see MR-981 §6 for the policy). The argv-shim + stderr warning +/// pattern preserves back-compat for human users while pointing every +/// caller at the single canonical name in `--help`. +/// +/// Returns the (possibly rewritten) argv that clap should parse. +fn rewrite_deprecated_argv(args: Vec) -> Vec { + if args.len() >= 3 { + let sub = args[1].to_str(); + let sub2 = args[2].to_str(); + if sub == Some("query") && matches!(sub2, Some("lint") | Some("check")) { + let suffix = sub2.unwrap(); + eprintln!( + "warning: `omnigraph query {suffix}` is deprecated; use `omnigraph lint` instead" + ); + // Drop the leading `query` token AND normalize `check` -> `lint`. + // `check` is no longer a clap visible_alias (MR-981 §6), so the + // rewritten argv must reach the canonical `lint` subcommand + // directly. Result for `omnigraph query check --query foo.gq`: + // `omnigraph lint --query foo.gq`. + let mut out = Vec::with_capacity(args.len() - 1); + out.push(args[0].clone()); + out.push(OsString::from("lint")); + out.extend(args[3..].iter().cloned()); + return out; + } + } + if let Some(sub) = args.get(1).and_then(|s| s.to_str()) { + match sub { + "read" => eprintln!( + "warning: `omnigraph read` is deprecated; use `omnigraph query` instead" + ), + "change" => eprintln!( + "warning: `omnigraph change` is deprecated; use `omnigraph mutate` instead" + ), + "check" => { + eprintln!( + "warning: `omnigraph check` is deprecated; use `omnigraph lint` instead" + ); + // Rewrite the top-level subcommand to `lint`; pass through the rest. + let mut out = Vec::with_capacity(args.len()); + out.push(args[0].clone()); + out.push(OsString::from("lint")); + out.extend(args[2..].iter().cloned()); + return out; + } + _ => {} + } + } + args +} + #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; let cli = { + let raw_args = rewrite_deprecated_argv(std::env::args_os().collect()); let matches = Cli::command() .arg( Arg::new("version") @@ -1737,7 +1855,7 @@ async fn main() -> Result<()> { .action(ArgAction::Version) .help("Print version"), ) - .get_matches(); + .get_matches_from(raw_args); Cli::from_arg_matches(&matches)? }; let http_client = build_http_client()?; @@ -2199,22 +2317,20 @@ async fn main() -> Result<()> { } } }, - Command::Query { command } => match command { - QueryCommand::Lint { - uri, - target, - config, - query, - schema, - json, - } => { - let config = load_cli_config(config.as_ref())?; - let output = - execute_query_lint(&config, uri, target.as_deref(), schema.as_ref(), &query) - .await?; - finish_query_lint(&output, json)?; - } - }, + Command::Lint { + uri, + target, + config, + query, + schema, + json, + } => { + let config = load_cli_config(config.as_ref())?; + let output = + execute_query_lint(&config, uri, target.as_deref(), schema.as_ref(), &query) + .await?; + finish_query_lint(&output, json)?; + } Command::Snapshot { uri, target, @@ -2284,13 +2400,14 @@ async fn main() -> Result<()> { .await?; } } - Command::Read { + Command::Query { uri, legacy_uri, target, config, alias, query, + query_string, name, params, branch, @@ -2299,8 +2416,8 @@ async fn main() -> Result<()> { json, alias_args, } => { - if alias.is_some() == query.is_some() { - bail!("exactly one of --alias or --query must be provided"); + if alias.is_none() && query.is_none() && query_string.is_none() { + bail!("exactly one of --query, --query-string, or --alias must be provided"); } let config = load_cli_config(config.as_ref())?; @@ -2323,6 +2440,7 @@ async fn main() -> Result<()> { let query_source = resolve_query_source( &config, query.as_ref(), + query_string.as_deref(), alias_config.map(|a| a.query.as_str()), )?; let params_json = merged_params_json( @@ -2369,21 +2487,22 @@ async fn main() -> Result<()> { ); print_read_output(&output, format, &config)?; } - Command::Change { + Command::Mutate { uri, legacy_uri, target, config, alias, query, + query_string, name, params, branch, json, alias_args, } => { - if alias.is_some() == query.is_some() { - bail!("exactly one of --alias or --query must be provided"); + if alias.is_none() && query.is_none() && query_string.is_none() { + bail!("exactly one of --query, --query-string, or --alias must be provided"); } let config = load_cli_config(config.as_ref())?; @@ -2406,6 +2525,7 @@ async fn main() -> Result<()> { let query_source = resolve_query_source( &config, query.as_ref(), + query_string.as_deref(), alias_config.map(|a| a.query.as_str()), )?; let params_json = merged_params_json( @@ -2639,14 +2759,62 @@ mod tests { use std::fs; use super::{ - DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, load_cli_config, - load_env_file_into_process, normalize_bearer_token, parse_env_assignment, - resolve_remote_bearer_token, + DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file, + legacy_change_request_body, load_cli_config, load_env_file_into_process, + normalize_bearer_token, parse_env_assignment, resolve_remote_bearer_token, }; use omnigraph_server::load_config; use reqwest::header::AUTHORIZATION; + use serde_json::json; use tempfile::tempdir; + #[test] + fn legacy_change_request_body_uses_legacy_field_names() { + // `execute_change_remote` hits `POST /change`, which old + // `omnigraph-server` builds deserialize as `ChangeRequest` with + // **required** `query_source` and optional `query_name` keys. + // Newer servers accept both spellings via serde alias, but a + // newer CLI must still emit the legacy keys on the wire so it + // can talk to an old server during a rolling upgrade. + let body = legacy_change_request_body( + "query insert_person($n: String) { insert Person { name: $n } }", + Some("insert_person"), + "main", + Some(&json!({ "n": "Alice" })), + ); + assert_eq!( + body["query_source"].as_str(), + Some("query insert_person($n: String) { insert Person { name: $n } }"), + ); + assert_eq!(body["query_name"].as_str(), Some("insert_person")); + assert_eq!(body["branch"].as_str(), Some("main")); + assert_eq!(body["params"]["n"].as_str(), Some("Alice")); + // Crucially, the **new** field names must NOT appear -- old + // servers would silently treat them as unknown fields and then + // fail on missing required `query_source`. + assert!( + body.get("query").is_none(), + "legacy /change body must not carry the renamed `query` key; got {body}" + ); + assert!( + body.get("name").is_none(), + "legacy /change body must not carry the renamed `name` key; got {body}" + ); + } + + #[test] + fn legacy_change_request_body_omits_optional_fields_when_unset() { + let body = legacy_change_request_body( + "query find() { match { $p: Person } return { $p.name } }", + None, + "main", + None, + ); + assert_eq!(body["branch"].as_str(), Some("main")); + assert!(body.get("query_name").is_none()); + assert!(body.get("params").is_none()); + } + #[test] fn apply_bearer_token_adds_header_when_configured() { let client = reqwest::Client::new(); diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index f956c86..6e5de37 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -631,6 +631,202 @@ query list_people() { assert_eq!(stdout_string(&lint_output), stdout_string(&check_output)); } +/// `omnigraph lint` is the canonical top-level lint command after the +/// query/mutate rename. `omnigraph query lint` and `omnigraph query check` +/// are kept as deprecated argv shims (warning + rewrite). All three must +/// produce identical stdout output. +#[test] +fn lint_top_level_matches_deprecated_query_lint_output() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Person { + name: String +} +"#, + ); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let canonical = output_success( + cli() + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_lint = output_success( + cli() + .arg("query") + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_check = output_success( + cli() + .arg("query") + .arg("check") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_lint)); + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); + + // Canonical form must NOT emit the deprecation warning. + let canonical_stderr = String::from_utf8(canonical.stderr).unwrap(); + assert!( + !canonical_stderr.contains("deprecated"), + "`omnigraph lint` is canonical and must not warn; got stderr: {canonical_stderr}" + ); + + // Deprecated forms MUST emit the one-line warning, pointing at the + // new top-level `omnigraph lint`. + let lint_stderr = String::from_utf8(deprecated_lint.stderr).unwrap(); + assert!( + lint_stderr.contains("`omnigraph query lint` is deprecated") + && lint_stderr.contains("`omnigraph lint`"), + "expected deprecation warning pointing at `omnigraph lint`; got: {lint_stderr}" + ); + let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); + assert!( + check_stderr.contains("`omnigraph query check` is deprecated") + && check_stderr.contains("`omnigraph lint`"), + "expected deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" + ); +} + +/// Bare `omnigraph check` is NOT a clap `visible_alias` on `lint` (MR-981 §6: +/// visible aliases give agents two canonical names to emit interchangeably). +/// It's an argv-level shim: rewrites to `omnigraph lint`, prints a one-line +/// stderr deprecation warning, and produces identical stdout to the canonical +/// invocation. Cargo/Go users typing `check` keep working; help text shows +/// only `lint`. +#[test] +fn deprecated_check_top_level_rewrites_to_lint() { + let temp = tempdir().unwrap(); + let schema_path = temp.path().join("schema.pg"); + let query_path = temp.path().join("queries.gq"); + write_file( + &schema_path, + r#" +node Person { + name: String +} +"#, + ); + write_query_file( + &query_path, + r#" +query list_people() { + match { $p: Person } + return { $p.name } +} +"#, + ); + + let canonical = output_success( + cli() + .arg("lint") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + let deprecated_check = output_success( + cli() + .arg("check") + .arg("--query") + .arg(&query_path) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + ); + + assert_eq!(stdout_string(&canonical), stdout_string(&deprecated_check)); + + let check_stderr = String::from_utf8(deprecated_check.stderr).unwrap(); + assert!( + check_stderr.contains("`omnigraph check` is deprecated") + && check_stderr.contains("`omnigraph lint`"), + "expected `omnigraph check` deprecation warning pointing at `omnigraph lint`; got: {check_stderr}" + ); + + // `check` must NOT appear in the canonical `omnigraph --help` output — + // agents copy the surface from help text and would otherwise emit both + // names interchangeably. + let help = cli().arg("--help").output().unwrap(); + let stdout = String::from_utf8(help.stdout).unwrap(); + let check_aliased = stdout + .lines() + .any(|line| line.trim_start().starts_with("lint") && line.contains("check")); + assert!( + !check_aliased, + "`check` must not be advertised as a visible alias of `lint`; help output: {stdout}" + ); +} + +/// `omnigraph read` and `omnigraph change` are kept as visible clap +/// aliases for the new canonical `query` / `mutate` subcommands, plus an +/// argv-level deprecation warning. The warning is emitted to stderr; the +/// command otherwise behaves identically to the canonical form. +#[test] +fn deprecated_read_and_change_subcommands_emit_warnings() { + // Both subcommands require `--query`/`--query-string`/`--alias`, so + // invoking them with no args will exit non-zero. That's fine -- + // we only care that the deprecation warning is printed before the + // argument-required error. + let output = cli().arg("read").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("`omnigraph read` is deprecated") + && stderr.contains("`omnigraph query`"), + "expected `omnigraph read` deprecation warning; got: {stderr}" + ); + + let output = cli().arg("change").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("`omnigraph change` is deprecated") + && stderr.contains("`omnigraph mutate`"), + "expected `omnigraph change` deprecation warning; got: {stderr}" + ); + + // Sanity check the inverse: the canonical names must NOT print the + // deprecation banner. + let output = cli().arg("query").arg("--help").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("deprecated"), + "`omnigraph query` is canonical and must not warn; got: {stderr}" + ); + let output = cli().arg("mutate").arg("--help").output().unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("deprecated"), + "`omnigraph mutate` is canonical and must not warn; got: {stderr}" + ); +} + #[test] fn query_lint_can_use_local_graph_via_positional_uri() { let temp = tempdir().unwrap(); @@ -1422,6 +1618,102 @@ fn read_requires_name_for_multi_query_files() { assert!(stderr.contains("multiple queries")); } +#[test] +fn read_supports_inline_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_success( + cli() + .arg("read") + .arg(&repo) + .arg("-e") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") + .arg("--params") + .arg(r#"{"name":"Alice"}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["query_name"], "find"); + assert_eq!(payload["row_count"], 1); + assert_eq!(payload["rows"][0]["p.name"], "Alice"); +} + +#[test] +fn change_supports_inline_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_success( + cli() + .arg("change") + .arg(&repo) + .arg("--query-string") + .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") + .arg("--params") + .arg(r#"{"name":"Inline","age":42}"#) + .arg("--json"), + ); + let payload: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(payload["query_name"], "add"); + assert_eq!(payload["affected_nodes"], 1); + + let verify = output_success( + cli() + .arg("read") + .arg(&repo) + .arg("-e") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }") + .arg("--params") + .arg(r#"{"name":"Inline"}"#) + .arg("--json"), + ); + let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap(); + assert_eq!(verify_payload["row_count"], 1); +} + +#[test] +fn read_rejects_query_string_combined_with_query() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_failure( + cli() + .arg("read") + .arg(&repo) + .arg("--query") + .arg(fixture("test.gq")) + .arg("-e") + .arg("query whatever() { match { $p: Person } return { $p.name } }"), + ); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("cannot be used") || stderr.contains("conflict"), + "expected clap conflict error, got: {stderr}" + ); +} + +#[test] +fn read_rejects_empty_query_string() { + let temp = tempdir().unwrap(); + let repo = graph_path(temp.path()); + init_graph(&repo); + load_fixture(&repo); + + let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg("")); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("must not be empty"), + "expected empty-string rejection, got: {stderr}" + ); +} + #[test] fn branch_create_json_outputs_source_and_name() { let temp = tempdir().unwrap(); diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index aaff3ba..074b203 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -246,6 +246,37 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() { )); assert_eq!(read_after["row_count"], 1); assert_eq!(read_after["rows"][0]["p.name"], "Eve"); + + // Inline-source variants of the same read/change flow (CLI `-e` / + // `--query-string`). Confirms that file-less invocations reach the + // engine identically, including param binding and `branch=main` defaults. + let inline_change = parse_stdout_json(&output_success( + cli() + .arg("change") + .arg(graph.path()) + .arg("-e") + .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") + .arg("--params") + .arg(r#"{"name":"Inline","age":42}"#) + .arg("--json"), + )); + assert_eq!(inline_change["branch"], "main"); + assert_eq!(inline_change["query_name"], "add"); + assert_eq!(inline_change["affected_nodes"], 1); + + let inline_read = parse_stdout_json(&output_success( + cli() + .arg("read") + .arg(graph.path()) + .arg("--query-string") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") + .arg("--params") + .arg(r#"{"name":"Inline"}"#) + .arg("--json"), + )); + assert_eq!(inline_read["row_count"], 1); + assert_eq!(inline_read["rows"][0]["p.name"], "Inline"); + assert_eq!(inline_read["rows"][0]["p.age"], 42); } #[test] diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index a1c2901..c86e32e 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -203,6 +203,67 @@ query insert_person($name: String, $age: I32) { assert_eq!(local_verify["row_count"], 1); assert_eq!(local_verify["rows"][0]["p.name"], "Mina"); + // CLI `-e` over the HTTP transport (--config points at remote server). + // Confirms inline source survives the remote-execution path identically + // to file-based queries, and exercises `POST /query` end-to-end via the + // change-then-read round trip we just established. + let inline_remote_read = parse_stdout_json(&output_success( + cli() + .arg("read") + .arg("--config") + .arg(&config) + .arg("-e") + .arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }") + .arg("--params") + .arg(r#"{"name":"Mina"}"#) + .arg("--json"), + )); + assert_eq!(inline_remote_read["row_count"], 1); + assert_eq!(inline_remote_read["rows"][0]["p.name"], "Mina"); + + let inline_remote_change = parse_stdout_json(&output_success( + cli() + .arg("change") + .arg("--config") + .arg(&config) + .arg("--query-string") + .arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }") + .arg("--params") + .arg(r#"{"name":"Inline","age":42}"#) + .arg("--json"), + )); + assert_eq!(inline_remote_change["affected_nodes"], 1); + + // `POST /query` happy path directly: a hand-rolled HTTP body using the + // new clean field names. + let http_query = client + .post(format!("{}/query", server.base_url)) + .json(&json!({ + "branch": "main", + "query": "query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }", + "params": { "name": "Inline" } + })) + .send() + .unwrap() + .error_for_status() + .unwrap() + .json::() + .unwrap(); + assert_eq!(http_query["row_count"], 1); + assert_eq!(http_query["rows"][0]["p.name"], "Inline"); + + // `POST /query` rejects mutations with 400. + let http_query_mutation = client + .post(format!("{}/query", server.base_url)) + .json(&json!({ + "branch": "main", + "query": "query bad($name: String, $age: I32) { insert Person { name: $name, age: $age } }", + "params": { "name": "Nope", "age": 1 } + })) + .send() + .unwrap(); + assert_eq!(http_query_mutation.status(), reqwest::StatusCode::BAD_REQUEST); + // `run publish` / `run list` removed. Direct-to-target writes // already landed via the change call above; the commit graph is now // the audit surface (verified separately by `commit list`). diff --git a/crates/omnigraph-server/examples/bench_actor_isolation.rs b/crates/omnigraph-server/examples/bench_actor_isolation.rs index ce38790..5a708e0 100644 --- a/crates/omnigraph-server/examples/bench_actor_isolation.rs +++ b/crates/omnigraph-server/examples/bench_actor_isolation.rs @@ -199,8 +199,8 @@ async fn drive_light_actor( let mut other = 0usize; for op_idx in 0..ops { let request_body = ChangeRequest { - query_source: "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}".to_string(), - query_name: Some("insert_person".to_string()), + query: "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}".to_string(), + name: Some("insert_person".to_string()), params: Some(serde_json::json!({ "name": format!("light-{actor_idx}-{op_idx}"), "age": op_idx as i32, diff --git a/crates/omnigraph-server/examples/bench_concurrent_http.rs b/crates/omnigraph-server/examples/bench_concurrent_http.rs index 097b50b..6a8411a 100644 --- a/crates/omnigraph-server/examples/bench_concurrent_http.rs +++ b/crates/omnigraph-server/examples/bench_concurrent_http.rs @@ -121,8 +121,8 @@ async fn drive_actor( for op_idx in 0..ops { let table_idx = pick_table(actor_idx, op_idx, mode, num_tables); let request_body = ChangeRequest { - query_source: build_query_source(table_idx), - query_name: Some("insert_item".to_string()), + query: build_query_source(table_idx), + name: Some("insert_item".to_string()), params: Some(serde_json::json!({ "name": format!("a{actor_idx}_o{op_idx}"), "value": op_idx as i32, diff --git a/crates/omnigraph-server/src/api.rs b/crates/omnigraph-server/src/api.rs index 5ff0787..2c818ae 100644 --- a/crates/omnigraph-server/src/api.rs +++ b/crates/omnigraph-server/src/api.rs @@ -250,19 +250,53 @@ pub struct ReadRequest { pub snapshot: Option, } +/// Inline read-query request for `POST /query`. +/// +/// Friendlier-named alternative to [`ReadRequest`] for ad-hoc reads and +/// AI-agent integration. Mutations are rejected with 400 — use `POST +/// /mutate` (or its deprecated alias `POST /change`) for write queries. +/// Field names are deliberately short (`query`, `name`) to match the GQ +/// keyword and the CLI `-e` flag. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct QueryRequest { + /// GQ read-query source. May declare one or more named queries; pick one + /// with `name` when more than one is declared. Mutations + /// (`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its + /// deprecated alias `POST /change`) instead. + #[schema(example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}")] + pub query: String, + /// Name of the query to run when `query` declares multiple. Optional when + /// only one query is declared. + pub name: Option, + /// JSON object whose keys match the query's declared parameters. + pub params: Option, + /// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`. + pub branch: Option, + /// Snapshot id to read from. Mutually exclusive with `branch`. + pub snapshot: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ChangeRequest { /// GQ mutation source containing `insert`, `update`, or `delete` statements. - /// May declare multiple named mutations; pick one with `query_name`. + /// May declare multiple named mutations; pick one with `name`. + /// + /// Accepts the legacy field name `query_source` as a deserialization alias. #[schema( example = "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}" )] - pub query_source: String, - /// Name of the mutation to run when `query_source` declares multiple. - pub query_name: Option, + #[serde(alias = "query_source")] + pub query: String, + /// Name of the mutation to run when `query` declares multiple. + /// + /// Accepts the legacy field name `query_name` as a deserialization alias. + #[serde(default, alias = "query_name")] + pub name: Option, /// JSON object whose keys match the mutation's declared parameters. + #[serde(default)] pub params: Option, /// Target branch. Defaults to `main`. + #[serde(default)] pub branch: Option, } diff --git a/crates/omnigraph-server/src/config.rs b/crates/omnigraph-server/src/config.rs index 1272a7b..87737d0 100644 --- a/crates/omnigraph-server/src/config.rs +++ b/crates/omnigraph-server/src/config.rs @@ -93,7 +93,16 @@ pub struct PolicySettings { #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AliasCommand { + /// Read alias (canonical: `query`). The legacy spelling `read` is + /// kept as the variant name for back-compat with serialized configs + /// and external SDK callers; `query` is accepted on the wire via the + /// serde alias. + #[serde(alias = "query")] Read, + /// Mutation alias (canonical: `mutate`). The legacy spelling `change` + /// is kept as the variant name for back-compat; `mutate` is accepted + /// on the wire via the serde alias. + #[serde(alias = "mutate")] Change, } diff --git a/crates/omnigraph-server/src/lib.rs b/crates/omnigraph-server/src/lib.rs index 6e3df89..ad41f9d 100644 --- a/crates/omnigraph-server/src/lib.rs +++ b/crates/omnigraph-server/src/lib.rs @@ -22,16 +22,16 @@ use api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, CommitListQuery, ErrorCode, ErrorOutput, ExportRequest, GraphInfo, GraphListResponse, - HealthOutput, IngestOutput, IngestRequest, ReadOutput, ReadRequest, SchemaApplyOutput, - SchemaApplyRequest, SchemaOutput, SnapshotQuery, ingest_output, schema_apply_output, - snapshot_payload, + HealthOutput, IngestOutput, IngestRequest, QueryRequest, ReadOutput, ReadRequest, + SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotQuery, ingest_output, + schema_apply_output, snapshot_payload, }; pub use auth::{AWS_SECRET_ENV, EnvOrFileTokenSource, TokenSource, resolve_token_source}; use axum::body::{Body, Bytes}; use axum::extract::DefaultBodyLimit; use axum::extract::{Extension, OriginalUri, Path, Query, Request, State}; use axum::http::StatusCode; -use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE, HeaderName, HeaderValue}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; @@ -86,9 +86,13 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash { server_health, server_graphs_list, server_snapshot, - server_read, + // deprecated; the #[deprecated] attribute on the handler + // surfaces as `deprecated: true` on the OpenAPI operation. + #[allow(deprecated)] server_read, + server_query, server_export, - server_change, + #[allow(deprecated)] server_change, + server_mutate, server_schema_apply, server_schema_get, server_ingest, @@ -930,8 +934,21 @@ pub fn build_app(state: AppState) -> Router { let per_graph_protected = Router::new() .route("/snapshot", get(server_snapshot)) .route("/export", post(server_export)) - .route("/read", post(server_read)) - .route("/change", post(server_change)) + // /read and /change are kept indefinitely for back-compat; + // their handlers carry #[deprecated] so the OpenAPI operation is + // flagged and their responses include RFC 9745 Deprecation + + // RFC 8288 Link headers. Suppress the call-site warning for the + // route registration itself. + .route("/read", post({ + #[allow(deprecated)] + server_read + })) + .route("/query", post(server_query)) + .route("/change", post({ + #[allow(deprecated)] + server_change + })) + .route("/mutate", post(server_mutate)) .route("/schema", get(server_schema_get)) .route("/schema/apply", post(server_schema_apply)) .route( @@ -1591,6 +1608,21 @@ async fn server_snapshot( Ok(Json(snapshot_payload(&branch, &snapshot))) } +/// Header values that flag a response as coming from a deprecated route +/// (RFC 9745 / RFC 8288) and point at the canonical successor. +fn deprecation_headers(successor_link: &'static str) -> [(HeaderName, HeaderValue); 2] { + [ + ( + HeaderName::from_static("deprecation"), + HeaderValue::from_static("true"), + ), + ( + HeaderName::from_static("link"), + HeaderValue::from_static(successor_link), + ), + ] +} + #[utoipa::path( post, path = "/read", @@ -1598,69 +1630,84 @@ async fn server_snapshot( operation_id = "read", request_body = ReadRequest, responses( - (status = 200, description = "Query results", body = ReadOutput), + (status = 200, description = "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ReadOutput), (status = 400, description = "Bad request", body = ErrorOutput), (status = 401, description = "Unauthorized", body = ErrorOutput), (status = 403, description = "Forbidden", body = ErrorOutput), ), security(("bearer_token" = [])), )] -/// Execute a GQ read query. +#[deprecated(note = "use POST /query instead; /read is kept indefinitely for byte-stable back-compat")] +/// **Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead. /// -/// Runs the query in `query_source` against either a branch or a frozen -/// snapshot (mutually exclusive). When `query_source` defines multiple named -/// queries, pick one with `query_name`. `params` is a JSON object whose keys -/// match the parameters declared by the query. Returns rows as a JSON array -/// plus a `columns` list. Read-only. +/// Execute a GQ read query. Behavior is unchanged from prior releases; the +/// route is kept indefinitely for byte-stable back-compat. New integrations +/// should target `POST /query`, which has clean field names (`query` / +/// `name`) and a 400-on-mutation guard. Responses from this route include +/// `Deprecation: true` and `Link: ; rel="successor-version"` +/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the +/// signal. async fn server_read( Extension(handle): Extension>, actor: Option>, Json(request): Json, -) -> std::result::Result, ApiError> { - if request.branch.is_some() && request.snapshot.is_some() { - return Err(ApiError::bad_request( - "read request may specify branch or snapshot, not both", - )); - } - - let target = read_target_from_request(request.branch, request.snapshot); - let policy_branch = match &target { - ReadTarget::Branch(branch) => Some(branch.clone()), - ReadTarget::Snapshot(_) if handle.policy.is_some() && actor.is_some() => { - let db = &handle.engine; - db.resolved_branch_of(target.clone()) - .await - .map(|branch| branch.or_else(|| Some("main".to_string()))) - .map_err(ApiError::from_omni)? - } - ReadTarget::Snapshot(_) => None, - }; - authorize_request( +) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { + let (selected_name, target, result) = run_query( + handle, actor.as_ref().map(|Extension(actor)| actor), - handle.policy.as_deref(), - PolicyRequest { - action: PolicyAction::Read, - branch: policy_branch, - target_branch: None, - }, - )?; - let (selected_name, query_params) = - select_named_query(&request.query_source, request.query_name.as_deref()) - .map_err(|err| ApiError::bad_request(err.to_string()))?; - let params = query_params_from_json(&query_params, request.params.as_ref()) - .map_err(|err| ApiError::bad_request(err.to_string()))?; + &request.query_source, + request.query_name.as_deref(), + request.params.as_ref(), + request.branch, + request.snapshot, + false, // /read predates the D2 rule; legacy callers may submit mutating queries here + ) + .await?; + Ok(( + deprecation_headers("; rel=\"successor-version\""), + Json(api::read_output(selected_name, &target, result)), + )) +} - let result = { - let db = &handle.engine; - db.query( - target.clone(), - &request.query_source, - &selected_name, - ¶ms, - ) - .await - .map_err(ApiError::from_omni)? - }; +#[utoipa::path( + post, + path = "/query", + tag = "queries", + operation_id = "query", + request_body = QueryRequest, + responses( + (status = 200, description = "Query results", body = ReadOutput), + (status = 400, description = "Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries", body = ErrorOutput), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +/// Execute an inline read query (friendlier-named alternative to `POST /read`). +/// +/// Designed for ad-hoc exploration and AI-agent tool-use: short field +/// names (`query`, `name`) match the CLI `-e` flag and the GQ `query` +/// keyword. Mutations (`insert`/`update`/`delete`) are rejected with 400 +/// -- use `POST /mutate` (or its deprecated alias `POST /change`) for +/// write queries. Otherwise behaves identically to `POST /read`: same +/// target semantics (branch xor snapshot), same Cedar action (Read), +/// same response shape. +async fn server_query( + Extension(handle): Extension>, + actor: Option>, + Json(request): Json, +) -> std::result::Result, ApiError> { + let (selected_name, target, result) = run_query( + handle, + actor.as_ref().map(|Extension(actor)| actor), + &request.query, + request.name.as_deref(), + request.params.as_ref(), + request.branch, + request.snapshot, + true, // /query is read-only; reject mutations + ) + .await?; Ok(Json(api::read_output(selected_name, &target, result))) } @@ -1725,44 +1772,31 @@ async fn server_export( .into_response()) } -#[utoipa::path( - post, - path = "/change", - tag = "mutations", - operation_id = "change", - request_body = ChangeRequest, - responses( - (status = 200, description = "Mutation results", body = ChangeOutput), - (status = 400, description = "Bad request", body = ErrorOutput), - (status = 401, description = "Unauthorized", body = ErrorOutput), - (status = 403, description = "Forbidden", body = ErrorOutput), - (status = 409, description = "Merge conflict", body = ErrorOutput), - (status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput), - ), - security(("bearer_token" = [])), -)] -/// Apply a GQ mutation to a branch. +/// Shared implementation behind `POST /mutate` (canonical) and +/// `POST /change` (deprecated alias). Returns the bare `ChangeOutput`; +/// each route handler wraps it (the alias also attaches Deprecation +/// headers). +/// Shared backend for `/mutate` (canonical) and `/change` (deprecated alias). /// -/// Writes to the named `branch` (defaults to `main`). Mutations are atomic -/// per call and produce a new commit. Returns counts of nodes and edges -/// affected. **Destructive**: on success the branch is updated; rejected -/// mutations may still acquire locks briefly. Returns 409 on merge conflict. -async fn server_change( - State(state): State, - Extension(handle): Extension>, - actor: Option>, - Json(request): Json, -) -> std::result::Result, ApiError> { - let branch = request.branch.unwrap_or_else(|| "main".to_string()); +/// Decoupled from `ChangeRequest` so MR-969's `/queries/{name}` stored-query +/// handler can call this directly with registry-supplied fields without +/// rebuilding the request body. Today's HTTP handlers unpack the request and +/// call here; the registry would do the same. +async fn run_mutate( + state: AppState, + handle: Arc, + actor: Option<&ResolvedActor>, + query: &str, + name: Option<&str>, + params_json: Option<&Value>, + branch: String, +) -> std::result::Result { let actor_arc = actor - .as_ref() - .map(|Extension(actor)| Arc::clone(&actor.actor_id)) + .map(|a| Arc::clone(&a.actor_id)) .unwrap_or_else(|| Arc::::from("anonymous")); - let actor_id = actor - .as_ref() - .map(|Extension(actor)| actor.actor_id.as_ref()); + let actor_id = actor.map(|a| a.actor_id.as_ref()); authorize_request( - actor.as_ref().map(|Extension(actor)| actor), + actor, handle.policy.as_deref(), PolicyRequest { action: PolicyAction::Change, @@ -1774,10 +1808,8 @@ async fn server_change( // estimated bytes per actor. Cedar runs FIRST so denied requests // don't consume admission slots. Estimate uses the request body // size as a coarse proxy; engine memory pressure can run higher. - let est_bytes = request.query_source.len() as u64 - + request - .params - .as_ref() + let est_bytes = query.len() as u64 + + params_json .map(|p| p.to_string().len() as u64) .unwrap_or(0); let _admission = state @@ -1785,30 +1817,188 @@ async fn server_change( .try_admit(&actor_arc, est_bytes) .map_err(ApiError::from_workload_reject)?; let (selected_name, query_params) = - select_named_query(&request.query_source, request.query_name.as_deref()) - .map_err(|err| ApiError::bad_request(err.to_string()))?; - let params = query_params_from_json(&query_params, request.params.as_ref()) + select_named_query(query, name).map_err(|err| ApiError::bad_request(err.to_string()))?; + let params = query_params_from_json(&query_params, params_json) .map_err(|err| ApiError::bad_request(err.to_string()))?; let result = { let db = &handle.engine; - db.mutate_as( - &branch, - &request.query_source, - &selected_name, - ¶ms, - actor_id, - ) - .await - .map_err(ApiError::from_omni)? + db.mutate_as(&branch, query, &selected_name, ¶ms, actor_id) + .await + .map_err(ApiError::from_omni)? }; - Ok(Json(ChangeOutput { + Ok(ChangeOutput { branch, query_name: selected_name, affected_nodes: result.affected_nodes, affected_edges: result.affected_edges, actor_id: actor_id.map(str::to_string), - })) + }) +} + +/// Shared backend for `/query` (canonical) and `/read` (deprecated alias). +/// +/// Mirrors [`run_mutate`]'s decoupled shape so MR-969's stored-query handler +/// can call here with registry-supplied fields. Rejects inline source that +/// contains mutations (D2 rule); callers wanting writes go through +/// [`run_mutate`] instead. +/// +/// Intentionally does **not** take [`AppState`] (unlike [`run_mutate`]): +/// reads are not admission-gated today, so there is no `state.workload` +/// consumer. The signature grows the parameter when Phase 1 (MR-976) adds +/// the request envelope's `expect: { max_rows_scanned: N }` budget, or +/// MR-969 extends per-actor admission to stored-read invocations. +async fn run_query( + handle: Arc, + actor: Option<&ResolvedActor>, + query: &str, + name: Option<&str>, + params_json: Option<&Value>, + branch: Option, + snapshot: Option, + reject_mutations: bool, +) -> std::result::Result<(String, ReadTarget, omnigraph_compiler::result::QueryResult), ApiError> { + if branch.is_some() && snapshot.is_some() { + return Err(ApiError::bad_request( + "request may specify branch or snapshot, not both", + )); + } + + let target = read_target_from_request(branch, snapshot); + let policy_branch = match &target { + ReadTarget::Branch(branch) => Some(branch.clone()), + ReadTarget::Snapshot(_) if handle.policy.is_some() && actor.is_some() => { + let db = &handle.engine; + db.resolved_branch_of(target.clone()) + .await + .map(|branch| branch.or_else(|| Some("main".to_string()))) + .map_err(ApiError::from_omni)? + } + ReadTarget::Snapshot(_) => None, + }; + authorize_request( + actor, + handle.policy.as_deref(), + PolicyRequest { + action: PolicyAction::Read, + branch: policy_branch, + target_branch: None, + }, + )?; + let query_decl = + select_named_query_decl(query, name).map_err(|err| ApiError::bad_request(err.to_string()))?; + if reject_mutations && !query_decl.mutations.is_empty() { + return Err(ApiError::bad_request(format!( + "query '{}' contains mutations (insert/update/delete); use POST /mutate for write queries", + query_decl.name + ))); + } + let selected_name = query_decl.name.clone(); + let params = query_params_from_json(&query_decl.params, params_json) + .map_err(|err| ApiError::bad_request(err.to_string()))?; + + let result = { + let db = &handle.engine; + db.query(target.clone(), query, &selected_name, ¶ms) + .await + .map_err(ApiError::from_omni)? + }; + Ok((selected_name, target, result)) +} + +#[utoipa::path( + post, + path = "/change", + tag = "mutations", + operation_id = "change", + request_body = ChangeRequest, + responses( + (status = 200, description = "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", body = ChangeOutput), + (status = 400, description = "Bad request", body = ErrorOutput), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + (status = 409, description = "Merge conflict", body = ErrorOutput), + (status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +#[deprecated(note = "use POST /mutate instead; /change is kept indefinitely for back-compat")] +/// **Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead. +/// +/// Apply a GQ mutation to a branch. Behavior is unchanged; the route is +/// kept indefinitely for back-compat. New integrations should target +/// `POST /mutate`, which has identical semantics and a name that pairs +/// cleanly with `POST /query`. Responses from this route include +/// `Deprecation: true` and `Link: ; rel="successor-version"` +/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the +/// signal. +async fn server_change( + State(state): State, + Extension(handle): Extension>, + actor: Option>, + Json(request): Json, +) -> std::result::Result<([(HeaderName, HeaderValue); 2], Json), ApiError> { + let branch = request.branch.unwrap_or_else(|| "main".to_string()); + let output = run_mutate( + state, + handle, + actor.as_ref().map(|Extension(actor)| actor), + &request.query, + request.name.as_deref(), + request.params.as_ref(), + branch, + ) + .await?; + Ok(( + deprecation_headers("; rel=\"successor-version\""), + Json(output), + )) +} + +#[utoipa::path( + post, + path = "/mutate", + tag = "mutations", + operation_id = "mutate", + request_body = ChangeRequest, + responses( + (status = 200, description = "Mutation results", body = ChangeOutput), + (status = 400, description = "Bad request", body = ErrorOutput), + (status = 401, description = "Unauthorized", body = ErrorOutput), + (status = 403, description = "Forbidden", body = ErrorOutput), + (status = 409, description = "Merge conflict", body = ErrorOutput), + (status = 429, description = "Per-actor admission cap exceeded; honor `Retry-After` header", body = ErrorOutput), + ), + security(("bearer_token" = [])), +)] +/// Apply a GQ mutation to a branch (canonical mutation endpoint). +/// +/// Writes to the named `branch` (defaults to `main`). Mutations are atomic +/// per call and produce a new commit. Returns counts of nodes and edges +/// affected. **Destructive**: on success the branch is updated; rejected +/// mutations may still acquire locks briefly. Returns 409 on merge conflict. +/// +/// Pairs with `POST /query` (read-only). The legacy `POST /change` route +/// has identical semantics and is kept as a deprecated alias. +async fn server_mutate( + State(state): State, + Extension(handle): Extension>, + actor: Option>, + Json(request): Json, +) -> std::result::Result, ApiError> { + let branch = request.branch.unwrap_or_else(|| "main".to_string()); + Ok(Json( + run_mutate( + state, + handle, + actor.as_ref().map(|Extension(actor)| actor), + &request.query, + request.name.as_deref(), + request.params.as_ref(), + branch, + ) + .await?, + )) } #[utoipa::path( @@ -2350,10 +2540,10 @@ fn read_target_from_request(branch: Option, snapshot: Option) -> } } -fn select_named_query( +fn select_named_query_decl( query_source: &str, requested_name: Option<&str>, -) -> Result<(String, Vec)> { +) -> Result { let parsed = parse_query(query_source)?; let query = if let Some(name) = requested_name { parsed @@ -2366,7 +2556,14 @@ fn select_named_query( } else { bail!("query file contains multiple queries; pass --name"); }; + Ok(query) +} +fn select_named_query( + query_source: &str, + requested_name: Option<&str>, +) -> Result<(String, Vec)> { + let query = select_named_query_decl(query_source, requested_name)?; Ok((query.name, query.params)) } diff --git a/crates/omnigraph-server/tests/openapi.rs b/crates/omnigraph-server/tests/openapi.rs index 6b28f6c..a2542db 100644 --- a/crates/omnigraph-server/tests/openapi.rs +++ b/crates/omnigraph-server/tests/openapi.rs @@ -164,8 +164,10 @@ const EXPECTED_PATHS: &[&str] = &[ "/graphs", "/snapshot", "/read", + "/query", "/export", "/change", + "/mutate", "/schema", "/schema/apply", "/ingest", @@ -232,6 +234,64 @@ fn openapi_change_is_post() { assert!(doc["paths"]["/change"]["post"].is_object()); } +#[test] +fn openapi_mutate_is_post() { + let doc = openapi_json(); + assert!(doc["paths"]["/mutate"]["post"].is_object()); +} + +// Deprecation flagging — `/read` and `/change` are kept indefinitely for +// back-compat but are flagged so OpenAPI codegens (typescript-fetch, +// openapi-generator, oapi-codegen, etc.) emit @deprecated on the generated +// SDK methods. The canonical successors `/query` and `/mutate` are not +// flagged. See `deprecation_headers` in `omnigraph-server/src/lib.rs` for +// the matching runtime signal (RFC 9745 + RFC 8288 headers). +#[test] +fn openapi_read_is_deprecated() { + let doc = openapi_json(); + assert_eq!( + doc["paths"]["/read"]["post"]["deprecated"], + serde_json::Value::Bool(true), + "/read must be flagged deprecated in OpenAPI; use /query instead" + ); +} + +#[test] +fn openapi_change_is_deprecated() { + let doc = openapi_json(); + assert_eq!( + doc["paths"]["/change"]["post"]["deprecated"], + serde_json::Value::Bool(true), + "/change must be flagged deprecated in OpenAPI; use /mutate instead" + ); +} + +#[test] +fn openapi_query_is_not_deprecated() { + let doc = openapi_json(); + let deprecated = doc["paths"]["/query"]["post"] + .get("deprecated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + assert!( + !deprecated, + "/query is the canonical read endpoint and must not be deprecated" + ); +} + +#[test] +fn openapi_mutate_is_not_deprecated() { + let doc = openapi_json(); + let deprecated = doc["paths"]["/mutate"]["post"] + .get("deprecated") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + assert!( + !deprecated, + "/mutate is the canonical mutation endpoint and must not be deprecated" + ); +} + #[test] fn openapi_ingest_is_post() { let doc = openapi_json(); @@ -283,6 +343,7 @@ const EXPECTED_SCHEMAS: &[&str] = &[ "BranchMergeRequest", "ChangeOutput", "ChangeRequest", + "QueryRequest", "CommitListOutput", "CommitOutput", "ErrorCode", @@ -373,13 +434,65 @@ fn read_output_schema_has_expected_fields() { #[test] fn change_request_schema_has_expected_fields() { + // Canonical field names on the wire are now `query` and `name`. The + // schema descriptions document `query_source` and `query_name` as + // legacy deserialization aliases for backward compatibility. let doc = openapi_json(); let schema = &doc["components"]["schemas"]["ChangeRequest"]; let props = schema["properties"].as_object().unwrap(); - assert!(props.contains_key("query_source")); - assert!(props.contains_key("query_name")); + assert!(props.contains_key("query")); + assert!(props.contains_key("name")); assert!(props.contains_key("params")); assert!(props.contains_key("branch")); + let query_desc = schema["properties"]["query"]["description"] + .as_str() + .unwrap_or_default(); + assert!( + query_desc.contains("query_source"), + "expected `query` description to mention the legacy `query_source` alias, got: {query_desc}" + ); +} + +#[test] +fn query_request_schema_has_expected_fields() { + let doc = openapi_json(); + let schema = &doc["components"]["schemas"]["QueryRequest"]; + let props = schema["properties"].as_object().unwrap(); + assert!(props.contains_key("query")); + assert!(props.contains_key("name")); + assert!(props.contains_key("params")); + assert!(props.contains_key("branch")); + assert!(props.contains_key("snapshot")); +} + +#[test] +fn query_request_query_is_required() { + let doc = openapi_json(); + let schema = &doc["components"]["schemas"]["QueryRequest"]; + let required: Vec<&str> = schema["required"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(required.contains(&"query")); +} + +#[test] +fn openapi_query_is_post() { + let doc = openapi_json(); + assert!(doc["paths"]["/query"]["post"].is_object()); +} + +#[test] +fn query_endpoint_documents_mutation_400() { + let doc = openapi_json(); + let four_hundred = &doc["paths"]["/query"]["post"]["responses"]["400"]; + let description = four_hundred["description"].as_str().unwrap_or_default(); + assert!( + description.contains("mutations") || description.contains("POST /mutate"), + "expected /query 400 response to mention mutation rejection, got: {description}" + ); } #[test] diff --git a/crates/omnigraph-server/tests/server.rs b/crates/omnigraph-server/tests/server.rs index a588cc7..3ace80e 100644 --- a/crates/omnigraph-server/tests/server.rs +++ b/crates/omnigraph-server/tests/server.rs @@ -14,7 +14,7 @@ use omnigraph::loader::{LoadMode, load_jsonl}; use omnigraph_policy::{PolicyChecker, PolicyEngine}; use omnigraph_server::api::{ BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest, - IngestRequest, ReadRequest, SchemaApplyRequest, SchemaOutput, + IngestRequest, QueryRequest, ReadRequest, SchemaApplyRequest, SchemaOutput, }; use omnigraph_server::{AppState, build_app}; use serde_json::{Value, json}; @@ -831,8 +831,8 @@ async fn schema_drift_returns_conflict_for_snapshot_read_and_change() { ); let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Mina", "age": 28 })), branch: Some("main".to_string()), }; @@ -1472,8 +1472,8 @@ async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() let app = build_app(state); let main_change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Mina", "age": 28 })), branch: Some("main".to_string()), }; @@ -1496,8 +1496,8 @@ async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() ); let feature_change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Mina", "age": 28 })), branch: Some("feature".to_string()), }; @@ -1592,8 +1592,8 @@ async fn authenticated_change_stamps_actor_on_commits() { let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[("act-andrew", "token-one")]).await; let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Mina", "age": 28 })), branch: Some("main".to_string()), }; @@ -1841,8 +1841,8 @@ async fn authenticated_branch_merge_stamps_merge_actor_on_head_commit() { assert_eq!(create_status, StatusCode::OK); let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Zoe", "age": 33 })), branch: Some("feature".to_string()), }; @@ -1971,8 +1971,8 @@ async fn repeated_read_after_change_sees_updated_state_from_same_app() { let (_temp, app) = app_for_loaded_graph().await; let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Mina", "age": 28 })), branch: Some("main".to_string()), }; @@ -2011,6 +2011,265 @@ async fn repeated_read_after_change_sees_updated_state_from_same_app() { assert_eq!(read_body["rows"][0]["p.name"], "Mina"); } +#[tokio::test(flavor = "multi_thread")] +async fn query_endpoint_runs_inline_read() { + let (_temp, app) = app_for_loaded_graph().await; + + let query = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), + params: Some(json!({ "name": "Alice" })), + branch: Some("main".to_string()), + snapshot: None, + }; + let (status, body) = json_response( + &app, + Request::builder() + .uri("/query") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&query).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["query_name"], "get_person"); + assert_eq!(body["row_count"], 1); + assert_eq!(body["rows"][0]["p.name"], "Alice"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn query_endpoint_rejects_mutation_with_400() { + let (_temp, app) = app_for_loaded_graph().await; + + let query = QueryRequest { + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), + params: Some(json!({ "name": "Should", "age": 1 })), + branch: Some("main".to_string()), + snapshot: None, + }; + let (status, body) = json_response( + &app, + Request::builder() + .uri("/query") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&query).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + let err = body["error"].as_str().unwrap_or_default(); + assert!( + err.contains("contains mutations") && err.contains("POST /mutate"), + "expected mutation-rejection message pointing at canonical /mutate, got: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mutate_endpoint_runs_inline_mutation() { + // Canonical mutation endpoint. Pairs with `/query` on the read side. + // Same wire shape as `/change`, no deprecation signal. + let (_temp, app) = app_for_loaded_graph().await; + + let request = json!({ + "query": MUTATION_QUERIES, + "name": "insert_person", + "params": { "name": "Mutie", "age": 30 }, + "branch": "main", + }); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/mutate") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + // Canonical route is NOT deprecated; no Deprecation header expected. + assert!( + response.headers().get("deprecation").is_none(), + "POST /mutate must not advertise itself as deprecated" + ); + let body_bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["affected_nodes"], 1); + assert_eq!(body["query_name"], "insert_person"); + assert_eq!(body["branch"], "main"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn change_endpoint_emits_deprecation_headers() { + // `/change` is kept indefinitely for back-compat but flagged at runtime + // per RFC 9745 (`Deprecation: true`) + RFC 8288 (`Link: ; + // rel="successor-version"`). The OpenAPI side is covered by + // `openapi_change_is_deprecated` in tests/openapi.rs. + let (_temp, app) = app_for_loaded_graph().await; + + let request = json!({ + "query": MUTATION_QUERIES, + "name": "insert_person", + "params": { "name": "Legacyer", "age": 33 }, + "branch": "main", + }); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/change") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("deprecation") + .and_then(|v| v.to_str().ok()), + Some("true"), + "POST /change must advertise `Deprecation: true` (RFC 9745)" + ); + assert_eq!( + response.headers().get("link").and_then(|v| v.to_str().ok()), + Some("; rel=\"successor-version\""), + "POST /change must point at /mutate via `Link` rel=successor-version (RFC 8288)" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_endpoint_emits_deprecation_headers() { + // `/read` is kept indefinitely for byte-stable back-compat but flagged + // at runtime per RFC 9745 + RFC 8288. Successor is `/query`. + let (_temp, app) = app_for_loaded_graph().await; + + let request = ReadRequest { + query_source: fs::read_to_string(fixture("test.gq")).unwrap(), + query_name: Some("get_person".to_string()), + params: Some(json!({ "name": "Alice" })), + branch: Some("main".to_string()), + snapshot: None, + }; + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/read") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("deprecation") + .and_then(|v| v.to_str().ok()), + Some("true"), + "POST /read must advertise `Deprecation: true` (RFC 9745)" + ); + assert_eq!( + response.headers().get("link").and_then(|v| v.to_str().ok()), + Some("; rel=\"successor-version\""), + "POST /read must point at /query via `Link` rel=successor-version (RFC 8288)" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn query_endpoint_does_not_emit_deprecation_headers() { + // Sanity check the inverse: the canonical `/query` endpoint must not + // carry deprecation signaling, so SDK codegens don't propagate a + // bogus `@deprecated` marker. + let (_temp, app) = app_for_loaded_graph().await; + + let request = QueryRequest { + query: fs::read_to_string(fixture("test.gq")).unwrap(), + name: Some("get_person".to_string()), + params: Some(json!({ "name": "Alice" })), + branch: Some("main".to_string()), + snapshot: None, + }; + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/query") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&request).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response.headers().get("deprecation").is_none(), + "POST /query is canonical and must not advertise itself as deprecated" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn change_endpoint_accepts_legacy_field_names() { + // The canonical wire field names on /change are `query` and `name`, but + // serde aliases keep the legacy `query_source`/`query_name` payload + // shape working for clients that haven't migrated yet. Pin both shapes. + let (_temp, app) = app_for_loaded_graph().await; + + let legacy_body = json!({ + "query_source": MUTATION_QUERIES, + "query_name": "insert_person", + "params": { "name": "Legacy", "age": 21 }, + "branch": "main", + }); + let (status, body) = json_response( + &app, + Request::builder() + .uri("/change") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&legacy_body).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["affected_nodes"], 1); + + let canonical_body = json!({ + "query": MUTATION_QUERIES, + "name": "insert_person", + "params": { "name": "Canonical", "age": 22 }, + "branch": "main", + }); + let (status, body) = json_response( + &app, + Request::builder() + .uri("/change") + .method(Method::POST) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&canonical_body).unwrap())) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["affected_nodes"], 1); +} + #[tokio::test(flavor = "multi_thread")] async fn remote_branch_list_create_merge_flow_works() { let (_temp, app) = app_for_loaded_graph().await; @@ -2058,8 +2317,8 @@ async fn remote_branch_list_create_merge_flow_works() { assert_eq!(list_body["branches"], json!(["feature", "main"])); let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "Zoe", "age": 33 })), branch: Some("feature".to_string()), }; @@ -2392,8 +2651,8 @@ async fn change_conflict_returns_manifest_conflict_409() { .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("set_age".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("set_age".to_string()), params: Some(json!({ "name": "Alice", "age": 33 })), branch: Some("main".to_string()), }) @@ -2452,8 +2711,8 @@ async fn change_concurrent_inserts_same_key_serialize_without_409() { let app = app.clone(); handles.push(tokio::spawn(async move { let body = serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": format!("racer-{i}"), "age": i as i32 })), branch: Some("main".to_string()), }) @@ -2565,8 +2824,8 @@ async fn change_concurrent_updates_same_key_serialize_via_publisher_cas() { let target_age = 100 + i as i32; handles.push(tokio::spawn(async move { let body = serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("set_age".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("set_age".to_string()), params: Some(json!({ "name": "Alice", "age": target_age })), branch: Some("main".to_string()), }) @@ -2734,8 +2993,8 @@ mod matrix { pub async fn insert_person(&self, branch: &str, name: &str, age: i32) { let body = serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": name, "age": age })), branch: Some(branch.to_string()), }) @@ -2881,8 +3140,8 @@ mod matrix { /// /change either deadlocks or returns a non-200. pub async fn assert_post_op_sentinel(&self, cell: &str, sentinel: &str) { let body = serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": sentinel, "age": 99 })), branch: Some("main".to_string()), }) @@ -2960,8 +3219,8 @@ mod matrix { tokio::spawn(async move { barrier.wait().await; let body = serde_json::to_vec(&ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": name, "age": age })), branch: Some(branch), }) @@ -3466,8 +3725,8 @@ query insert_c($name: String) { let app_p = app.clone(); handles.push(tokio::spawn(async move { let body = serde_json::to_vec(&ChangeRequest { - query_source: PERSON_QUERY.to_string(), - query_name: Some("insert_p".to_string()), + query: PERSON_QUERY.to_string(), + name: Some("insert_p".to_string()), params: Some(json!({ "name": format!("p-{i}"), "age": i as i32 })), branch: Some("main".to_string()), }) @@ -3483,8 +3742,8 @@ query insert_c($name: String) { let app_c = app.clone(); handles.push(tokio::spawn(async move { let body = serde_json::to_vec(&ChangeRequest { - query_source: COMPANY_QUERY.to_string(), - query_name: Some("insert_c".to_string()), + query: COMPANY_QUERY.to_string(), + name: Some("insert_c".to_string()), params: Some(json!({ "name": format!("c-{i}") })), branch: Some("main".to_string()), }) @@ -3824,8 +4083,8 @@ async fn default_deny_mode_rejects_change_with_forbidden() { .await; let change = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "DefaultDeny", "age": 1 })), branch: Some("main".to_string()), }; @@ -3988,8 +4247,8 @@ async fn http_change_decision( .unwrap(); let app = build_app(state); let req = ChangeRequest { - query_source: MUTATION_QUERIES.to_string(), - query_name: Some("insert_person".to_string()), + query: MUTATION_QUERIES.to_string(), + name: Some("insert_person".to_string()), params: Some(json!({ "name": "ParityCharlie", "age": 30 })), branch: Some("main".to_string()), }; diff --git a/docs/dev/index.md b/docs/dev/index.md index 504c277..83df8c8 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -58,6 +58,7 @@ Working documents for in-flight feature work. Removed when the work lands. | Area | Read | |---|---| | Schema-lint chassis v1 (MR-694) — `--allow-data-loss`, soft/hard drops | [schema-lint-v1-plan.md](schema-lint-v1-plan.md) | +| Inline + stored queries, request/response envelope, MCP (MR-656 / MR-976 / MR-969) | [rfc-001-queries-envelope-mcp.md](rfc-001-queries-envelope-mcp.md) | ## Boundary diff --git a/docs/dev/rfc-001-queries-envelope-mcp.md b/docs/dev/rfc-001-queries-envelope-mcp.md new file mode 100644 index 0000000..b5d62d4 --- /dev/null +++ b/docs/dev/rfc-001-queries-envelope-mcp.md @@ -0,0 +1,351 @@ +# RFC: Inline + Stored Queries, Request/Response Envelope, MCP + +**Status:** Proposed +**Date:** 2026-05-28 +**Tickets:** MR-656 (inline `-e` + URL rename), MR-668 (multi-graph, shipped), MR-976 (Phase 1 envelope parent: MR-977 / MR-978 / MR-979 / MR-980), MR-969 (stored queries + MCP) +**Target release:** v0.6.x patch series (MR-656 + Phase 1) → v0.7.0 (MR-969 PRs 1-3) + +## Summary + +OmniGraph today exposes `POST /read` and `POST /change` with a weakly-contracted body (counts only on writes) and no per-query authorization. This RFC consolidates the work landing across three Linear tickets into one coherent design: + +1. **MR-656**: rename `/read` → `/query` and `/change` → `/mutate`, add inline `-e` CLI flag, ship three-channel deprecation on the legacy URLs. **In flight, PR #110.** +2. **Envelope hardening** (this RFC adds it as a Phase 1 before MR-969): make today's mutation surface agent-grade with idempotency keys, preconditions, deadlines, and a structured response envelope carrying `audit_id`, `commit_id`, `snapshot_id`, and cost stats. +3. **MR-969**: add a stored-query registry, `POST /queries/{name}`, a new `InvokeQuery` Cedar action with per-query scope, inline pragmas in `.gq` (`@description`, `@returns`, `@mcp`), and MCP transport over the same routing primitive. + +The bet: inline and stored queries serve different stages of the same lifecycle, run through the same engine code, and are gated by different Cedar actions. HelixDB collapsed to stored-only. Postgres has neither stored-query Cedar nor MCP. The window for an OSS, declarative, agent-grade graph query surface is open. + +## Motivation + +Three problems today: + +- **Mutation responses are too thin.** `ChangeOutput { node_count, edge_count }` is the entire memory the API has of what just happened. No `commit_id`, no `audit_id`, no `snapshot_id`. Agents reporting results have nothing to cite. Humans can't reproduce a read. +- **No agent-safe surface.** Cedar gates `read` and `change` at the action level. A token either runs *any* query or *no* query of that kind. There is no way to express "this agent can invoke `find_user` and nothing else." +- **No discovery primitive.** Agents need a tool list. SDKs need a stable contract per operation. Both are absent. + +The MR-656 rename solves the cosmetic asymmetry (`/read` was a poor pair for the future `/queries/{name}`). The envelope work and MR-969 solve the substantive gaps. + +## Non-Goals + +- Compiled query bundles (HelixDB's `queries.json` shape). `.gq` files are already declarative; the file *is* the artifact. +- Hot reload of the registry. Restart-only matches the multi-graph operational model from MR-668. +- Per-query rate limits in v1. Existing `WorkloadController` covers the bulk of the risk. Punt to a future ticket. +- Cross-graph tool listing in MCP. Agents loop over per-graph endpoints when they need multi-graph access. Avoid namespacing in the contract. +- Web dashboard / control-plane management of the registry. Operators edit `.gq` + `policy.yaml` and restart. +- Schema introspection through MCP. Schema is an operator concern; agents see types through declared return shapes on the queries they're allowed to invoke. +- Per-environment override files. Environment-specific differences live in `policy.yaml`, which already has per-env variants. + +## Background + +OmniGraph runs on Lance 6.x with a property graph layered on top: typed nodes/edges in per-type Lance datasets, atomic multi-table commits via a `__manifest` table, branchable and time-travelable through Lance versioning. The HTTP server (`omnigraph-server`) is Axum + utoipa with bearer-token auth and Cedar policy enforcement at every `_as` writer. + +MR-668 shipped multi-graph mode in v0.6.0. One server process can host 1-10 graphs, with per-graph endpoints under `/graphs/{id}/...`. Cedar policy resolves against `Server::"root"` (for management actions) and `Graph::"prod"` (for per-graph actions). + +MR-656 is currently in PR #110 (CONFLICTING / DIRTY against main; rebase planned). It renames the URL surface, adds inline source support, and ships three-channel deprecation (OpenAPI `deprecated: true`, RFC 9745 `Deprecation: true` header, RFC 8288 successor `Link`). + +## Design + +### Two paths, one engine + +| Dimension | Inline (`/query`, `/mutate`) | Stored (`/queries/{name}`) | +|---|---|---| +| Source location | Request body | `queries/*.gq` on disk | +| Parse + typecheck | Per request | Once at server boot | +| Cedar action | `read` / `change` | `invoke_query` (per-name scope) | +| MCP-exposed | No (not enumerable) | Yes (when `@mcp(expose=true)`) | +| Output schema | Inferred | Declared via `@returns`, asserted at boot | +| Audit log shape | Records query hash | Records query name | +| Failure visibility | Runtime 400 | Boot-time refusal | + +Both paths converge in the engine: + +``` +POST /query ─parse→─┐ +POST /mutate ─parse→─┤ + ├─→ run_query / run_mutate(ast, params, branch) ─→ envelope +POST /queries/{name} ───────┤ +POST /mcp/invoke ───────────┘ (MCP adapter on top of the same call) +``` + +The MR-656 rebase widens `run_query` / `run_mutate` to accept a parsed AST or source string. Inline parses on each call. Stored looks up the pre-parsed AST in the registry. Same execution path beyond that point. + +### Cedar split (the LLM-safe wedge) + +Inline and stored coexist safely because they're gated by different actions: + +```yaml +# Production policy — agents locked to a curated stored-query set +- deny: + actors: { group: agents } + actions: [read, change] # blocks /query, /mutate, /read, /change + +- allow: + actors: { group: agents } + actions: [invoke_query] + resource: Graph::"prod" + query_scope: { names: [find_user, list_orders, search_docs] } +``` + +The agent's effective surface: three stored queries by name. Cannot compose inline. Cannot enumerate schema. Cannot read arbitrary entities. A developer in the same deployment with `dev-engineers` group membership might have `[read, change, invoke_query]` allowed — full access to both paths. + +Same server, same data, two completely different API surfaces depending on token. This is the posture MR-969 calls "LLM-safe API surface." + +### `.gq` pragmas + +Stored queries self-describe at the top of the source file: + +```gq +@description("Look up a user by ID. Returns name, email, last_login.") +@returns({ name: String, email: String, last_login: DateTime? }) +@mcp(expose=true) + +query find_user($id: String) { + match { $u: User { id: $id } } + return { $u.name, $u.email, $u.last_login } +} +``` + +Three pragmas in v1: + +- `@description("...")` — string surfaced in `omnigraph queries explain` and MCP tool descriptions. +- `@returns({...})` — optional output type assertion. Compiler verifies the inferred type matches; mismatch fails server startup. +- `@mcp(expose=true|false, tool_name="alt_name"?)` — controls MCP visibility. Default is `expose=false` (callable via HTTP, hidden from MCP). `tool_name` defaults to the query name. + +Pragmas live in source, not in a separate YAML registry. Drop a file in `queries/`, restart, the registry picks it up. The full agent contract is reviewable in one diff. + +### Request envelope ("before") + +Today's request carries auth + body. The envelope adds five fields, all optional: + +```http +POST /graphs/prod/queries/find_user +Authorization: Bearer +Idempotency-Key: 01HXYZ... # mutations only +If-Match: 01HABC... # optimistic concurrency +X-Deadline: 2026-05-28T19:30:00Z # or X-Timeout-Ms: 5000 +X-Trace-Id: 01HDEF... +Content-Type: application/json + +{ + "params": { "id": "u-42" }, + "branch": "main", + "expect": "read_only", # scope assertion + "dry_run": false, # mutations only + "fields": ["name", "email"] # result projection +} +``` + +Field semantics: + +| Field | Applies to | Purpose | +|---|---|---| +| `Idempotency-Key` | Mutations | Server caches `(token, key)` → response for 10 minutes. Replays return cached response with `Idempotency-Replay: true` header. Prevents double-write on retry. | +| `If-Match` | Mutations | Run only if branch HEAD matches the given commit ID. 412 Precondition Failed otherwise. Enables read-then-write without races. | +| `X-Deadline` / `X-Timeout-Ms` | All | Server respects; returns 504-typed error past the deadline. Bounds execution for context-budget-constrained callers. | +| `X-Trace-Id` | All | Caller-supplied; server echoes back. Lets agents correlate multi-call sequences. | +| `expect` | All | Caller asserts shape: `"read_only"`, `{"max_rows_scanned": 10000}`. Server validates against parsed AST or planner estimate; rejects before running. | +| `dry_run` | Mutations | Returns what *would* happen without committing. Implemented via scratch branch + diff + discard. | +| `fields` | Reads | Server returns only listed columns. Saves bandwidth + agent context window. | + +All five fields are optional; today's call shape continues working. + +### Response envelope ("after") + +The response envelope replaces today's bare-result shape with a structured wrapper. Every endpoint (inline, stored, MCP) returns the same envelope: + +```json +{ + "result": { "name": "Alice", "email": "alice@..." }, + "audit_id": "01HGHI...", + "snapshot_id": "01HJKL...", + "commit_id": null, + "stats": { + "rows_scanned": 1, + "ms_elapsed": 4, + "bytes_read": 128 + }, + "warnings": [] +} +``` + +Response headers: + +| Header | When | Purpose | +|---|---|---| +| `Idempotency-Replay: true\|false` | Mutations | Was this response served from the idempotency cache? | +| `X-Trace-Id` | All | Echo of the request's trace ID, or server-minted if absent. | +| `Deprecation: true` | `/read`, `/change` only | RFC 9745 signal from MR-656. | +| `Link: ; rel="successor-version"` | `/read`, `/change` only | RFC 8288 successor pointer from MR-656. | + +Body envelope fields: + +| Field | When | Purpose | +|---|---|---| +| `result` | All | The actual response payload. Shape determined by the query's return type. | +| `audit_id` | All | ULID for the audit log entry. Lets the caller cite exactly what ran. | +| `snapshot_id` | All | Manifest snapshot the query observed. Reproducibility — replay with `?snapshot=`. | +| `commit_id` | Mutations | ULID of the new commit. Null for reads. Lets the caller cite what changed. | +| `stats` | All | `{rows_scanned, ms_elapsed, bytes_read}`. Lets agents learn what's expensive. | +| `warnings` | All | Non-fatal observations: deprecated property access, full-scan despite available index, scan exceeded soft row limit. Empty array when none. | + +The envelope is the API's *memory of what happened*. Without `audit_id` + `commit_id` + `snapshot_id`, agent reports are hearsay and reads are not reproducible. With them, provenance is a first-class property of every response. + +### MCP integration with multi-graph + +MCP routes are per-graph, matching the rest of MR-668's hierarchy: + +``` +GET /graphs/{id}/mcp/tools # tool list for this graph, this token +POST /graphs/{id}/mcp/invoke # invoke a tool on this graph +``` + +Single-mode collapses to `/mcp/tools` and `/mcp/invoke` at the root (same shape, no `/graphs/{id}` prefix). Both modes route through identical handler code. + +Tool list response: + +```json +{ + "tools": [ + { + "name": "find_user", + "description": "Look up a user by ID.", + "inputSchema": { "id": { "type": "string", "required": true } }, + "outputSchema": { "name": "string", "email": "string", "last_login": "datetime?" }, + "read_only": true + } + ], + "graph_id": "prod", + "snapshot_id": "01HJKL..." +} +``` + +The tool list is the subset of registered queries where (a) `@mcp(expose=true)` in source and (b) Cedar permits `invoke_query` for this token on this name on this graph. Computed per request — cheap because it's just iterating the registry + one Cedar evaluation per name. + +**Token scoping.** Most tokens carry one graph claim. Cross-graph access requires multiple Cedar rules (one per graph) and is uncommon. Agents that genuinely operate across graphs loop over `/graphs/{id}/mcp/tools` themselves. The contract stays clean; graph renames don't break tool names. + +**Discovery.** Agents are told their MCP URL at provisioning: `https://omnigraph.example.com/graphs/prod/mcp`. Token authorizes; URL identifies. Same model as every OAuth-style API. + +**`/mcp/invoke` is a protocol adapter.** Unwrap MCP protocol envelope, call the same code path as `/queries/{name}`, wrap the response in MCP shape. No new execution semantics. + +### CLI surface + +The CLI mirrors the HTTP routes. Post-MR-656 and post-MR-969: + +```bash +# Inline (MR-656) +omnigraph query -e 'query test() { ... }' # /query +omnigraph mutate -e 'query bump() { update ... }' # /mutate + +# Stored (MR-969) +omnigraph queries list # GET /queries (future) +omnigraph queries explain find_user # show params + return shape + source +omnigraph queries invoke find_user --param id=u-42 # POST /queries/find_user + +# Pragma + registry validation +omnigraph lint queries/find_user.gq # parses + verifies pragmas +omnigraph queries lint # validates the whole registry +``` + +`omnigraph queries invoke` reads bearer + URL from `omnigraph.yaml` like the other remote commands. Local invocations work the same way the existing `omnigraph query`/`mutate` do. + +### Lifecycle + +The promotion path from inline to stored is the load-bearing DX story: + +``` +1. EXPLORE omnigraph query -e 'query find_user($id: String) { ... }' --params '{"id": "u-42"}' + └─ POST /query, iterate freely + +2. STABILIZE write queries/find_user.gq with @description, @returns, @mcp pragmas + └─ git diff shows the full agent contract in one file + +3. AUTHORIZE add Cedar rule allowing invoke_query for the appropriate actor group + └─ scope_names: [find_user] + +4. DEPLOY restart server + └─ /queries/find_user goes live + └─ /mcp/tools auto-lists it for any token with invoke_query[find_user] + +5. RETIRE deny: read change for the agent group + └─ inline access closed; stored remains + └─ MR-969's "LLM-safe API surface" reached +``` + +Same `.gq` source through all five steps. No rewrite. No language shift. The pragmas are the only added syntax between exploration and production. + +## Migration + +Existing callers see no breakage: + +- `POST /read` and `POST /change` keep working, now with `Deprecation: true` headers (MR-656). +- `ChangeRequest` field names `query_source` / `query_name` accepted as serde aliases (MR-656). +- `aliases:` block in `omnigraph.yaml` unchanged; both `read`/`change` and `query`/`mutate` accepted as `command:` values (MR-656). +- New envelope fields are additive; old clients ignoring them keep working. +- `Idempotency-Key`, `If-Match`, `X-Deadline` are opt-in headers; absence is the current behavior. + +Callers move at their own pace. The envelope upgrades + URL rename ship in v0.6.x (small PRs). Stored queries + MCP ship in v0.7.0. + +## Sequencing + +**Phase 1: envelope (v0.6.x, before MR-969).** Four small PRs, ~100-200 LOC each. + +1. Wrap responses in the structured envelope. Add `audit_id`, `snapshot_id`, `commit_id`, `stats`, `warnings`. Backward-compatible if we keep today's top-level fields and add new ones alongside; cleaner break if we move to nested `result.*`. Pick one and live with it. +2. Honor `Idempotency-Key` on `/mutate` (and the deprecated `/change`). Server-side cache keyed by `(token, key)`. +3. Honor `If-Match` on `/mutate`. Wire through to the publisher CAS layer. +4. Honor `X-Deadline` / `X-Timeout-Ms` on every endpoint. Return 504-typed error past deadline. + +**Phase 2: MR-969 PR 1 (registry).** The stored-query registry, `/queries/{name}` route, `InvokeQuery` Cedar action with per-name scope, `.gq` pragma parsing (`@description`, `@returns`, `@mcp`), read-vs-mutate classification at registry load. Inline keeps working unchanged. + +**Phase 3: MR-969 PR 2 (MCP).** `/graphs/{id}/mcp/tools` and `/graphs/{id}/mcp/invoke`. Tool schemas projected from declared return types and parameter declarations. Single-graph-scoped tokens. + +**Phase 4: MR-969 PR 3 (Cedar deny-on-ad-hoc sugar).** Small Cedar-language addition so operators can lock down `/read` / `/query` while keeping `/queries/*` open. Independent of PRs 1-2. + +**Phase 5: deferred.** +- Cross-graph MCP namespacing (wait for usage signal). +- Per-query rate limits (extend `WorkloadController`). +- Schema introspection as a separate Cedar action (3-line PR). +- CLI verb consolidation (`omnigraph call `). +- Cache warming (HelixDB-style; not load-bearing). + +## Rejected Alternatives + +**Per-environment override files (`_overrides.yaml`).** Initial design had a sparse YAML file for per-env tweaks: MCP exposure, row caps, kill-switch, param locks. Rejected because every override candidate either belongs in source (`@mcp` flag), Cedar policy (per-actor visibility, per-env), or `omnigraph.yaml` (operator config). Splitting query metadata across files makes it harder to review what an agent can see. Keep source authoritative; let Cedar express the per-env differences. + +**Compiled query bundle (HelixDB's `queries.json`).** HelixDB compiles their Rust-DSL queries to JSON. Rejected because `.gq` files are already declarative. The file is the artifact. Reviewers diff source, not bytecode. + +**Stored-queries-only (HelixDB's posture).** Rejected because the personal-graph / dev-iteration use case dies without inline. Inline `-e` is the REPL for human exploration; stored is the contract for production agents. Both first-class. + +**Cross-graph tool-name prefixing (`prod.find_user`).** Rejected because graph renames would break agent contracts. Per-graph URLs let graph identity live in the URL, not in tool names. + +**Body-field graph dispatch (`{tool, graph, params}`).** Rejected because it doubles the contract surface (every tool is identified by two fields). Per-graph URLs are simpler. + +**Pragmas in YAML instead of source.** Rejected because two-file definitions (source + metadata YAML) make diffs harder to review and create drift opportunities. Source is the source of truth. + +**Pragmas as in-source comments (`#[mcp]` HelixDB-style).** Considered; chose `@mcp(...)` because comment-flavored pragmas conflate documentation and machine-readable metadata. The `@` prefix makes the pragma's role explicit. + +## Open Questions + +1. **Envelope breakage vs additive.** Phase 1.1 wraps responses in a structured envelope. Do we keep today's top-level fields *and* add new ones (additive, ugly), or move result to `result.*` (clean break, requires SDK updates)? Lean toward additive — let the new envelope coexist with the old shape until v0.7.0, then collapse. + +2. **`@returns` strictness.** Should mismatched declared-vs-inferred return type be a boot-time error or a warning? Lean toward error — silent drift defeats the assertion's purpose. Operators who want flexibility omit `@returns`. + +3. **MCP protocol transport.** Streamable HTTP (the new MCP standard) vs stdio (Anthropic's original). Both have Rust crates. Lean toward streamable HTTP since we're already an HTTP server. + +4. **Stored mutation routing.** A `.gq` file that contains both reads and writes — does the registry reject it at load (parse-time D2 rule from MR-656), or accept and classify as "mixed"? Lean toward reject. Mixed queries are a footgun; force operators to split. + +5. **`expect` field strictness.** `expect: "read_only"` against a parsed mutating query is an obvious 400. But `expect: {max_rows_scanned: 10000}` requires planner estimates that don't exist today. Either ship `expect` with only the "read_only" assertion in v1 and grow it, or wait for the planner. Lean toward shipping the partial form. + +6. **CLI `queries invoke` shape.** Today's `omnigraph query` takes a file or alias. `omnigraph queries invoke find_user` takes a stored query name. Should `omnigraph query --name find_user` also work (auto-detect)? Cleaner to keep them separate verbs — the stored vs inline distinction is part of the contract. + +## References + +- MR-656: [Support inline query strings in CLI and HTTP server](https://linear.app/modernrelay/issue/MR-656) +- MR-668: [Multi-graph server mode](https://linear.app/modernrelay/issue/MR-668) (shipped, PR #119) +- MR-969: [Stored queries with MCP exposure and per-query Cedar authorization](https://linear.app/modernrelay/issue/MR-969) +- PR #110: [feat: inline query strings in CLI and HTTP server](https://github.com/ModernRelay/omnigraph/pull/110) +- HelixDB docs: [docs.helix-db.com/llms-full.txt](https://docs.helix-db.com/llms-full.txt) — `#[mcp]` macro, scoped API keys, stored query model +- RFC 9745 (`Deprecation` header) +- RFC 8288 (`Link` relations, `successor-version`) +- MCP spec: [modelcontextprotocol.io](https://modelcontextprotocol.io) +- [invariants.md](./invariants.md) — substrate boundaries this work respects +- [../user/server.md](../user/server.md) — current HTTP surface (post-MR-656 picks up the `/query`+`/mutate` rename and deprecation) diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md index 978df0c..7984056 100644 --- a/docs/releases/v0.6.0.md +++ b/docs/releases/v0.6.0.md @@ -1,9 +1,10 @@ # Omnigraph v0.6.0 -Two pieces of work land in this release: +Three pieces of work land in this release: 1. The **graph terminology rename** (renamed `Repo` → `Graph` across the Cedar resource model, policy API, and query-lint schema source). 2. **Multi-graph server mode** — one `omnigraph-server` process can now serve 1–10 graphs concurrently behind cluster routes (`/graphs/{graph_id}/...`), with per-graph and server-level Cedar policy, read-only `GET /graphs` enumeration, and CLI parity (`omnigraph graphs list`). +3. **Inline + canonical-named queries and mutations.** New `POST /query` and `POST /mutate` endpoints pair with the CLI's new `-e/--query-string` flag for ad-hoc execution without a temp file. `POST /read` and `POST /change` continue serving indefinitely as deprecated aliases that carry RFC 9745 `Deprecation: true` and RFC 8288 `Link: ; rel="successor-version"` response headers, plus `deprecated: true` in `openapi.json`. Same canonicalization on the CLI: `omnigraph query`, `omnigraph mutate`, and top-level `omnigraph lint` / `omnigraph check` replace `omnigraph read`, `omnigraph change`, and the nested `omnigraph query lint` / `omnigraph query check`. Every deprecated spelling remains a `visible_alias` that warns to stderr once per invocation. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`, `omnigraph graphs create`) is **not** in v0.6.0. Operators add or remove graphs by editing `omnigraph.yaml` and restarting. The first cut of `POST /graphs` shipped behind an atomic-YAML-rewrite design that we pulled before release once its concurrency guarantees were challenged (flock-on-renamed-inode race, duplicate-check outside the critical section, and an init-cleanup path that could destroy an existing graph's schema on re-init). The correct fix is a Lance-style cluster catalog (reserve → init → publish with recovery sidecars); that work is deferred. @@ -31,6 +32,11 @@ Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`, `omnigraph graphs cre - **Tokens without policy default-deny non-read actions.** Existing authenticated deployments that relied on writes or admin routes without Cedar policy must add policy rules for those actions. - **`GET /graphs` requires `server.policy.file` in every runtime state.** Even `--unauthenticated` mode keeps server topology closed until the operator explicitly authorizes `graph_list`. +### Query / mutation rename + +- **`ChangeRequest` field rename**: `query_source` → `query`, `query_name` → `name`. Both legacy names continue to deserialize via `#[serde(alias = "...")]`, so existing clients sending the old JSON keys keep working. CLI remote calls against `/change` still emit the legacy keys verbatim through the `legacy_change_request_body` helper so a newer CLI talking to an older server keeps working byte-for-byte. +- **CLI `omnigraph query lint` / `omnigraph query check`** are now top-level — canonical name is **`omnigraph lint`**. The three deprecated invocations (`omnigraph query lint`, `omnigraph query check`, and bare `omnigraph check`) remain as argv-level shims that rewrite to `omnigraph lint` and print a one-line stderr deprecation warning. `check` is deliberately **not** a clap `visible_alias` on `lint` — two equivalent canonical names would split agent emissions between them depending on training-data drift, so the deprecation pattern (rewrite + warn) gives one unambiguous canonical name in `omnigraph --help`. + ## New - **Multi-graph mode**. Invoke with `omnigraph-server --config omnigraph.yaml` where the YAML has a non-empty `graphs:` map and no single-mode selector (no `server.graph`, no CLI `` or `--target`). At startup the server opens every configured graph in parallel (bounded concurrency, fail-fast). @@ -41,6 +47,11 @@ Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`, `omnigraph graphs cre - **Server-level Cedar policy**. `server.policy.file` in the config governs the `graph_list` action on `Omnigraph::Server::"root"`. Required to expose `GET /graphs` in every runtime state — without a server policy the default-deny posture rejects `graph_list`, including in `--unauthenticated` mode. - **Cedar action vocabulary**: `graph_list` (server-scoped). Runtime `graph_create` / `graph_delete` are reserved but not shipped — see "Deferred." - **Canonical graph URI identity.** Server startup normalizes graph root URIs before registry insertion and response output, so aliases such as `/tmp/g`, `/tmp/g/`, and `file:///tmp/g` cannot register as distinct graphs that actually share one Lance root. +- **`POST /query`** and **`POST /mutate`**. Canonical inline endpoints. `/query` rejects mutations with a typed 400 (the D2 rule lives at the URL — read-only contract enforced before execution); body uses the clean `{ query, name, params, branch, snapshot }` shape. `/mutate` accepts the same shape for mutations. Both available in single mode and per-graph multi mode (`/graphs/{id}/query`, `/graphs/{id}/mutate`). Internal call sites share two helpers (`run_query`, `run_mutate`) that take decoupled args, not request bodies — the seam MR-969's future stored-query handler plugs into. +- **CLI `omnigraph query` / `omnigraph mutate`** as top-level canonical subcommands. Pairs with new top-level **`omnigraph lint` (alias `check`)** so query validation no longer sits under `omnigraph query`. +- **CLI `-e, --query-string `** on both `omnigraph query` and `omnigraph mutate`. 3-way mutex with `--query ` and `--alias ` — exactly one is required. Empty string rejected. Suits ad-hoc exploration, REPL workflows, and agent tool-use without temp files. +- **Three-channel deprecation signal on `/read` and `/change`**: OpenAPI `deprecated: true` on the operation (every codegen flags the generated SDK method), RFC 9745 `Deprecation: true` response header, and RFC 8288 `Link: ; rel="successor-version"` (or ``) response header. Auto-discoverable; no SDK breakage. +- **`omnigraph.yaml` `aliases..command`** now accepts `query` and `mutate` as canonical values alongside the legacy `read` and `change`. The internal `AliasCommand` enum retains the legacy variant names so serialized configs stay byte-stable. ## Configuration @@ -79,6 +90,7 @@ graphs: - **Cluster routes are breaking for client SDKs targeting multi mode.** Generated clients from previous v0.5.0 OpenAPI specs will hit 404 on flat paths against a multi-mode server. Regenerate against the v0.6.0 `openapi.json`. - **Supported YAML policy authoring is unchanged.** The Cedar `Omnigraph::Graph` and `Omnigraph::Server` entities are internally generated by `compile_policy_source` — operator YAML only references actions and groups. - **Operators with unsupported raw Cedar policy files** should update `Omnigraph::Repo` resource references to `Omnigraph::Graph`. +- **Endpoint and CLI rename is cosmetic on the client side.** Existing callers on `/read`, `/change`, `omnigraph read`, `omnigraph change`, and `omnigraph query lint` keep working — they pick up the `Deprecation` + `Link` headers (or stderr deprecation warning on the CLI) so SDKs and proxies can surface the successor name automatically. New integrations should target the canonical names. ChangeRequest field names migrate at the caller's pace — both `query_source`/`query_name` and `query`/`name` accepted indefinitely. ## Migration: single → multi @@ -114,6 +126,8 @@ To add a new graph after rollout: stop the server, append a new `graphs.` en - Public docs, CLI help, examples, server docs, and test helpers now consistently use "graph" for the OmniGraph data artifact. - GitHub/source repository terminology remains spelled out as "repository" where needed. - New: `docs/user/cli.md` documents `omnigraph graphs list`; `docs/user/server.md` documents the multi-graph mode and the cluster route convention; `docs/user/policy.md` documents the per-graph vs server-scoped action distinction. +- New: `docs/user/server.md` documents `POST /query` / `POST /mutate` and the three-channel deprecation signal on `/read` / `/change`. `docs/user/cli.md` documents the `-e/--query-string` flag with examples. `docs/user/cli-reference.md` shows the canonical CLI verbs (`query`, `mutate`, `lint`, `check`) with legacy spellings as visible aliases. +- New: `docs/dev/rfc-001-queries-envelope-mcp.md` is the cross-cutting design doc for the inline / stored query work that started landing in this release. It sequences the v0.6.x patch series (request/response envelope hardening) and the v0.7.0 stored-query + MCP work. ## Test coverage diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md index 8be88e7..0326e64 100644 --- a/docs/user/cli-reference.md +++ b/docs/user/cli-reference.md @@ -11,15 +11,15 @@ A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` sc | `init` | `--schema ` → initialize a graph (also scaffolds `omnigraph.yaml` if missing) | | `load` | bulk load a branch (`--mode overwrite\|append\|merge`) | | `ingest` | branch-creating transactional load (`--from `) | -| `read` | run named query (params via `--params`, `--params-file`, or alias args) | -| `change` | run mutation query | +| `query` (alias: `read`) | run named read query; source via `--query `, `-e`/`--query-string `, or `--alias ` (exactly one). `read` is the deprecated previous name and prints a one-line warning to stderr | +| `mutate` (alias: `change`) | run mutation query; same `--query` / `-e` / `--alias` mutual-exclusion as `query`. `change` is the deprecated previous name and prints a one-line warning to stderr | | `snapshot` | print current snapshot (per-table version + row count) | | `export` | dump to JSONL on stdout (`--type T`, `--table K` filters) | | `branch create \| list \| delete \| merge` | branching ops | | `commit list \| show` | inspect commit graph | | `run list \| show \| publish \| abort` | transactional run ops | | `schema plan \| apply \| show (alias: get)` | migrations | -| `query lint \| check` | offline / graph-backed validation | +| `lint` (alias: `check`) | offline / graph-backed query validation. Replaces `query lint` / `query check`, which are kept as deprecated argv-level shims that print a one-line warning and rewrite to `omnigraph lint` | | `optimize` | non-destructive Lance compaction | | `cleanup --keep N --older-than 7d --confirm` | destructive version GC | | `embed` | offline JSONL embedding pipeline | @@ -49,7 +49,10 @@ auth: env_file: ./.env.omni aliases: : - command: read|change + # accepted values: `read` / `query` (read alias), `change` / `mutate` + # (write alias). `query` and `mutate` are recommended; `read` and + # `change` remain accepted forever for back-compat. + command: read|change|query|mutate query: name: args: [, …] @@ -60,7 +63,7 @@ policy: file: ./policy.yaml ``` -## Output formats (read command) +## Output formats (`query` command, alias: `read`) - `json` — pretty-printed object with metadata + rows - `jsonl` — one metadata line then one JSON object per row diff --git a/docs/user/cli.md b/docs/user/cli.md index 8147d0b..b6f2c09 100644 --- a/docs/user/cli.md +++ b/docs/user/cli.md @@ -6,10 +6,35 @@ omnigraph init --schema ./schema.pg ./graph.omni omnigraph load --data ./data.jsonl --mode overwrite ./graph.omni omnigraph snapshot ./graph.omni --branch main --json -omnigraph read --uri ./graph.omni --query ./queries.gq --name get_person --params '{"name":"Alice"}' -omnigraph change --uri ./graph.omni --query ./queries.gq --name insert_person --params '{"name":"Mina","age":28}' +omnigraph query --uri ./graph.omni --query ./queries.gq --name get_person --params '{"name":"Alice"}' +omnigraph mutate --uri ./graph.omni --query ./queries.gq --name insert_person --params '{"name":"Mina","age":28}' ``` +`omnigraph query` is the canonical read command (pairs with `POST /query`); +`omnigraph mutate` is the canonical write command (pairs with `POST /mutate`). +The previous names `omnigraph read` and `omnigraph change` keep working as +visible aliases — invocations emit a one-line deprecation warning to stderr +and otherwise behave identically. See [Deprecated names](#deprecated-names) +for the migration table. + +For ad-hoc reads and mutations (REPLs, AI agents, one-off scripts), pass the +GQ source inline with `-e` / `--query-string` instead of a file path: + +```bash +omnigraph query --uri ./graph.omni \ + -e 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }' \ + --params '{"name":"Alice"}' + +omnigraph mutate --uri ./graph.omni \ + -e 'query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }' \ + --params '{"name":"Inline","age":42}' +``` + +`-e` is mutually exclusive with `--query ` and `--alias `; exactly +one of the three must be provided. The inline source travels through the same +parser, lint, params binding, and commit machinery as a file-based query — +only the source loader changes. + ## Branching And Reviewable Data Flows ```bash @@ -34,7 +59,7 @@ omnigraph-server ./graph.omni --bind 127.0.0.1:8080 Read through the HTTP API: ```bash -omnigraph read \ +omnigraph query \ --target http://127.0.0.1:8080 \ --query ./queries.gq \ --name get_person \ @@ -68,8 +93,8 @@ omnigraph read --uri http://server.example.com/graphs/beta --query ./q.gq ... ## Runs, Policy, And Diagnostics ```bash -omnigraph query lint --query ./queries.gq --schema ./schema.pg --json -omnigraph query check --query ./queries.gq ./graph.omni --json +omnigraph lint --query ./queries.gq --schema ./schema.pg --json +omnigraph check --query ./queries.gq ./graph.omni --json omnigraph schema plan --schema ./next.pg ./graph.omni --json omnigraph schema apply --schema ./next.pg ./graph.omni --json @@ -119,3 +144,21 @@ The config file can also define: When policy is enabled, `schema apply` is authorized through the `schema_apply` action and is typically limited to admins on protected `main`. + +## Deprecated names + +The CLI was renamed to align with the HTTP server's canonical endpoint +names (`POST /query`, `POST /mutate`) and the `query` keyword in the GQ +language. The previous spellings keep working forever; invocations emit a +one-line warning to stderr and otherwise behave identically. + +| Old (deprecated) | New (canonical) | Migration | +|--------------------------|---------------------|----------------------------------------------------------| +| `omnigraph read` | `omnigraph query` | Same flags and behavior. `read` is a visible clap alias. | +| `omnigraph change` | `omnigraph mutate` | Same flags and behavior. `change` is a visible clap alias. | +| `omnigraph query lint` | `omnigraph lint` | Same flags. The argv-level shim rewrites `query lint` to `lint`. | +| `omnigraph query check` | `omnigraph check` | `check` is a visible alias of `omnigraph lint`. | + +The `command:` field in `aliases.` in `omnigraph.yaml` accepts both +`read` / `change` (legacy) and `query` / `mutate` (canonical); the two +spellings are interchangeable on the wire via serde aliases. diff --git a/docs/user/server.md b/docs/user/server.md index 1a1bdb8..6f55e16 100644 --- a/docs/user/server.md +++ b/docs/user/server.md @@ -29,9 +29,11 @@ Per-graph endpoints — same body shape across modes; URLs differ: | GET | `/healthz` | `/healthz` | none | — | `server_health` | | GET | `/openapi.json` | `/openapi.json` | none | — | `server_openapi` (strips security if auth disabled; in multi mode emits cluster paths with `cluster_` operation-id prefix) | | GET | `/snapshot?branch=` | `/graphs/{id}/snapshot?branch=` | bearer + `read` | snapshot of branch | `server_snapshot` | -| POST | `/read` | `/graphs/{id}/read` | bearer + `read` | run named query | `server_read` | +| POST | `/query` | `/graphs/{id}/query` | bearer + `read` | inline read query (canonical; clean field names `query`/`name`; mutations → 400) | `server_query` | +| POST | `/read` | `/graphs/{id}/read` | bearer + `read` | **deprecated** alias of `/query` (legacy field names `query_source`/`query_name`, byte-stable response; carries `Deprecation: true` + `Link: ; rel="successor-version"`) | `server_read` | | POST | `/export` | `/graphs/{id}/export` | bearer + `export` | NDJSON stream | `server_export` | -| POST | `/change` | `/graphs/{id}/change` | bearer + `change` | mutation | `server_change` | +| POST | `/mutate` | `/graphs/{id}/mutate` | bearer + `change` | mutation (canonical; `query`/`name`; accepts legacy `query_source`/`query_name` as serde aliases) | `server_mutate` | +| POST | `/change` | `/graphs/{id}/change` | bearer + `change` | **deprecated** alias of `/mutate` (carries `Deprecation: true` + `Link: ; rel="successor-version"`) | `server_change` | | GET | `/schema` | `/graphs/{id}/schema` | bearer + `read` | get current `.pg` source | `server_schema_get` | | POST | `/schema/apply` | `/graphs/{id}/schema/apply` | bearer + `schema_apply` (target=`main`) | migrate | `server_schema_apply` | | POST | `/ingest` | `/graphs/{id}/ingest` | bearer + `branch_create` (if new) + `change` | bulk load | `server_ingest` (32 MB body limit) | @@ -60,6 +62,52 @@ A future release may introduce a managed registry (Lance-backed, catalog-style: reserve → init → publish with recovery sidecars) and re-expose runtime mutation on top of it. +## Inline read queries (`POST /query`) + +`POST /query` is the read-only, agent-friendly twin of `POST /read`. The +request body uses clean field names that match the CLI `-e` flag and the GQ +`query` keyword: + +```json +{ + "query": "query find($n: String) { match { $p: Person { name: $n } } return { $p.name } }", + "name": "find", + "params": { "n": "Alice" }, + "branch": "main", + "snapshot": null +} +``` + +Response shape is identical to `/read` (`ReadOutput`). If the inline source +contains mutations (`insert` / `update` / `delete`), the request is rejected +with HTTP 400 and an error pointing the caller at `POST /mutate` — the +read-only contract is enforced at the URL. + +`POST /mutate` is the canonical mutation endpoint. It accepts the same clean +field names (`query`, `name`); the legacy field names `query_source` and +`query_name` continue to deserialize as serde aliases so existing clients keep +working without changes. + +## Deprecated names (`/read`, `/change`) + +`POST /read` and `POST /change` are kept for back-compat indefinitely — they +are byte-stable on the request side and otherwise behave identically to +`/query` / `/mutate`. They are flagged as deprecated through three independent +channels: + +- **OpenAPI**: the operations carry `deprecated: true` in `openapi.json`, so + every OpenAPI codegen (typescript-fetch, openapi-generator, oapi-codegen, + …) emits a `@deprecated` marker on the generated SDK method. +- **Response headers (RFC 9745)**: every response carries `Deprecation: true`. +- **Response headers (RFC 8288)**: every response carries a `Link` header + pointing at the canonical successor: + `Link: ; rel="successor-version"` for `/read`, and + `Link: ; rel="successor-version"` for `/change`. SDKs and HTTP + proxies can pick the successor up automatically. + +Migration is purely cosmetic on the client side — swap the URL path, leave +the request body and response handling alone. + ## Streaming Only `/export` streams (`application/x-ndjson`, MPSC channel + `Body::from_stream`). Everything else is buffered JSON. @@ -72,8 +120,8 @@ Uniform `ErrorOutput { error, code?, merge_conflicts[], manifest_conflict? }` wi caller's pre-write view of one table's manifest version was stale. `ManifestConflictOutput { table_key, expected, actual }` tells the client which table to refresh and retry. This is the conflict shape produced by -concurrent `/change` or `/ingest` calls landing the same `(table, branch)` -race. +concurrent `/mutate` (or its `/change` alias) or `/ingest` calls landing +the same `(table, branch)` race. HTTP status codes used: 200, 400, 401, 403, 404, 409, 429, 500. @@ -99,10 +147,11 @@ actors are unaffected. Cedar policy authorization runs **before** admission accounting so denied requests don't consume admission slots. -Today admission gates every mutating handler: `/change`, `/ingest`, -`/branches/{create,delete,merge}`, and `/schema/apply`. Read-only -endpoints (`/snapshot`, `/read`, `/export`, `/branches` GET, `/commits`, -`/schema` GET) are not admission-gated. +Today admission gates every mutating handler: `/mutate` (and its +deprecated alias `/change`), `/ingest`, `/branches/{create,delete,merge}`, +and `/schema/apply`. Read-only endpoints (`/snapshot`, `/query`, `/read`, +`/export`, `/branches` GET, `/commits`, `/schema` GET) are not +admission-gated. ## Body limits @@ -134,8 +183,9 @@ See [deployment.md](deployment.md) for token-source operational details. ## Not implemented (by design or "TBD") - CORS — not configured; add `tower_http::cors` if needed. -- Rate limiting — per-actor admission control gates `/change`, `/ingest`, - `/branches/{create,delete,merge}`, `/schema/apply` (see "Per-actor +- Rate limiting — per-actor admission control gates `/mutate` (alias + `/change`), `/ingest`, `/branches/{create,delete,merge}`, + `/schema/apply` (see "Per-actor admission control" above). No global rate limiter is configured; add `tower_http::limit` if a graph-wide cap is needed. - Pagination — none (commits/branches return everything; export streams). diff --git a/og-cheet-sheet.md b/og-cheet-sheet.md index 8ae6f5c..2cb4d76 100644 --- a/og-cheet-sheet.md +++ b/og-cheet-sheet.md @@ -5,23 +5,27 @@ Use an explicit schema file: ```bash -omnigraph query lint --query ./queries.gq --schema ./schema.pg --json -omnigraph query check --query ./queries.gq --schema ./schema.pg +omnigraph lint --query ./queries.gq --schema ./schema.pg --json +omnigraph check --query ./queries.gq --schema ./schema.pg ``` Use a local or `s3://` repo target: ```bash -omnigraph query lint --query ./queries.gq ./repo.omni --json -omnigraph query check --query ./queries.gq s3://bucket/repo +omnigraph lint --query ./queries.gq ./repo.omni --json +omnigraph check --query ./queries.gq s3://bucket/repo ``` Use `omnigraph.yaml` target resolution: ```bash -omnigraph query lint --query ./queries.gq --target local --config ./omnigraph.yaml +omnigraph lint --query ./queries.gq --target local --config ./omnigraph.yaml ``` +> The previous `omnigraph query lint` / `omnigraph query check` spellings +> are kept as deprecated argv shims that print a one-line warning to +> stderr and rewrite to the canonical `omnigraph lint` / `omnigraph check`. + ## What It Checks - parses every query in the file diff --git a/openapi.json b/openapi.json index 1e76360..d1fa337 100644 --- a/openapi.json +++ b/openapi.json @@ -312,8 +312,8 @@ "tags": [ "mutations" ], - "summary": "Apply a GQ mutation to a branch.", - "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 on merge conflict.", + "summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.", + "description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", "operationId": "change", "requestBody": { "content": { @@ -327,7 +327,7 @@ }, "responses": { "200": { - "description": "Mutation results", + "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -387,6 +387,7 @@ } } }, + "deprecated": true, "security": [ { "bearer_token": [] @@ -741,13 +742,167 @@ ] } }, + "/mutate": { + "post": { + "tags": [ + "mutations" + ], + "summary": "Apply a GQ mutation to a branch (canonical mutation endpoint).", + "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 on merge conflict.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.", + "operationId": "mutate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Mutation results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangeOutput" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "409": { + "description": "Merge conflict", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "429": { + "description": "Per-actor admission cap exceeded; honor `Retry-After` header", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/query": { + "post": { + "tags": [ + "queries" + ], + "summary": "Execute an inline read query (friendlier-named alternative to `POST /read`).", + "description": "Designed for ad-hoc exploration and AI-agent tool-use: short field\nnames (`query`, `name`) match the CLI `-e` flag and the GQ `query`\nkeyword. Mutations (`insert`/`update`/`delete`) are rejected with 400\n-- use `POST /mutate` (or its deprecated alias `POST /change`) for\nwrite queries. Otherwise behaves identically to `POST /read`: same\ntarget semantics (branch xor snapshot), same Cedar action (Read),\nsame response shape.", + "operationId": "query", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Query results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadOutput" + } + } + } + }, + "400": { + "description": "Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, "/read": { "post": { "tags": [ "queries" ], - "summary": "Execute a GQ read query.", - "description": "Runs the query in `query_source` against either a branch or a frozen\nsnapshot (mutually exclusive). When `query_source` defines multiple named\nqueries, pick one with `query_name`. `params` is a JSON object whose keys\nmatch the parameters declared by the query. Returns rows as a JSON array\nplus a `columns` list. Read-only.", + "summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.", + "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", "operationId": "read", "requestBody": { "content": { @@ -761,7 +916,7 @@ }, "responses": { "200": { - "description": "Query results", + "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -801,6 +956,7 @@ } } }, + "deprecated": true, "security": [ { "bearer_token": [] @@ -1160,7 +1316,7 @@ "ChangeRequest": { "type": "object", "required": [ - "query_source" + "query" ], "properties": { "branch": { @@ -1170,19 +1326,19 @@ ], "description": "Target branch. Defaults to `main`." }, - "params": { - "description": "JSON object whose keys match the mutation's declared parameters." - }, - "query_name": { + "name": { "type": [ "string", "null" ], - "description": "Name of the mutation to run when `query_source` declares multiple." + "description": "Name of the mutation to run when `query` declares multiple.\n\nAccepts the legacy field name `query_name` as a deserialization alias." }, - "query_source": { + "params": { + "description": "JSON object whose keys match the mutation's declared parameters." + }, + "query": { "type": "string", - "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `query_name`.", + "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `name`.\n\nAccepts the legacy field name `query_source` as a deserialization alias.", "example": "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}" } } @@ -1542,6 +1698,44 @@ } } }, + "QueryRequest": { + "type": "object", + "description": "Inline read-query request for `POST /query`.\n\nFriendlier-named alternative to [`ReadRequest`] for ad-hoc reads and\nAI-agent integration. Mutations are rejected with 400 — use `POST\n/mutate` (or its deprecated alias `POST /change`) for write queries.\nField names are deliberately short (`query`, `name`) to match the GQ\nkeyword and the CLI `-e` flag.", + "required": [ + "query" + ], + "properties": { + "branch": { + "type": [ + "string", + "null" + ], + "description": "Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`." + }, + "name": { + "type": [ + "string", + "null" + ], + "description": "Name of the query to run when `query` declares multiple. Optional when\nonly one query is declared." + }, + "params": { + "description": "JSON object whose keys match the query's declared parameters." + }, + "query": { + "type": "string", + "description": "GQ read-query source. May declare one or more named queries; pick one\nwith `name` when more than one is declared. Mutations\n(`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its\ndeprecated alias `POST /change`) instead.", + "example": "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" + }, + "snapshot": { + "type": [ + "string", + "null" + ], + "description": "Snapshot id to read from. Mutually exclusive with `branch`." + } + } + }, "ReadOutput": { "type": "object", "required": [