feat(cli)!: query/mutate invoke stored queries by name; server kind-assert (RFC-011 D3) (#247)

omnigraph query <name> / mutate <name> invoke a stored query by name from the served catalog (served-only). The verb asserts kind via a new expect_mutation on POST /queries/{name} (400 on mismatch). -e/--query + --store is the ad-hoc lane; the positional selects within the source (replacing --name). The bare positional graph URI, --uri, and --name are removed from query/mutate.
This commit is contained in:
Andrew Altshuler 2026-06-15 16:52:58 +03:00 committed by GitHub
parent 1bc0ea6b51
commit 9ef5f90991
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 331 additions and 120 deletions

View file

@ -325,6 +325,13 @@ pub struct InvokeStoredQueryRequest {
/// mutation). Mutually exclusive with `branch`.
#[serde(default)]
pub snapshot: Option<String>,
/// The kind the caller expects (RFC-011 Decision 3): `Some(false)` for
/// `omnigraph query <name>`, `Some(true)` for `omnigraph mutate <name>`.
/// When set and it disagrees with the stored query's actual kind, the
/// server rejects the call (400) so the verb asserts the kind. `None`
/// (the default) skips the check — preserving older clients and aliases.
#[serde(default)]
pub expect_mutation: Option<bool>,
}
/// Response for `POST /queries/{name}`: the read envelope for a stored

View file

@ -92,20 +92,18 @@ pub(crate) enum Command {
/// when used. Pairs with `omnigraph mutate` on the write side.
#[command(visible_alias = "read")]
Query {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
/// Query name. With no `--query`/`-e`, the stored query to invoke from
/// the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Ad-hoc query file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>`.
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[arg(long)]
name: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long, conflicts_with = "snapshot")]
@ -124,20 +122,18 @@ pub(crate) enum Command {
/// warning when used. Pairs with `omnigraph query` on the read side.
#[command(visible_alias = "change")]
Mutate {
/// Graph URI
#[arg(long)]
uri: Option<String>,
#[arg(hide = true)]
legacy_uri: Option<String>,
/// Query name. With no `--query`/`-e`, the stored mutation to invoke
/// from the catalog (served — addressed via --server/--profile). With
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
name: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
/// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
#[arg(long, conflicts_with = "query_string")]
query: Option<PathBuf>,
/// Inline GQ source — alternative to `--query <path>`.
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
query_string: Option<String>,
#[arg(long)]
name: Option<String>,
#[command(flatten)]
params: ParamsArgs,
#[arg(long)]

View file

@ -29,7 +29,8 @@ use omnigraph::db::{Omnigraph, ReadTarget};
use omnigraph_api_types::{
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
InvokeStoredQueryRequest, ReadOutput,
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
ingest_output, read_output, schema_apply_output, snapshot_payload,
};
@ -563,6 +564,50 @@ impl GraphClient {
}
}
/// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
/// Served-only: the catalog is server-owned, so a `--store` (embedded)
/// scope has nothing to resolve the name against. `expect_mutation` carries
/// the verb's asserted kind; the server rejects a mismatch (400) before
/// running, so the response is exactly the expected envelope — the caller
/// deserializes it as the concrete `T` (`ReadOutput` for `query`,
/// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
pub(crate) async fn invoke_named<T: serde::de::DeserializeOwned>(
&self,
name: &str,
expect_mutation: bool,
params_json: Option<&Value>,
branch: Option<String>,
snapshot: Option<String>,
) -> Result<T> {
match self {
GraphClient::Remote {
http,
base_url,
token,
} => {
let body = InvokeStoredQueryRequest {
params: params_json.cloned(),
branch,
snapshot,
expect_mutation: Some(expect_mutation),
};
remote_json(
http,
Method::POST,
remote_url(base_url, &["queries", name], &[])?,
Some(serde_json::to_value(body)?),
token.as_deref(),
)
.await
}
GraphClient::Embedded { .. } => bail!(
"by-name invocation needs a server (the stored-query catalog is \
server-owned); use -e '<gq>' or --query <file> for an ad-hoc query \
against --store, or address a server with --server / --profile"
),
}
}
pub(crate) async fn branch_create_from(
&self,
from: &str,

View file

@ -578,75 +578,96 @@ async fn main() -> Result<()> {
.await?;
}
Command::Query {
uri,
legacy_uri,
name,
config,
query,
query_string,
name,
params,
branch,
snapshot,
format,
json,
} => {
if query.is_none() && query_string.is_none() {
bail!("provide a query: --query <file> or -e '<inline gq>'");
}
let config = load_cli_config(config.as_ref())?;
let client = client::GraphClient::resolve(
&config,
cli.server.as_deref(),
cli.graph.as_deref(),
uri.or(legacy_uri),
None,
cli.profile.as_deref(),
cli.store.as_deref(),
)
.await?;
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
let params_json = load_params_json(&params)?;
let target = resolve_read_target(&config, branch, snapshot, None)?;
let output = client
.query(target, &query_source, name.as_deref(), params_json.as_ref())
.await?;
let output: ReadOutput = if query.is_some() || query_string.is_some() {
// Ad-hoc lane: run the source; the positional `name` selects
// within it when it holds more than one query.
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
client
.query(target, &query_source, name.as_deref(), params_json.as_ref())
.await?
} else {
// Catalog lane (served-only): invoke the stored query by name.
let Some(name) = name else {
bail!(
"provide a query name to invoke from the catalog, or -e '<gq>' / \
--query <file> for an ad-hoc query"
);
};
let (branch, snapshot) = match &target {
ReadTarget::Branch(b) => (Some(b.clone()), None),
ReadTarget::Snapshot(s) => (None, Some(s.as_str().to_string())),
};
client
.invoke_named(&name, false, params_json.as_ref(), branch, snapshot)
.await?
};
let format = resolve_read_format(&config, format, json, None);
print_read_output(&output, format, &config)?;
}
Command::Mutate {
uri,
legacy_uri,
name,
config,
query,
query_string,
name,
params,
branch,
json,
} => {
if query.is_none() && query_string.is_none() {
bail!("provide a mutation query: --query <file> or -e '<inline gq>'");
}
let config = load_cli_config(config.as_ref())?;
let client = client::GraphClient::resolve_with_policy(
&config,
cli.server.as_deref(),
cli.graph.as_deref(),
uri.or(legacy_uri),
None,
cli.as_actor.as_deref(),
cli.profile.as_deref(),
cli.store.as_deref(),
)
.await?;
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
let params_json = load_params_json(&params)?;
let branch = resolve_branch(&config, branch, None, "main");
let output = client
.mutate(&branch, &query_source, name.as_deref(), params_json.as_ref())
.await?;
let output: ChangeOutput = if query.is_some() || query_string.is_some() {
// Ad-hoc lane: run the source; positional `name` selects within it.
let query_source =
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
client
.mutate(&branch, &query_source, name.as_deref(), params_json.as_ref())
.await?
} else {
// Catalog lane (served-only): invoke the stored mutation by name.
let Some(name) = name else {
bail!(
"provide a mutation name to invoke from the catalog, or -e '<gq>' / \
--query <file> for an ad-hoc mutation"
);
};
client
.invoke_named(&name, true, params_json.as_ref(), Some(branch), None)
.await?
};
if json {
print_json(&output)?;
} else {

View file

@ -200,15 +200,17 @@ fn wrong_address_guard_message_has_no_trailing_space() {
#[test]
fn graph_flag_on_a_positional_uri_errors() {
// RFC-011: `--graph` selects within a multi-graph scope (a server or
// cluster). A bare positional URI is already a single graph, so pairing it
// with `--graph` is a loud error, not a silently-dropped flag. (The guard
// lets `--graph` reach a data verb; the scope resolver is what rejects it.)
// cluster). An explicit `--store <uri>` is already a single graph, so
// pairing it with `--graph` is a loud error, not a silently-dropped flag.
// (The guard lets `--graph` reach a data verb; the scope resolver rejects
// it.)
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let output = output_failure(
cli()
.arg("query")
.arg("--store")
.arg(&graph)
.arg("--graph")
.arg("knowledge")
@ -218,7 +220,29 @@ fn graph_flag_on_a_positional_uri_errors() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("already a single graph"),
"expected --graph-on-positional-URI rejection; got: {stderr}"
"expected --graph-on-explicit-store rejection; got: {stderr}"
);
}
#[test]
fn query_by_name_against_a_store_needs_a_server() {
// RFC-011 D3: by-name (catalog) invocation is served-only — the catalog is
// server-owned, so a bare `--store` has nothing to resolve the name
// against. The ad-hoc lane (`-e`/`--query`) is the local alternative.
let temp = tempdir().unwrap();
let graph = graph_path(temp.path());
init_graph(&graph);
let output = output_failure(
cli()
.arg("query")
.arg("find_people")
.arg("--store")
.arg(&graph),
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("needs a server"),
"expected a served-only by-name error; got: {stderr}"
);
}
@ -835,10 +859,10 @@ fn read_json_outputs_rows_for_named_query() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -867,7 +891,6 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
let output = output_success(
cmd.arg("--query")
.arg(&queries)
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -876,8 +899,8 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
serde_json::from_slice(&output.stdout).unwrap()
};
// Baseline: positional URI.
let baseline = read_rows(cli().arg("query").arg(&graph));
// Baseline: --store names the graph.
let baseline = read_rows(cli().arg("query").arg("--store").arg(&graph));
assert_eq!(baseline["rows"][0]["p.name"], "Alice");
// --store names the same graph directly.
@ -1097,7 +1120,6 @@ fn read_can_resolve_uri_from_config() {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1117,10 +1139,10 @@ fn read_csv_format_outputs_header_and_row_values() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1154,10 +1176,10 @@ fn read_uses_operator_default_output_format() {
command
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#);
@ -1189,10 +1211,10 @@ fn read_jsonl_format_outputs_metadata_header_first() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -1224,6 +1246,7 @@ query insert_person($name: String, $age: I32) {
let output = output_success(
cli()
.arg("change")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&mutation_file)
@ -1240,10 +1263,10 @@ query insert_person($name: String, $age: I32) {
let verify = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
@ -1298,6 +1321,7 @@ fn read_requires_name_for_multi_query_files() {
let output = output_failure(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(fixture("test.gq")),
@ -1316,6 +1340,7 @@ fn read_supports_inline_query_string() {
let output = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("-e")
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
@ -1331,11 +1356,12 @@ fn read_supports_inline_query_string() {
#[test]
fn positional_http_uri_on_a_data_verb_is_rejected() {
// RFC-011: a positional/`--uri` http(s):// URL no longer dispatches to a
// remote server — that requires `--server <url>`.
// RFC-011: a `--store` http(s):// URL no longer dispatches to a remote
// server — that requires `--server <url>`.
let output = output_failure(
cli()
.arg("query")
.arg("--store")
.arg("http://127.0.0.1:1")
.arg("-e")
.arg("query q() { match { $p: Person { } } return { $p } }"),
@ -1343,7 +1369,7 @@ fn positional_http_uri_on_a_data_verb_is_rejected() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("must be addressed with `--server <url>`"),
"expected positional-remote rejection; got: {stderr}"
"expected store-remote rejection; got: {stderr}"
);
}
@ -1381,6 +1407,7 @@ fn change_supports_inline_query_string() {
let output = output_success(
cli()
.arg("change")
.arg("--store")
.arg(&repo)
.arg("--query-string")
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
@ -1395,6 +1422,7 @@ fn change_supports_inline_query_string() {
let verify = output_success(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("-e")
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
@ -1416,6 +1444,7 @@ fn read_rejects_query_string_combined_with_query() {
let output = output_failure(
cli()
.arg("read")
.arg("--store")
.arg(&repo)
.arg("--query")
.arg(fixture("test.gq"))
@ -1436,7 +1465,7 @@ fn read_rejects_empty_query_string() {
init_graph(&repo);
load_fixture(&repo);
let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg(""));
let output = output_failure(cli().arg("read").arg("--store").arg(&repo).arg("-e").arg(""));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("must not be empty"),

View file

@ -83,7 +83,6 @@ fn parity_query() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--params",
r#"{"name":"Alice"}"#,
@ -232,7 +231,6 @@ fn parity_errors_share_exit_codes() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"no_such_query",
"--json",
],
@ -252,7 +250,6 @@ fn parity_errors_share_exit_codes() {
"query",
"--query",
query.to_str().unwrap(),
"--name",
"get_person",
"--json",
],

View file

@ -231,10 +231,10 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
let read_before = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -246,6 +246,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
let change_payload = parse_stdout_json(&output_success(
cli()
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
@ -259,10 +260,10 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
let read_after = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
@ -277,6 +278,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
let inline_change = parse_stdout_json(&output_success(
cli()
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("-e")
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
@ -291,6 +293,7 @@ fn local_cli_end_to_end_init_load_read_change_read_flow() {
let inline_read = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query-string")
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
@ -322,6 +325,7 @@ fn local_cli_end_to_end_branch_change_merge_flow() {
let change_payload = parse_stdout_json(&output_success(
cli()
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
@ -337,10 +341,10 @@ fn local_cli_end_to_end_branch_change_merge_flow() {
let feature_read = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature")
@ -365,10 +369,10 @@ fn local_cli_end_to_end_branch_change_merge_flow() {
let main_read = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Zoe"}"#)
@ -435,10 +439,10 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() {
let zoe = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature-ingest")
@ -452,10 +456,10 @@ fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() {
let bob = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature-ingest")
@ -629,10 +633,10 @@ fn local_cli_export_round_trips_full_branch_graph() {
let eve = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&imported_graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
@ -644,10 +648,10 @@ fn local_cli_export_round_trips_full_branch_graph() {
let friends = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&imported_graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("friends_of")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -717,7 +721,6 @@ policy: {{}}
.arg(&config)
.arg("--query")
.arg("test.gq")
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -779,6 +782,7 @@ fn local_cli_failed_change_keeps_target_state_unchanged() {
let output = output_failure(
cli()
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
@ -791,10 +795,10 @@ fn local_cli_failed_change_keeps_target_state_unchanged() {
let friends_payload = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("friends_of")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -865,7 +869,6 @@ query get_person($name: String) {
.arg(&config)
.arg("--query")
.arg("local.gq")
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -974,10 +977,10 @@ query get_task($slug: String) {
let filtered = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("due_with_tag")
.arg("--params")
.arg(r#"{"deadline":"2026-04-02T00:00:00Z","tag":"launch"}"#)
@ -999,10 +1002,10 @@ query get_task($slug: String) {
let insert_payload = parse_stdout_json(&output_success(
cli()
.arg("change")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("insert_task")
.arg("--params")
.arg(
@ -1015,10 +1018,10 @@ query get_task($slug: String) {
let update_payload = parse_stdout_json(&output_success(
cli()
.arg("change")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("update_task")
.arg("--params")
.arg(r#"{"slug":"gamma","due_at":"2026-04-04T10:45:00Z","tags":["embed","released"],"scores":[13,21],"active_days":["2026-04-04","2026-04-05"]}"#)
@ -1029,10 +1032,10 @@ query get_task($slug: String) {
let gamma = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("get_task")
.arg("--params")
.arg(r#"{"slug":"gamma"}"#)
@ -1112,10 +1115,10 @@ query vector_search($q: String) {
let result = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.arg("--name")
.arg("vector_search")
.arg("--params")
.arg(r#"{"q":"alpha"}"#)
@ -1265,10 +1268,10 @@ fn local_cli_change_enforces_engine_layer_policy() {
let verify = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"RagnorOnMain"}"#)
@ -1292,7 +1295,7 @@ fn local_cli_positional_uri_does_not_inherit_default_graph_policy() {
.arg("change")
.arg("--config")
.arg(&config)
.arg("--uri")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
@ -2343,7 +2346,6 @@ fn local_cli_keyed_credentials_authenticate_url_matched_server() {
.arg(&server.base_url)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -2514,6 +2516,45 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
.unwrap();
assert!(output.status.success(), "{output:?}");
// RFC-011 D3: invoke the STORED query by name (catalog lane, served-only).
// No `-e`/`--query` — the positional `find_person` is the catalog name.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("query")
.arg("find_person")
.arg("--server")
.arg("dev")
.arg("--graph")
.arg("local")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
.arg("--json")
.output()
.unwrap();
assert!(output.status.success(), "by-name catalog invocation: {output:?}");
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}");
// The verb asserts kind: `mutate <a-read>` is rejected by the server.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("mutate")
.arg("find_person")
.arg("--server")
.arg("dev")
.arg("--graph")
.arg("local")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
.output()
.unwrap();
assert!(!output.status.success(), "mutate on a read query must fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("'find_person' is a read — use omnigraph query find_person"),
"expected a kind-mismatch error; got: {stderr}"
);
// Unknown --server errors listing what IS defined.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
@ -2528,10 +2569,14 @@ fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("unknown server 'nope'") && stderr.contains("dev"), "{stderr}");
// --server is exclusive with a positional URI.
// --server is exclusive with --store (two ways to address the graph).
// (RFC-011 D3: there is no positional URI anymore — the positional is a
// query name — so the double-addressing contradiction now surfaces between
// the two scope primitives.)
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("query")
.arg("--store")
.arg(&server.base_url)
.arg("--server")
.arg("dev")

View file

@ -131,10 +131,10 @@ query insert_person($name: String, $age: I32) {
let local_read = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -147,7 +147,6 @@ query insert_person($name: String, $age: I32) {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -191,10 +190,10 @@ query insert_person($name: String, $age: I32) {
let local_verify = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Mina"}"#)
@ -394,7 +393,6 @@ query ordered_person($name: String) {
.arg(&config)
.arg("--query")
.arg(&ordered_query)
.arg("--name")
.arg("ordered_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -415,7 +413,6 @@ query ordered_person($name: String) {
.arg(&config)
.arg("--query")
.arg(&ordered_query)
.arg("--name")
.arg("ordered_person")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
@ -514,7 +511,6 @@ query insert_person($name: String, $age: I32) {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Zoe"}"#)
@ -604,7 +600,6 @@ query add_friend($from: String, $to: String) {
.arg(&config)
.arg("--query")
.arg(&mutation_file)
.arg("--name")
.arg("insert_person")
.arg("--branch")
.arg("feature")
@ -619,7 +614,6 @@ query add_friend($from: String, $to: String) {
.arg(&config)
.arg("--query")
.arg(&mutation_file)
.arg("--name")
.arg("add_friend")
.arg("--branch")
.arg("feature")
@ -686,10 +680,10 @@ query add_friend($from: String, $to: String) {
let eve = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&imported_graph)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"Eve"}"#)
@ -747,7 +741,6 @@ fn remote_ingest_creates_review_branch_and_keeps_it_readable() {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature-ingest")
@ -875,7 +868,6 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature-ingest")
@ -893,7 +885,6 @@ fn remote_ingest_reuses_existing_branch_and_merges_updates() {
.arg(&config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--branch")
.arg("feature-ingest")
@ -1020,7 +1011,6 @@ query insert_person($name: String, $age: I32) {
.arg(&client_config)
.arg("--query")
.arg(fixture("test.gq"))
.arg("--name")
.arg("get_person")
.arg("--params")
.arg(r#"{"name":"PolicyRemote"}"#)

View file

@ -980,6 +980,22 @@ pub(crate) async fn server_invoke_query(
let query_name = stored.name.clone();
let is_mutation = stored.is_mutation();
// RFC-011 D3: the CLI verb asserts the stored query's kind. `query <name>`
// sends `expect_mutation: false`, `mutate <name>` sends `true`; a mismatch
// is rejected here so the wrong verb errors instead of silently running.
if let Some(expected) = req.expect_mutation {
if expected != is_mutation {
let (actual, verb) = if is_mutation {
("mutation", "mutate")
} else {
("read", "query")
};
return Err(ApiError::bad_request(format!(
"'{query_name}' is a {actual} — use omnigraph {verb} {query_name}"
)));
}
}
info!(
graph = %handle.uri,
actor = ?actor_ref.map(|a| a.actor_id.as_ref()),

View file

@ -82,6 +82,58 @@ async fn invoke_stored_read_returns_rows() {
assert!(body["rows"].is_array(), "read envelope shape; body: {body}");
}
#[tokio::test(flavor = "multi_thread")]
async fn invoke_with_mismatched_expected_kind_is_rejected() {
// RFC-011 D3: the CLI verb asserts the stored query's kind via
// `expect_mutation`. Invoking a read with `expect_mutation: true`
// (i.e. `omnigraph mutate <a-read>`) is a 400 naming the right verb.
let (_temp, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, false)],
&[("act-invoke", "t-invoke")],
INVOKE_POLICY_YAML,
)
.await;
let (status, body) = json_response(
&app,
invoke_request(
"find_person",
"t-invoke",
json!({ "expect_mutation": true, "params": { "name": "Alice" } }),
),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("'find_person' is a read — use omnigraph query find_person"),
"expected a kind-mismatch error; body: {body}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn invoke_with_matching_expected_kind_runs() {
// The matching assertion (`omnigraph query <a-read>`) passes through.
let (_temp, app) = app_with_stored_queries(
&[("find_person", FIND_PERSON_GQ, false)],
&[("act-invoke", "t-invoke")],
INVOKE_POLICY_YAML,
)
.await;
let (status, body) = json_response(
&app,
invoke_request(
"find_person",
"t-invoke",
json!({ "expect_mutation": false, "params": { "name": "Alice" } }),
),
)
.await;
assert_eq!(status, StatusCode::OK, "matching kind should run; body: {body}");
assert_eq!(body["query_name"], "find_person");
}
#[tokio::test(flavor = "multi_thread")]
async fn invoke_stored_read_accepts_absent_or_empty_body() {
let no_param_query = "query list_people() { match { $p: Person } return { $p.name } }";

View file

@ -6,35 +6,43 @@
omnigraph init --schema schema.pg graph.omni
omnigraph load --data data.jsonl --mode overwrite graph.omni
omnigraph snapshot graph.omni --branch main --json
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}'
# Invoke a stored query BY NAME from the catalog (served — addressed by scope):
omnigraph query get_person --params '{"name":"Alice"}'
omnigraph mutate 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.
The positional argument is the **stored-query name**, invoked from the served
catalog (RFC-011 D3) — the graph is addressed by scope (`--server` / `--profile`
/ defaults), and the verb asserts the query's kind (`query` rejects a stored
mutation, and vice-versa). The previous names `omnigraph read` and
`omnigraph change` keep working as visible aliases — invocations emit a one-line
deprecation warning to stderr. See [Deprecated names](#deprecated-names).
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:
For **ad-hoc** reads and mutations (REPLs, AI agents, one-off scripts, local dev),
pass the GQ source with `-e` / `--query-string` (inline) or `--query <path>` (a
file), and address a graph's storage directly with `--store`. By-name catalog
invocation is served-only — a bare `--store` has no catalog, so it's the ad-hoc
lane:
```bash
omnigraph query --uri graph.omni \
omnigraph query --store 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 \
omnigraph mutate --store graph.omni \
-e 'query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }' \
--params '{"name":"Inline","age":42}'
# A multi-query file: the positional selects which query to run.
omnigraph query --store graph.omni --query queries.gq get_person --params '{"name":"Alice"}'
```
`-e` is mutually exclusive with `--query <path>`; exactly one of the two must be
provided. (Operator aliases moved to their own `omnigraph alias <name>`
subcommand — RFC-011 D4.) The inline source travels through the same
parser, lint, params binding, and commit machinery as a file-based query —
only the source loader changes.
`-e` is mutually exclusive with `--query <path>`. With either, the positional
name (optional) selects which query in the source to run. 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
@ -57,13 +65,11 @@ Serve a graph:
omnigraph-server graph.omni --bind 127.0.0.1:8080
```
Read through the HTTP API:
Read through the HTTP API — invoke a stored query by name from the catalog:
```bash
omnigraph query \
omnigraph query get_person \
--server http://127.0.0.1:8080 \
--query queries.gq \
--name get_person \
--params '{"name":"Alice"}'
```
@ -87,10 +93,10 @@ For config-driven clients, set the remote graph's `bearer_token_env` to an envir
Runtime add/remove is **not** in v0.6.0. To add a graph, stop the server, add a `graphs.<id>` entry to `omnigraph.yaml`, then restart. To remove, stop the server, delete the entry, restart.
Per-graph URLs: hit a graph's cluster route from any subcommand by pointing `--uri` at it:
Per-graph addressing: select a graph on a multi-graph server with `--graph`:
```bash
omnigraph read --uri http://server.example.com/graphs/beta --query q.gq ...
omnigraph query get_person --server http://server.example.com --graph beta --params '{"name":"Ada"}'
```
## Runs, Policy, And Diagnostics

View file

@ -2,7 +2,7 @@
A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` schema. For a quick-start guide, see [cli.md](index.md).
Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server <name|url>` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph <id>` for multi-graph servers; exclusive with a positional URI), `--store <uri>` (a single graph's storage directly), or `--profile <name>` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config <dir>`. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected.
Top-level command families and subcommands. Graph-targeting commands accept a positional `file://`/`s3://` URI, `--server <name|url>` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph <id>` for multi-graph servers; exclusive with a positional URI), `--store <uri>` (a single graph's storage directly), or `--profile <name>` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config <dir>`. A remote server is addressed only with `--server` — a positional `http(s)://` URI is rejected. **`query`/`mutate` are the exception**: their positional is a stored-query *name* (RFC-011 D3), not a graph URI, so they address the graph only via `--store`/`--server`/`--profile`/defaults.
## Top-level commands
@ -11,8 +11,8 @@ Top-level command families and subcommands. Graph-targeting commands accept a po
| `init` | `--schema <pg>` → initialize a graph (no longer scaffolds `omnigraph.yaml`; start cluster configs from the [cluster.md](../clusters/index.md) quick-start or `config migrate`) |
| `load` | bulk load a branch, local or remote (`--mode overwrite\|append\|merge` is **required** — overwrite is destructive, so there is no default). Without `--from` the target branch must exist; `--from <base>` forks a missing `--branch` from `<base>` first |
| `ingest` | deprecated alias of `load --from <base>` (defaults: `--from main --mode merge`); prints a one-line warning to stderr |
| `query` (alias: `read`) | run a read query; source via `--query <path>` or `-e`/`--query-string <GQ>`. `read` is the deprecated previous name and prints a one-line warning to stderr |
| `mutate` (alias: `change`) | run a mutation query; same `--query` / `-e` source as `query`. `change` is the deprecated previous name and prints a one-line warning to stderr |
| `query <name>` (alias: `read`) | run a read query. **Catalog lane** (default): `<name>` is a stored query invoked **by name** from the served catalog (served-only — address with `--server`/`--profile`; the verb asserts the query is a read). **Ad-hoc lane**: with `--query <path>` or `-e`/`--query-string <GQ>`, runs that source (the positional `<name>` then selects which query in it). No positional graph URI — address via `--store`/`--server`/`--profile`. `read` is the deprecated previous name (one-line stderr warning) |
| `mutate <name>` (alias: `change`) | run a mutation query; same catalog (by-name, served-only, verb asserts mutation) / ad-hoc (`--query`/`-e`) lanes as `query`. `change` is the deprecated previous name (one-line stderr warning) |
| `alias <name> [args]` | invoke an operator alias — a personal binding (under `aliases:` in `~/.omnigraph/config.yaml`) to a stored query on a named server (RFC-011 D4; replaces the removed `--alias` flag) |
| `snapshot` | print current snapshot (per-table version + row count) |
| `export` | dump to JSONL on stdout (`--type T`, `--table K` filters) |

View file

@ -1891,6 +1891,13 @@
],
"description": "Branch to run against. Defaults to `main`; for a stored mutation the\nwrite targets this branch."
},
"expect_mutation": {
"type": [
"boolean",
"null"
],
"description": "The kind the caller expects (RFC-011 Decision 3): `Some(false)` for\n`omnigraph query <name>`, `Some(true)` for `omnigraph mutate <name>`.\nWhen set and it disagrees with the stored query's actual kind, the\nserver rejects the call (400) so the verb asserts the kind. `None`\n(the default) skips the check — preserving older clients and aliases."
},
"params": {
"description": "JSON object whose keys match the stored query's declared parameters."
},