From a8abb8769f7a2e883918c0c1fb640605b3059059 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:05:47 +0100 Subject: [PATCH 1/3] Add timeout support --- cmd/execution/results.go | 61 ++++++++++++++++- cmd/execution/results_test.go | 120 ++++++++++++++++++++++++++++++++-- cmd/query/helpers.go | 8 ++- cmd/query/run.go | 9 ++- cmd/query/run_sql.go | 9 ++- 5 files changed, 192 insertions(+), 15 deletions(-) diff --git a/cmd/execution/results.go b/cmd/execution/results.go index a9735c3..564e16a 100644 --- a/cmd/execution/results.go +++ b/cmd/execution/results.go @@ -2,13 +2,18 @@ package execution import ( "fmt" + "time" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/cli/output" + "github.com/duneanalytics/duneapi-client-go/dune" "github.com/duneanalytics/duneapi-client-go/models" "github.com/spf13/cobra" ) +// PollInterval controls the polling interval when waiting for execution results. +var PollInterval = 2 * time.Second + func newResultsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "results ", @@ -19,6 +24,8 @@ func newResultsCmd() *cobra.Command { cmd.Flags().Int("limit", 0, "maximum number of rows to return (0 = all)") cmd.Flags().Int("offset", 0, "number of rows to skip") + cmd.Flags().Bool("no-wait", false, "fetch current state without waiting for completion") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") output.AddFormatFlag(cmd, "text") return cmd @@ -29,6 +36,7 @@ func runResults(cmd *cobra.Command, args []string) error { limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") + noWait, _ := cmd.Flags().GetBool("no-wait") if limit < 0 { return fmt.Errorf("limit must be non-negative, got %d", limit) @@ -46,11 +54,58 @@ func runResults(cmd *cobra.Command, args []string) error { } client := cmdutil.ClientFromCmd(cmd) - resp, err := client.QueryResultsV2(executionID, opts) - if err != nil { - return err + + if noWait { + resp, err := client.QueryResultsV2(executionID, opts) + if err != nil { + return err + } + return handleResultsResponse(cmd, executionID, resp) + } + + timeout, _ := cmd.Flags().GetInt("timeout") + intervalSec := int(PollInterval.Seconds()) + maxRetries := timeout + if intervalSec > 0 { + maxRetries = timeout / intervalSec + } + if maxRetries < 1 { + maxRetries = 1 + } + + return waitForResults(cmd, client, executionID, opts, PollInterval, maxRetries) +} + +func waitForResults( + cmd *cobra.Command, + client dune.DuneClient, + executionID string, + opts models.ResultOptions, + interval time.Duration, + maxRetries int, +) error { + for i := 0; i < maxRetries; i++ { + resp, err := client.QueryResultsV2(executionID, opts) + if err != nil { + return err + } + + switch resp.State { + case "QUERY_STATE_PENDING", "QUERY_STATE_EXECUTING": + // still running, wait and retry + default: + return handleResultsResponse(cmd, executionID, resp) + } + + if i < maxRetries-1 { + time.Sleep(interval) + } } + return fmt.Errorf("timed out waiting for execution %s to complete", executionID) +} + +func handleResultsResponse(cmd *cobra.Command, executionID string, resp *models.ResultsResponse) error { switch resp.State { case "QUERY_STATE_COMPLETED": return output.DisplayResults(cmd, resp) diff --git a/cmd/execution/results_test.go b/cmd/execution/results_test.go index 4145004..64bf672 100644 --- a/cmd/execution/results_test.go +++ b/cmd/execution/results_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/duneanalytics/cli/cmd/execution" "github.com/duneanalytics/duneapi-client-go/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -67,7 +68,7 @@ func TestResultsJSONOutput(t *testing.T) { assert.Equal(t, "QUERY_STATE_COMPLETED", got.State) } -func TestResultsPending(t *testing.T) { +func TestResultsPendingNoWait(t *testing.T) { mock := &mockClient{ queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { return &models.ResultsResponse{ @@ -77,7 +78,7 @@ func TestResultsPending(t *testing.T) { } root, buf := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC"}) require.NoError(t, root.Execute()) out := buf.String() @@ -85,7 +86,7 @@ func TestResultsPending(t *testing.T) { assert.Contains(t, out, "State: QUERY_STATE_PENDING") } -func TestResultsExecuting(t *testing.T) { +func TestResultsExecutingNoWait(t *testing.T) { mock := &mockClient{ queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { return &models.ResultsResponse{ @@ -95,7 +96,7 @@ func TestResultsExecuting(t *testing.T) { } root, buf := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC"}) require.NoError(t, root.Execute()) out := buf.String() @@ -179,3 +180,114 @@ func TestResultsMissingArgument(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "accepts 1 arg(s)") } + +func TestResultsWaitPollsUntilComplete(t *testing.T) { + execution.PollInterval = 0 + t.Cleanup(func() { + execution.PollInterval = 2 * time.Second + }) + + callCount := 0 + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + callCount++ + if callCount < 3 { + return &models.ResultsResponse{ + State: "QUERY_STATE_EXECUTING", + }, nil + } + return testResultsResponse, nil + }, + } + + root, buf := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "--timeout", "10", "01ABC"}) + require.NoError(t, root.Execute()) + + assert.Equal(t, 3, callCount) + out := buf.String() + assert.Contains(t, out, "block_number") + assert.Contains(t, out, "2 rows") +} + +func TestResultsWaitPollsUntilFailed(t *testing.T) { + execution.PollInterval = 0 + t.Cleanup(func() { + execution.PollInterval = 2 * time.Second + }) + + callCount := 0 + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + callCount++ + if callCount < 2 { + return &models.ResultsResponse{ + State: "QUERY_STATE_PENDING", + }, nil + } + return &models.ResultsResponse{ + State: "QUERY_STATE_FAILED", + Error: &models.ExecutionError{ + Message: "out of memory", + }, + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "--timeout", "10", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "out of memory") + assert.Equal(t, 2, callCount) +} + +func TestResultsWaitTimeout(t *testing.T) { + execution.PollInterval = 0 + t.Cleanup(func() { + execution.PollInterval = 2 * time.Second + }) + + callCount := 0 + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + callCount++ + return &models.ResultsResponse{ + State: "QUERY_STATE_EXECUTING", + }, nil + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "--timeout", "1", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out waiting for execution") + +} + +func TestResultsWaitAPIError(t *testing.T) { + execution.PollInterval = 0 + t.Cleanup(func() { + execution.PollInterval = 2 * time.Second + }) + + callCount := 0 + mock := &mockClient{ + queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { + callCount++ + if callCount < 2 { + return &models.ResultsResponse{ + State: "QUERY_STATE_PENDING", + }, nil + } + return nil, errors.New("api: rate limited") + }, + } + + root, _ := newTestRoot(mock) + root.SetArgs([]string{"execution", "results", "--timeout", "10", "01ABC"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "api: rate limited") +} diff --git a/cmd/query/helpers.go b/cmd/query/helpers.go index 55aa282..acd5f85 100644 --- a/cmd/query/helpers.go +++ b/cmd/query/helpers.go @@ -28,8 +28,12 @@ func parsePerformance(cmd *cobra.Command) (string, error) { return performance, nil } -func waitAndDisplay(cmd *cobra.Command, exec dune.Execution) error { - resp, err := exec.WaitGetResults(5*time.Second, 60) +func waitAndDisplay(cmd *cobra.Command, exec dune.Execution, timeout int) error { + maxRetries := timeout / 2 + if maxRetries < 1 { + maxRetries = 1 + } + resp, err := exec.WaitGetResults(2*time.Second, maxRetries) if err != nil { return err } diff --git a/cmd/query/run.go b/cmd/query/run.go index 017daa4..9939c34 100644 --- a/cmd/query/run.go +++ b/cmd/query/run.go @@ -22,6 +22,7 @@ func newRunCmd() *cobra.Command { cmd.Flags().String("performance", "medium", `performance tier: "medium" or "large"`) cmd.Flags().Int("limit", 0, "maximum number of rows to display (0 = all)") cmd.Flags().Bool("no-wait", false, "submit execution and exit without waiting for results") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") output.AddFormatFlag(cmd, "text") return cmd @@ -56,7 +57,9 @@ func runRun(cmd *cobra.Command, args []string) error { if noWait { return runNoWait(cmd, req) } - return runWait(cmd, req) + + timeout, _ := cmd.Flags().GetInt("timeout") + return runWait(cmd, req, timeout) } func runNoWait(cmd *cobra.Command, req models.ExecuteRequest) error { @@ -70,7 +73,7 @@ func runNoWait(cmd *cobra.Command, req models.ExecuteRequest) error { return displayExecuteResponse(cmd, resp) } -func runWait(cmd *cobra.Command, req models.ExecuteRequest) error { +func runWait(cmd *cobra.Command, req models.ExecuteRequest, timeout int) error { client := cmdutil.ClientFromCmd(cmd) exec, err := client.RunQuery(req) @@ -78,7 +81,7 @@ func runWait(cmd *cobra.Command, req models.ExecuteRequest) error { return err } - return waitAndDisplay(cmd, exec) + return waitAndDisplay(cmd, exec, timeout) } func parseParams(raw []string) (map[string]any, error) { diff --git a/cmd/query/run_sql.go b/cmd/query/run_sql.go index 90c8417..6fa93c4 100644 --- a/cmd/query/run_sql.go +++ b/cmd/query/run_sql.go @@ -21,6 +21,7 @@ func newRunSQLCmd() *cobra.Command { cmd.Flags().String("performance", "medium", `performance tier: "medium" or "large"`) cmd.Flags().Int("limit", 0, "maximum number of rows to display (0 = all)") cmd.Flags().Bool("no-wait", false, "submit execution and exit without waiting for results") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") output.AddFormatFlag(cmd, "text") return cmd @@ -52,7 +53,9 @@ func runRunSQL(cmd *cobra.Command, _ []string) error { if noWait { return runSQLNoWait(cmd, req) } - return runSQLWait(cmd, req) + + timeout, _ := cmd.Flags().GetInt("timeout") + return runSQLWait(cmd, req, timeout) } func runSQLNoWait(cmd *cobra.Command, req models.ExecuteSQLRequest) error { @@ -66,7 +69,7 @@ func runSQLNoWait(cmd *cobra.Command, req models.ExecuteSQLRequest) error { return displayExecuteResponse(cmd, resp) } -func runSQLWait(cmd *cobra.Command, req models.ExecuteSQLRequest) error { +func runSQLWait(cmd *cobra.Command, req models.ExecuteSQLRequest, timeout int) error { client := cmdutil.ClientFromCmd(cmd) exec, err := client.RunSQL(req) @@ -74,5 +77,5 @@ func runSQLWait(cmd *cobra.Command, req models.ExecuteSQLRequest) error { return err } - return waitAndDisplay(cmd, exec) + return waitAndDisplay(cmd, exec, timeout) } From ea255e4ca639060852e70c7055855f230e8f87e9 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:35:59 +0100 Subject: [PATCH 2/3] rich tool descriptions --- cli/root.go | 9 ++++++--- cmd/dataset/dataset.go | 2 +- cmd/dataset/search.go | 29 +++++++++++++++++------------ cmd/docs/docs.go | 2 +- cmd/docs/search.go | 10 ++++++---- cmd/execution/execution.go | 2 +- cmd/execution/results.go | 18 +++++++++++++----- cmd/query/archive.go | 5 ++++- cmd/query/create.go | 17 +++++++++++------ cmd/query/get.go | 4 +++- cmd/query/query.go | 2 +- cmd/query/run.go | 17 +++++++++++------ cmd/query/run_sql.go | 18 +++++++++++------- cmd/query/update.go | 16 ++++++++++------ cmd/usage/usage.go | 8 +++++--- 15 files changed, 101 insertions(+), 58 deletions(-) diff --git a/cli/root.go b/cli/root.go index 88aea1c..ff0dc95 100644 --- a/cli/root.go +++ b/cli/root.go @@ -24,9 +24,12 @@ var apiKeyFlag string var rootCmd = &cobra.Command{ Use: "dune", - Short: "Dune CLI — interact with the Dune Analytics API", - Long: "A command-line interface for interacting with the Dune Analytics API.\n" + - "Manage queries, execute them, and retrieve results.", + Short: "Dune CLI — query, explore, and manage blockchain data on Dune Analytics", + Long: "A command-line interface for the Dune Analytics platform.\n\n" + + "Discover datasets across the Dune catalog, execute SQL queries (DuneSQL dialect),\n" + + "retrieve execution results, and manage your saved queries — all from the terminal.\n\n" + + "Authenticate with an API key via --api-key, the DUNE_API_KEY environment variable,\n" + + "or by running `dune auth`.", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if cmd.Annotations["skipAuth"] == "true" { return nil diff --git a/cmd/dataset/dataset.go b/cmd/dataset/dataset.go index 157d0e6..d4aee38 100644 --- a/cmd/dataset/dataset.go +++ b/cmd/dataset/dataset.go @@ -6,7 +6,7 @@ import "github.com/spf13/cobra" func NewDatasetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "dataset", - Short: "Manage Dune datasets", + Short: "Discover and explore datasets across the Dune catalog", } cmd.AddCommand(newSearchCmd()) return cmd diff --git a/cmd/dataset/search.go b/cmd/dataset/search.go index 467e872..4cd61c5 100644 --- a/cmd/dataset/search.go +++ b/cmd/dataset/search.go @@ -13,21 +13,26 @@ import ( func newSearchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search", - Short: "Search for datasets across the Dune catalog", + Short: "Search for tables and datasets across the Dune catalog", + Long: "Natural-language table discovery across the Dune catalog. Use this command\n" + + "to find concrete table names for use in SQL queries.\n\n" + + "Filter by category (canonical for chain primitives, decoded for ABI-level\n" + + "events/calls, spell for curated datasets, community for user-contributed),\n" + + "by blockchain, schema, dataset type, or ownership scope.", RunE: runSearch, } - cmd.Flags().String("query", "", "search query text") - cmd.Flags().StringArray("categories", nil, "filter by category (canonical, decoded, spell, community)") - cmd.Flags().StringArray("blockchains", nil, "filter by blockchain") - cmd.Flags().StringArray("dataset-types", nil, "filter by dataset type") - cmd.Flags().StringArray("schemas", nil, "filter by schema") - cmd.Flags().String("owner-scope", "", "ownership filter (all, me, team)") - cmd.Flags().Bool("include-private", false, "include private datasets") - cmd.Flags().Bool("include-schema", false, "include column schema in results") - cmd.Flags().Bool("include-metadata", false, "include metadata in results") - cmd.Flags().Int32("limit", 20, "maximum number of results") - cmd.Flags().Int32("offset", 0, "pagination offset") + cmd.Flags().String("query", "", "natural-language search intent or entity hints (e.g. 'uniswap v3 swaps'); use '*' to browse without keyword bias") + cmd.Flags().StringArray("categories", nil, "filter by table family: canonical (chain primitives), decoded (ABI-level events/calls), spell (curated datasets), community (user-contributed)") + cmd.Flags().StringArray("blockchains", nil, "chain scope to reduce ambiguity and improve ranking (e.g. ethereum, solana)") + cmd.Flags().StringArray("dataset-types", nil, "fine-grained dataset type filter: dune_table, decoded_table, spell, uploaded_table, transformation_table, transformation_view") + cmd.Flags().StringArray("schemas", nil, "schema/namespace constraint for high precision (e.g. dex, uniswap_v3_ethereum)") + cmd.Flags().String("owner-scope", "", "ownership filter: all, me, or team; does NOT automatically include private datasets") + cmd.Flags().Bool("include-private", false, "widen results to include private datasets visible to the authenticated user/team alongside public ones") + cmd.Flags().Bool("include-schema", false, "include column-level schema (name, type, nullable) for every result; useful when preparing SQL") + cmd.Flags().Bool("include-metadata", false, "include category-specific metadata (page_rank_score, description, abi_type, contract_name, project_name, etc.)") + cmd.Flags().Int32("limit", 20, "number of results per page; use 5-15 for quick checks, 20-50 for deeper exploration") + cmd.Flags().Int32("offset", 0, "pagination offset; use previous response pagination info for next page") output.AddFormatFlag(cmd, "text") return cmd diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 3c27b56..f05d6db 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -6,7 +6,7 @@ import "github.com/spf13/cobra" func NewDocsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "docs", - Short: "Search and browse Dune documentation", + Short: "Search the Dune documentation for guides, API references, and examples", Annotations: map[string]string{"skipAuth": "true"}, } cmd.AddCommand(newSearchCmd()) diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 59c920d..4067f7b 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -14,15 +14,17 @@ const defaultMCPEndpoint = "https://docs.dune.com/mcp" func newSearchCmd() *cobra.Command { cmd := &cobra.Command{ Use: "search", - Short: "Search the Dune documentation", + Short: "Search the Dune documentation for guides, API references, and code examples", + Long: "Search across all Dune documentation pages including guides, API references,\n" + + "DuneSQL syntax, and code examples. Does not require authentication.", Annotations: map[string]string{"skipAuth": "true"}, RunE: runSearch, } - cmd.Flags().String("query", "", "search query text (required)") + cmd.Flags().String("query", "", "search query text, e.g. 'DuneSQL date functions' or 'API authentication' (required)") _ = cmd.MarkFlagRequired("query") - cmd.Flags().Bool("api-reference-only", false, "prioritize API reference pages") - cmd.Flags().Bool("code-only", false, "prioritize pages with code examples") + cmd.Flags().Bool("api-reference-only", false, "prioritize API reference pages over conceptual guides") + cmd.Flags().Bool("code-only", false, "prioritize pages with executable examples and code snippets") cmd.Flags().String("mcp-endpoint", defaultMCPEndpoint, "MCP server endpoint URL") _ = cmd.Flags().MarkHidden("mcp-endpoint") output.AddFormatFlag(cmd, "text") diff --git a/cmd/execution/execution.go b/cmd/execution/execution.go index 0f25dd2..8969ef5 100644 --- a/cmd/execution/execution.go +++ b/cmd/execution/execution.go @@ -6,7 +6,7 @@ import "github.com/spf13/cobra" func NewExecutionCmd() *cobra.Command { cmd := &cobra.Command{ Use: "execution", - Short: "Manage query executions", + Short: "Retrieve and inspect query execution results", } cmd.AddCommand(newResultsCmd()) return cmd diff --git a/cmd/execution/results.go b/cmd/execution/results.go index 564e16a..d0b43bd 100644 --- a/cmd/execution/results.go +++ b/cmd/execution/results.go @@ -17,15 +17,23 @@ var PollInterval = 2 * time.Second func newResultsCmd() *cobra.Command { cmd := &cobra.Command{ Use: "results ", - Short: "Fetch results of a query execution", + Short: "Get execution results for a query execution by execution ID", + Long: "Retrieve the results of a query execution. By default, waits for the execution\n" + + "to complete (up to the timeout) before returning results.\n\n" + + "Behavior:\n" + + " 1. Checks the current execution status\n" + + " 2. If still running: polls until complete or timeout is reached\n" + + " 3. If completed: returns the result data\n" + + " 4. If failed/cancelled: returns the error details\n\n" + + "Use --no-wait to return the current state immediately without polling.", Args: cobra.ExactArgs(1), RunE: runResults, } - cmd.Flags().Int("limit", 0, "maximum number of rows to return (0 = all)") - cmd.Flags().Int("offset", 0, "number of rows to skip") - cmd.Flags().Bool("no-wait", false, "fetch current state without waiting for completion") - cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") + cmd.Flags().Int("limit", 0, "maximum number of result rows to return (0 = all)") + cmd.Flags().Int("offset", 0, "number of rows to skip before returning results, used for pagination") + cmd.Flags().Bool("no-wait", false, "return the current execution state immediately without waiting for completion") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for the execution to complete before timing out") output.AddFormatFlag(cmd, "text") return cmd diff --git a/cmd/query/archive.go b/cmd/query/archive.go index 1cec696..ed61a0a 100644 --- a/cmd/query/archive.go +++ b/cmd/query/archive.go @@ -11,7 +11,10 @@ import ( func newArchiveCmd() *cobra.Command { cmd := &cobra.Command{ Use: "archive ", - Short: "Archive a saved query", + Short: "Archive a saved Dune query by ID", + Long: "Mark a Dune query as archived. Archived queries are hidden from the library\n" + + "but can still be retrieved by ID. You must own the query or have edit access\n" + + "via team membership.", Args: cobra.ExactArgs(1), RunE: runArchive, } diff --git a/cmd/query/create.go b/cmd/query/create.go index aedc90f..31a5ae4 100644 --- a/cmd/query/create.go +++ b/cmd/query/create.go @@ -12,15 +12,20 @@ import ( func newCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", - Short: "Create a new saved query", + Short: "Create a new Dune query and return the query ID", + Long: "Create a new SQL query on Dune. Returns the query ID on success.\n\n" + + "The query is written in DuneSQL dialect. If the query targets tables with\n" + + "known partition columns, include a WHERE filter on those columns\n" + + "(e.g. WHERE block_date >= CURRENT_DATE - INTERVAL '7' DAY) to enable\n" + + "partition pruning and reduce query cost.", RunE: runCreate, } - cmd.Flags().String("name", "", "query name (required)") - cmd.Flags().String("sql", "", "query SQL (required)") - cmd.Flags().String("description", "", "query description") - cmd.Flags().Bool("private", false, "make the query private") - cmd.Flags().Bool("temp", false, "create a temporary (unsaved) query") + cmd.Flags().String("name", "", "human-readable query title, max 600 characters (required)") + cmd.Flags().String("sql", "", "the SQL query text in DuneSQL dialect, max 500,000 characters (required)") + cmd.Flags().String("description", "", "short description of what the query does, max 1,000 characters") + cmd.Flags().Bool("private", false, "make the query private; may be forced by team privacy settings") + cmd.Flags().Bool("temp", false, "create a temporary query that won't appear in the dune.com library or be accessible when shared") _ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("sql") output.AddFormatFlag(cmd, "text") diff --git a/cmd/query/get.go b/cmd/query/get.go index ad0842b..6b780bc 100644 --- a/cmd/query/get.go +++ b/cmd/query/get.go @@ -12,7 +12,9 @@ import ( func newGetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "get ", - Short: "Get a saved query", + Short: "Fetch a saved Dune query by ID, including SQL and metadata", + Long: "Retrieve SQL, metadata, owner, privacy flags, tags, and execution state for\n" + + "an existing Dune query. Only queries visible to the authenticated user are returned.", Args: cobra.ExactArgs(1), RunE: runGet, } diff --git a/cmd/query/query.go b/cmd/query/query.go index 9d57599..bbdfe19 100644 --- a/cmd/query/query.go +++ b/cmd/query/query.go @@ -6,7 +6,7 @@ import "github.com/spf13/cobra" func NewQueryCmd() *cobra.Command { cmd := &cobra.Command{ Use: "query", - Short: "Manage Dune queries", + Short: "Create, retrieve, update, execute, and archive Dune queries", } cmd.AddCommand(newCreateCmd()) cmd.AddCommand(newGetCmd()) diff --git a/cmd/query/run.go b/cmd/query/run.go index 9939c34..19be0e7 100644 --- a/cmd/query/run.go +++ b/cmd/query/run.go @@ -13,16 +13,21 @@ import ( func newRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run ", - Short: "Execute a saved query and display results", + Short: "Execute a saved Dune query by its ID and display results", + Long: "Execute a saved Dune query by its numeric ID. By default, waits for the\n" + + "execution to complete and displays the result rows. Use --no-wait to submit\n" + + "the execution and exit immediately with just the execution ID.\n\n" + + "Credits are consumed based on actual compute resources used. Use --performance\n" + + "to select the engine size (medium or large).", Args: cobra.ExactArgs(1), RunE: runRun, } - cmd.Flags().StringArray("param", nil, "query parameter in key=value format (repeatable)") - cmd.Flags().String("performance", "medium", `performance tier: "medium" or "large"`) - cmd.Flags().Int("limit", 0, "maximum number of rows to display (0 = all)") - cmd.Flags().Bool("no-wait", false, "submit execution and exit without waiting for results") - cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") + cmd.Flags().StringArray("param", nil, "typed query parameter in key=value format (repeatable); numbers are stringified, datetimes use YYYY-MM-DD HH:mm:ss") + cmd.Flags().String("performance", "medium", `engine size for the execution: "medium" (default) or "large"; credits are consumed based on actual compute resources used`) + cmd.Flags().Int("limit", 0, "maximum number of result rows to return (0 = all)") + cmd.Flags().Bool("no-wait", false, "submit the execution and exit immediately, printing only the execution ID and state") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for the execution to complete before timing out") output.AddFormatFlag(cmd, "text") return cmd diff --git a/cmd/query/run_sql.go b/cmd/query/run_sql.go index 6fa93c4..ed18752 100644 --- a/cmd/query/run_sql.go +++ b/cmd/query/run_sql.go @@ -10,18 +10,22 @@ import ( func newRunSQLCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run-sql", - Short: "Execute raw SQL and display results", + Short: "Execute a raw DuneSQL query and display results", + Long: "Execute an inline SQL statement in DuneSQL dialect without saving it as a\n" + + "query on Dune. By default, waits for completion and displays result rows.\n\n" + + "Use --no-wait to submit the execution and exit immediately with just the\n" + + "execution ID. Credits are consumed based on actual compute resources used.", Args: cobra.NoArgs, RunE: runRunSQL, } - cmd.Flags().String("sql", "", "SQL query to execute (required)") + cmd.Flags().String("sql", "", "the SQL query text in DuneSQL dialect (required)") _ = cmd.MarkFlagRequired("sql") - cmd.Flags().StringArray("param", nil, "query parameter in key=value format (repeatable)") - cmd.Flags().String("performance", "medium", `performance tier: "medium" or "large"`) - cmd.Flags().Int("limit", 0, "maximum number of rows to display (0 = all)") - cmd.Flags().Bool("no-wait", false, "submit execution and exit without waiting for results") - cmd.Flags().Int("timeout", 300, "maximum seconds to wait for completion") + cmd.Flags().StringArray("param", nil, "typed query parameter in key=value format (repeatable); numbers are stringified, datetimes use YYYY-MM-DD HH:mm:ss") + cmd.Flags().String("performance", "medium", `engine size for the execution: "medium" (default) or "large"; credits are consumed based on actual compute resources used`) + cmd.Flags().Int("limit", 0, "maximum number of result rows to return (0 = all)") + cmd.Flags().Bool("no-wait", false, "submit the execution and exit immediately, printing only the execution ID and state") + cmd.Flags().Int("timeout", 300, "maximum seconds to wait for the execution to complete before timing out") output.AddFormatFlag(cmd, "text") return cmd diff --git a/cmd/query/update.go b/cmd/query/update.go index 9172870..3257fca 100644 --- a/cmd/query/update.go +++ b/cmd/query/update.go @@ -12,16 +12,20 @@ import ( func newUpdateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "update ", - Short: "Update an existing saved query", + Short: "Update an existing Dune query's SQL, title, description, privacy, or tags", + Long: "Modify a query you own or have edit access to via team membership.\n" + + "Only supply the flags you want to change; unchanged fields are preserved.\n\n" + + "The update uses optimistic locking — if someone else edited the query\n" + + "concurrently, you'll get a conflict error.", Args: cobra.ExactArgs(1), RunE: runUpdate, } - cmd.Flags().String("name", "", "query name") - cmd.Flags().String("sql", "", "query SQL") - cmd.Flags().String("description", "", "query description") - cmd.Flags().Bool("private", false, "make the query private") - cmd.Flags().StringSlice("tags", nil, "query tags (comma-separated)") + cmd.Flags().String("name", "", "new title for the query, max 600 characters") + cmd.Flags().String("sql", "", "new SQL content in DuneSQL dialect, max 500,000 characters") + cmd.Flags().String("description", "", "new description for the query, max 1,000 characters") + cmd.Flags().Bool("private", false, "set to true to make the query private, false to make public") + cmd.Flags().StringSlice("tags", nil, "new set of tags for the query (comma-separated); replaces all existing tags") output.AddFormatFlag(cmd, "text") return cmd diff --git a/cmd/usage/usage.go b/cmd/usage/usage.go index c219196..b061237 100644 --- a/cmd/usage/usage.go +++ b/cmd/usage/usage.go @@ -14,12 +14,14 @@ import ( func NewUsageCmd() *cobra.Command { cmd := &cobra.Command{ Use: "usage", - Short: "Show credit and resource usage for your Dune account", + Short: "Get credit and resource usage for the authenticated Dune account", + Long: "Show current-period credit usage, storage consumption, and billing period\n" + + "boundaries for the authenticated user or team. Optionally filter by date range.", RunE: runUsage, } - cmd.Flags().String("start-date", "", "filter start date (YYYY-MM-DD)") - cmd.Flags().String("end-date", "", "filter end date (YYYY-MM-DD)") + cmd.Flags().String("start-date", "", "filter usage from this date (YYYY-MM-DD format)") + cmd.Flags().String("end-date", "", "filter usage up to this date (YYYY-MM-DD format)") output.AddFormatFlag(cmd, "text") return cmd From a6749d052b783bfba5ca0d73611cfb82b8fc5712 Mon Sep 17 00:00:00 2001 From: Ivan Pusic <450140+ivpusic@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:41:20 +0100 Subject: [PATCH 3/3] use wait results from sdk --- cmd/execution/results.go | 41 ++++++++-------------------- cmd/execution/results_test.go | 50 +++++++++++++++-------------------- 2 files changed, 33 insertions(+), 58 deletions(-) diff --git a/cmd/execution/results.go b/cmd/execution/results.go index d0b43bd..95b4747 100644 --- a/cmd/execution/results.go +++ b/cmd/execution/results.go @@ -26,8 +26,8 @@ func newResultsCmd() *cobra.Command { " 3. If completed: returns the result data\n" + " 4. If failed/cancelled: returns the error details\n\n" + "Use --no-wait to return the current state immediately without polling.", - Args: cobra.ExactArgs(1), - RunE: runResults, + Args: cobra.ExactArgs(1), + RunE: runResults, } cmd.Flags().Int("limit", 0, "maximum number of result rows to return (0 = all)") @@ -81,36 +81,17 @@ func runResults(cmd *cobra.Command, args []string) error { maxRetries = 1 } - return waitForResults(cmd, client, executionID, opts, PollInterval, maxRetries) -} - -func waitForResults( - cmd *cobra.Command, - client dune.DuneClient, - executionID string, - opts models.ResultOptions, - interval time.Duration, - maxRetries int, -) error { - for i := 0; i < maxRetries; i++ { - resp, err := client.QueryResultsV2(executionID, opts) - if err != nil { - return err - } - - switch resp.State { - case "QUERY_STATE_PENDING", "QUERY_STATE_EXECUTING": - // still running, wait and retry - default: - return handleResultsResponse(cmd, executionID, resp) - } - - if i < maxRetries-1 { - time.Sleep(interval) - } + exec := dune.NewExecution(client, executionID) + if _, err := exec.WaitGetResults(PollInterval, maxRetries); err != nil { + return err } - return fmt.Errorf("timed out waiting for execution %s to complete", executionID) + // Fetch final results with any limit/offset options. + resp, err := client.QueryResultsV2(executionID, opts) + if err != nil { + return err + } + return handleResultsResponse(cmd, executionID, resp) } func handleResultsResponse(cmd *cobra.Command, executionID string, resp *models.ResultsResponse) error { diff --git a/cmd/execution/results_test.go b/cmd/execution/results_test.go index 64bf672..b774629 100644 --- a/cmd/execution/results_test.go +++ b/cmd/execution/results_test.go @@ -13,9 +13,9 @@ import ( ) var testResultsResponse = &models.ResultsResponse{ - QueryID: 4125432, - State: "QUERY_STATE_COMPLETED", - ExecutionEndedAt: ptrTime(time.Now()), + QueryID: 4125432, + State: "QUERY_STATE_COMPLETED", + ExecutionEndedAt: ptrTime(time.Now()), IsExecutionFinished: true, Result: models.Result{ Metadata: models.ResultMetadata{ @@ -40,7 +40,7 @@ func TestResultsSuccess(t *testing.T) { } root, buf := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABCDEFGHIJKLMNOPQRSTUV"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABCDEFGHIJKLMNOPQRSTUV"}) require.NoError(t, root.Execute()) out := buf.String() @@ -59,7 +59,7 @@ func TestResultsJSONOutput(t *testing.T) { } root, buf := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC", "-o", "json"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC", "-o", "json"}) require.NoError(t, root.Execute()) var got models.ResultsResponse @@ -117,7 +117,7 @@ func TestResultsFailed(t *testing.T) { } root, _ := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC"}) err := root.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "syntax error at line 1") @@ -135,7 +135,7 @@ func TestResultsCancelled(t *testing.T) { } root, _ := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC"}) err := root.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "cancelled") @@ -151,7 +151,7 @@ func TestResultsWithLimitAndOffset(t *testing.T) { } root, _ := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC", "--limit", "10", "--offset", "5"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC", "--limit", "10", "--offset", "5"}) require.NoError(t, root.Execute()) require.NotNil(t, capturedOpts.Page) @@ -167,7 +167,7 @@ func TestResultsAPIError(t *testing.T) { } root, _ := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "01ABC"}) + root.SetArgs([]string{"execution", "results", "--no-wait", "01ABC"}) err := root.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "api: connection refused") @@ -204,7 +204,8 @@ func TestResultsWaitPollsUntilComplete(t *testing.T) { root.SetArgs([]string{"execution", "results", "--timeout", "10", "01ABC"}) require.NoError(t, root.Execute()) - assert.Equal(t, 3, callCount) + // 3 calls from WaitGetResults (2 executing + 1 completed) + 1 final fetch + assert.Equal(t, 4, callCount) out := buf.String() assert.Contains(t, out, "block_number") assert.Contains(t, out, "2 rows") @@ -226,7 +227,8 @@ func TestResultsWaitPollsUntilFailed(t *testing.T) { }, nil } return &models.ResultsResponse{ - State: "QUERY_STATE_FAILED", + State: "QUERY_STATE_FAILED", + IsExecutionFinished: true, Error: &models.ExecutionError{ Message: "out of memory", }, @@ -239,22 +241,19 @@ func TestResultsWaitPollsUntilFailed(t *testing.T) { err := root.Execute() require.Error(t, err) assert.Contains(t, err.Error(), "out of memory") - assert.Equal(t, 2, callCount) + // 2 calls from WaitGetResults (1 pending + 1 failed) + 1 final fetch + assert.Equal(t, 3, callCount) } -func TestResultsWaitTimeout(t *testing.T) { +func TestResultsWaitRetriesExhausted(t *testing.T) { execution.PollInterval = 0 t.Cleanup(func() { execution.PollInterval = 2 * time.Second }) - callCount := 0 mock := &mockClient{ queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { - callCount++ - return &models.ResultsResponse{ - State: "QUERY_STATE_EXECUTING", - }, nil + return nil, errors.New("server unavailable") }, } @@ -262,8 +261,7 @@ func TestResultsWaitTimeout(t *testing.T) { root.SetArgs([]string{"execution", "results", "--timeout", "1", "01ABC"}) err := root.Execute() require.Error(t, err) - assert.Contains(t, err.Error(), "timed out waiting for execution") - + assert.Contains(t, err.Error(), "retries have been exhausted") } func TestResultsWaitAPIError(t *testing.T) { @@ -272,22 +270,18 @@ func TestResultsWaitAPIError(t *testing.T) { execution.PollInterval = 2 * time.Second }) - callCount := 0 mock := &mockClient{ queryResultsV2Fn: func(_ string, _ models.ResultOptions) (*models.ResultsResponse, error) { - callCount++ - if callCount < 2 { - return &models.ResultsResponse{ - State: "QUERY_STATE_PENDING", - }, nil - } return nil, errors.New("api: rate limited") }, } root, _ := newTestRoot(mock) - root.SetArgs([]string{"execution", "results", "--timeout", "10", "01ABC"}) + // With --timeout=2 and PollInterval=0, maxRetries=2. + // The SDK retries on API errors until errCount > maxRetries. + root.SetArgs([]string{"execution", "results", "--timeout", "2", "01ABC"}) err := root.Execute() require.Error(t, err) + assert.Contains(t, err.Error(), "retries have been exhausted") assert.Contains(t, err.Error(), "api: rate limited") }