From 9ef5f9099184765958ccca0cf6a9feacad906d17 Mon Sep 17 00:00:00 2001 From: Andrew Altshuler Date: Mon, 15 Jun 2026 16:52:58 +0300 Subject: [PATCH] feat(cli)!: query/mutate invoke stored queries by name; server kind-assert (RFC-011 D3) (#247) omnigraph query / mutate 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. --- crates/omnigraph-api-types/src/lib.rs | 7 ++ crates/omnigraph-cli/src/cli.rs | 28 +++--- crates/omnigraph-cli/src/client.rs | 47 +++++++++- crates/omnigraph-cli/src/main.rs | 73 ++++++++++------ crates/omnigraph-cli/tests/cli_data.rs | 63 ++++++++++---- crates/omnigraph-cli/tests/parity_matrix.rs | 3 - crates/omnigraph-cli/tests/system_local.rs | 85 ++++++++++++++----- crates/omnigraph-cli/tests/system_remote.rs | 16 +--- crates/omnigraph-server/src/handlers.rs | 16 ++++ .../omnigraph-server/tests/stored_queries.rs | 52 ++++++++++++ docs/user/cli/index.md | 48 ++++++----- docs/user/cli/reference.md | 6 +- openapi.json | 7 ++ 13 files changed, 331 insertions(+), 120 deletions(-) diff --git a/crates/omnigraph-api-types/src/lib.rs b/crates/omnigraph-api-types/src/lib.rs index 910d86b..2814602 100644 --- a/crates/omnigraph-api-types/src/lib.rs +++ b/crates/omnigraph-api-types/src/lib.rs @@ -325,6 +325,13 @@ pub struct InvokeStoredQueryRequest { /// mutation). Mutually exclusive with `branch`. #[serde(default)] pub snapshot: Option, + /// The kind the caller expects (RFC-011 Decision 3): `Some(false)` for + /// `omnigraph query `, `Some(true)` for `omnigraph mutate `. + /// 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, } /// Response for `POST /queries/{name}`: the read envelope for a stored diff --git a/crates/omnigraph-cli/src/cli.rs b/crates/omnigraph-cli/src/cli.rs index 86d08f4..44d4c0c 100644 --- a/crates/omnigraph-cli/src/cli.rs +++ b/crates/omnigraph-cli/src/cli.rs @@ -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, - #[arg(hide = true)] - legacy_uri: Option, + /// 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, #[arg(long)] config: Option, + /// Ad-hoc query file (a `.gq` you're authoring / break-glass). #[arg(long, conflicts_with = "query_string")] query: Option, - /// Inline GQ source — alternative to `--query `. + /// Inline ad-hoc GQ source — alternative to `--query `. #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")] query_string: Option, - #[arg(long)] - name: Option, #[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, - #[arg(hide = true)] - legacy_uri: Option, + /// 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, #[arg(long)] config: Option, + /// Ad-hoc mutation file (a `.gq` you're authoring / break-glass). #[arg(long, conflicts_with = "query_string")] query: Option, - /// Inline GQ source — alternative to `--query `. + /// Inline ad-hoc GQ source — alternative to `--query `. #[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")] query_string: Option, - #[arg(long)] - name: Option, #[command(flatten)] params: ParamsArgs, #[arg(long)] diff --git a/crates/omnigraph-cli/src/client.rs b/crates/omnigraph-cli/src/client.rs index 41e01ff..653da42 100644 --- a/crates/omnigraph-cli/src/client.rs +++ b/crates/omnigraph-cli/src/client.rs @@ -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( + &self, + name: &str, + expect_mutation: bool, + params_json: Option<&Value>, + branch: Option, + snapshot: Option, + ) -> Result { + 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 '' or --query for an ad-hoc query \ + against --store, or address a server with --server / --profile" + ), + } + } + pub(crate) async fn branch_create_from( &self, from: &str, diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 074a1ee..45e24f7 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -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 or -e ''"); - } - 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(¶ms)?; 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 '' / \ + --query 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 or -e ''"); - } - 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(¶ms)?; 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 '' / \ + --query for an ad-hoc mutation" + ); + }; + client + .invoke_named(&name, true, params_json.as_ref(), Some(branch), None) + .await? + }; if json { print_json(&output)?; } else { diff --git a/crates/omnigraph-cli/tests/cli_data.rs b/crates/omnigraph-cli/tests/cli_data.rs index edb9c6d..ee7d5a9 100644 --- a/crates/omnigraph-cli/tests/cli_data.rs +++ b/crates/omnigraph-cli/tests/cli_data.rs @@ -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 ` 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 `. + // RFC-011: a `--store` http(s):// URL no longer dispatches to a remote + // server — that requires `--server `. 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 `"), - "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"), diff --git a/crates/omnigraph-cli/tests/parity_matrix.rs b/crates/omnigraph-cli/tests/parity_matrix.rs index 984cc71..dd8d225 100644 --- a/crates/omnigraph-cli/tests/parity_matrix.rs +++ b/crates/omnigraph-cli/tests/parity_matrix.rs @@ -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", ], diff --git a/crates/omnigraph-cli/tests/system_local.rs b/crates/omnigraph-cli/tests/system_local.rs index b357c74..5a9dd3a 100644 --- a/crates/omnigraph-cli/tests/system_local.rs +++ b/crates/omnigraph-cli/tests/system_local.rs @@ -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 ` 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") diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index 615e4e1..32ae6d7 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -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"}"#) diff --git a/crates/omnigraph-server/src/handlers.rs b/crates/omnigraph-server/src/handlers.rs index 94f4743..26fa33f 100644 --- a/crates/omnigraph-server/src/handlers.rs +++ b/crates/omnigraph-server/src/handlers.rs @@ -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 ` + // sends `expect_mutation: false`, `mutate ` 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()), diff --git a/crates/omnigraph-server/tests/stored_queries.rs b/crates/omnigraph-server/tests/stored_queries.rs index e4da1d3..b17cdd2 100644 --- a/crates/omnigraph-server/tests/stored_queries.rs +++ b/crates/omnigraph-server/tests/stored_queries.rs @@ -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 `) 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 `) 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 } }"; diff --git a/docs/user/cli/index.md b/docs/user/cli/index.md index 7eb50cf..f77a65e 100644 --- a/docs/user/cli/index.md +++ b/docs/user/cli/index.md @@ -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 ` (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 `; exactly one of the two must be -provided. (Operator aliases moved to their own `omnigraph alias ` -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 `. 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.` 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 diff --git a/docs/user/cli/reference.md b/docs/user/cli/reference.md index 9881315..5e60476 100644 --- a/docs/user/cli/reference.md +++ b/docs/user/cli/reference.md @@ -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 ` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph ` for multi-graph servers; exclusive with a positional URI), `--store ` (a single graph's storage directly), or `--profile ` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config `. 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 ` (an operator-defined server from `~/.omnigraph/config.yaml` by name, or a literal `http(s)://` URL, optionally with `--graph ` for multi-graph servers; exclusive with a positional URI), `--store ` (a single graph's storage directly), or `--profile ` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config `. 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 ` → 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 ` forks a missing `--branch` from `` first | | `ingest` | deprecated alias of `load --from ` (defaults: `--from main --mode merge`); prints a one-line warning to stderr | -| `query` (alias: `read`) | run a read query; source via `--query ` or `-e`/`--query-string `. `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 ` (alias: `read`) | run a read query. **Catalog lane** (default): `` 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 ` or `-e`/`--query-string `, runs that source (the positional `` 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 ` (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 [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) | diff --git a/openapi.json b/openapi.json index 4f0309f..ecbb3ad 100644 --- a/openapi.json +++ b/openapi.json @@ -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 `, `Some(true)` for `omnigraph mutate `.\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." },