diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index fcdab73224d..dce91bf9720 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2743,6 +2743,18 @@ export function ClerkIcon(props: SVGProps) { ) } +export function ClickHouseIcon(props: SVGProps) { + return ( + + + + + ) +} + export function MicrosoftIcon(props: SVGProps) { return ( @@ -3365,7 +3377,18 @@ export const OllamaIcon = (props: SVGProps) => ( xmlns='http://www.w3.org/2000/svg' > Ollama - + + +) +export const FalIcon = (props: SVGProps) => ( + + Fal + ) export function ShieldCheckIcon(props: SVGProps) { @@ -3982,16 +4005,16 @@ export function FireworksIcon(props: SVGProps) { return ( ) diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index d22e1caf00f..7b4a0e3a336 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -31,6 +31,7 @@ import { CirclebackIcon, ClayIcon, ClerkIcon, + ClickHouseIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -243,6 +244,7 @@ export const blockTypeToIconMap: Record = { circleback: CirclebackIcon, clay: ClayIcon, clerk: ClerkIcon, + clickhouse: ClickHouseIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/docs/content/docs/en/tools/clickhouse.mdx b/apps/docs/content/docs/en/tools/clickhouse.mdx new file mode 100644 index 00000000000..f3c9837525b --- /dev/null +++ b/apps/docs/content/docs/en/tools/clickhouse.mdx @@ -0,0 +1,559 @@ +--- +title: ClickHouse +description: Connect to a ClickHouse database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[ClickHouse](https://clickhouse.com) is an open-source, column-oriented database management system for online analytical processing (OLAP). It is built for speed at scale — running aggregations and analytical queries over billions of rows in real time. + +The ClickHouse block connects to any ClickHouse deployment (ClickHouse Cloud or self-hosted) over the [HTTP interface](https://clickhouse.com/docs/interfaces/http). Use it to run analytical queries, stream rows into tables, manage schemas, inspect system state, and execute arbitrary SQL — all from within a workflow. + +**Connection details** + +- **Host** — your ClickHouse hostname (e.g. `your-instance.clickhouse.cloud` or your server address). +- **Port** — the HTTP interface port. Use `8443` for HTTPS (ClickHouse Cloud) or `8123` for plain HTTP (self-hosted). +- **Database** / **Username** — default to `default` if not specified. +- **Password** — optional for unauthenticated local instances. +- **Use HTTPS** — keep enabled for any remote or Cloud instance. + +**Things to know** + +- `UPDATE` and `DELETE` are implemented as ClickHouse [mutations](https://clickhouse.com/docs/sql-reference/statements/alter/update) (`ALTER TABLE ... UPDATE/DELETE`). Mutations run **asynchronously** in the background, so the affected row count is not returned immediately. +- ClickHouse is optimized for bulk inserts. Prefer batching many rows per insert over many single-row inserts. +- The connection host is validated to block private/internal addresses, so the block cannot reach `localhost` or internal-only hosts. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface. + + + +## Tools + +### `clickhouse_query` + +Execute a SELECT query on a ClickHouse database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `query` | string | Yes | SQL SELECT query to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the query | +| `rowCount` | number | Number of rows returned | + +### `clickhouse_execute` + +Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `query` | string | Yes | Raw SQL statement to execute | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Array of rows returned from the statement | +| `rowCount` | number | Number of rows returned or affected | + +### `clickhouse_insert` + +Insert a row into a ClickHouse table + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to insert data into | +| `data` | object | Yes | Data object to insert \(key-value pairs mapping column names to values\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Inserted rows \(empty for ClickHouse inserts\) | +| `rowCount` | number | Number of rows inserted | + +### `clickhouse_insert_rows` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_update` + +Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to update data in | +| `data` | object | Yes | Data object with fields to update \(key-value pairs\) | +| `where` | string | Yes | WHERE clause condition \(without the WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Updated rows \(empty for ClickHouse mutations\) | +| `rowCount` | number | Number of rows written by the mutation | + +### `clickhouse_delete` + +Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | +| `table` | string | Yes | Table name to delete data from | +| `where` | string | Yes | WHERE clause condition \(without the WHERE keyword\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `rows` | array | Deleted rows \(empty for ClickHouse mutations\) | +| `rowCount` | number | Number of rows affected by the mutation | + +### `clickhouse_list_databases` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_tables` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_describe_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_show_create_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_count_rows` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_introspect` + +Introspect a ClickHouse database to retrieve table structures, columns, and engines + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | ClickHouse server hostname \(e.g., your-instance.clickhouse.cloud\) | +| `port` | number | Yes | ClickHouse HTTP interface port \(8443 for HTTPS, 8123 for HTTP\) | +| `database` | string | Yes | Database name to introspect | +| `username` | string | Yes | ClickHouse username | +| `password` | string | No | ClickHouse password | +| `secure` | boolean | No | Use a secure HTTPS connection \(default: true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `tables` | array | Array of table schemas with columns and engines | +| ↳ `name` | string | Table name | +| ↳ `database` | string | Database the table belongs to | +| ↳ `engine` | string | Table engine \(e.g., MergeTree, Log\) | +| ↳ `totalRows` | number | Approximate total number of rows in the table | +| ↳ `columns` | array | Table columns | +| ↳ `name` | string | Column name | +| ↳ `type` | string | ClickHouse data type \(e.g., UInt32, String, DateTime\) | +| ↳ `defaultKind` | string | Kind of default expression \(DEFAULT, MATERIALIZED, ALIAS\) | +| ↳ `defaultExpression` | string | Default value expression for the column | +| ↳ `isInPrimaryKey` | boolean | Whether the column is part of the primary key | +| ↳ `isInSortingKey` | boolean | Whether the column is part of the sorting key | + +### `clickhouse_create_database` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_database` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_create_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_truncate_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_rename_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_optimize_table` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_partitions` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_drop_partition` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_mutations` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_running_queries` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_kill_query` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_table_stats` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + +### `clickhouse_list_clusters` + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message describing the operation outcome | +| `rows` | array | Array of rows returned from the operation | +| `rowCount` | number | Number of rows returned or affected by the operation | +| `count` | number | Row count \(count rows operation\) | +| `ddl` | string | CREATE TABLE statement \(show create table operation\) | +| `tables` | array | Array of table schemas with columns and engines \(introspect operation\) | + + diff --git a/apps/docs/content/docs/en/tools/dagster.mdx b/apps/docs/content/docs/en/tools/dagster.mdx index b82c1a7f4ab..ef78e695e6e 100644 --- a/apps/docs/content/docs/en/tools/dagster.mdx +++ b/apps/docs/content/docs/en/tools/dagster.mdx @@ -73,8 +73,15 @@ Get the status and details of a Dagster run by its ID. | `runId` | string | Run ID | | `jobName` | string | Name of the job this run belongs to | | `status` | string | Run status \(QUEUED, NOT_STARTED, STARTING, MANAGED, STARTED, SUCCESS, FAILURE, CANCELING, CANCELED\) | +| `mode` | string | Execution mode of the run | | `startTime` | number | Run start time as Unix timestamp | | `endTime` | number | Run end time as Unix timestamp | +| `creationTime` | number | Time the run was created as Unix timestamp | +| `updateTime` | number | Time the run was last updated as Unix timestamp | +| `parentRunId` | string | ID of the immediate parent run \(for re-executions\) | +| `rootRunId` | string | ID of the root run in the re-execution group | +| `canTerminate` | boolean | Whether the run can currently be terminated | +| `assetSelection` | json | Asset keys targeted by the run, as slash-joined strings | | `runConfigYaml` | string | Run configuration as YAML | | `tags` | json | Run tags as array of \{key, value\} objects | @@ -108,7 +115,7 @@ Fetch execution event logs for a Dagster run. ### `dagster_list_runs` -List recent Dagster runs, optionally filtered by job name. +List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination. #### Input @@ -118,6 +125,9 @@ List recent Dagster runs, optionally filtered by job name. | `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | | `jobName` | string | No | Filter runs by job name \(optional\) | | `statuses` | string | No | Comma-separated run statuses to filter by, e.g. "SUCCESS,FAILURE" \(optional\) | +| `createdAfter` | number | No | Only return runs created at or after this Unix timestamp in seconds \(optional\) | +| `createdBefore` | number | No | Only return runs created at or before this Unix timestamp in seconds \(optional\) | +| `cursor` | string | No | Run ID to page after, from a previous response cursor \(optional\) | | `limit` | number | No | Maximum number of runs to return \(default 20\) | #### Output @@ -131,6 +141,8 @@ List recent Dagster runs, optionally filtered by job name. | ↳ `tags` | json | Run tags as array of \{key, value\} objects | | ↳ `startTime` | number | Start time as Unix timestamp | | ↳ `endTime` | number | End time as Unix timestamp | +| `cursor` | string | Run ID of the last returned run — pass as cursor to fetch the next page | +| `hasMore` | boolean | Whether more runs are likely available beyond this page | ### `dagster_list_jobs` @@ -295,7 +307,7 @@ List all sensors in a Dagster repository, optionally filtered by status. | --------- | ---- | ----------- | | `sensors` | json | Array of sensors \(name, sensorType, status, id, description\) | | ↳ `name` | string | Sensor name | -| ↳ `sensorType` | string | Sensor type \(ASSET, AUTO_MATERIALIZE, FRESHNESS_POLICY, MULTI_ASSET, RUN_STATUS, STANDARD\) | +| ↳ `sensorType` | string | Sensor type \(ASSET, AUTO_MATERIALIZE, FRESHNESS_POLICY, MULTI_ASSET, RUN_STATUS, STANDARD, UNKNOWN\) | | ↳ `status` | string | Sensor status: RUNNING or STOPPED | | ↳ `id` | string | Instigator state ID — use this to start or stop the sensor | | ↳ `description` | string | Human-readable sensor description | @@ -340,4 +352,120 @@ Disable (stop) a running sensor in Dagster. | `id` | string | Instigator state ID of the sensor | | `status` | string | Updated sensor status \(RUNNING or STOPPED\) | +### `dagster_list_assets` + +List assets tracked by a Dagster instance, optionally filtered by key prefix. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `prefix` | string | No | Slash-delimited asset key prefix to filter by, e.g. "raw" or "raw/events" \(optional\) | +| `cursor` | string | No | Asset key cursor from a previous response, for pagination \(optional\) | +| `limit` | number | No | Maximum number of assets to return \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assets` | json | Array of assets \(assetKey, path\) | +| ↳ `assetKey` | string | Slash-joined asset key | +| ↳ `path` | json | Asset key path segments | +| `cursor` | string | Cursor to pass on the next call to fetch more assets | +| `hasMore` | boolean | Whether more assets are likely available beyond this page | + +### `dagster_get_asset` + +Get an asset definition and its latest materialization by asset key. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key, e.g. "my_asset" or "raw/events" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `assetKey` | string | Slash-joined asset key | +| `path` | json | Asset key path segments | +| `groupName` | string | Asset group the definition belongs to | +| `description` | string | Asset description | +| `jobNames` | json | Names of jobs that can materialize this asset | +| `computeKind` | string | Compute kind tag \(e.g., python, dbt, spark\) | +| `isPartitioned` | boolean | Whether the asset is partitioned | +| `latestMaterialization` | json | Most recent materialization \(runId, timestamp, partition, stepKey\) | +| ↳ `runId` | string | Run that produced the materialization | +| ↳ `timestamp` | string | Materialization timestamp \(epoch ms string\) | +| ↳ `partition` | string | Partition key, if partitioned | +| ↳ `stepKey` | string | Step key that emitted it | + +### `dagster_materialize_assets` + +Materialize selected assets by launching their asset job with an asset selection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `repositoryLocationName` | string | Yes | Repository location \(code location\) name | +| `repositoryName` | string | Yes | Repository name within the code location | +| `jobName` | string | Yes | Asset job that contains the assets, e.g. "__ASSET_JOB" or a named asset job | +| `assetSelection` | string | Yes | Comma- or newline-separated asset keys to materialize, each slash-delimited \(e.g. "raw/events, summary"\) | +| `tags` | string | No | Tags as a JSON array of \{key, value\} objects \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `runId` | string | The globally unique ID of the launched materialization run | + +### `dagster_report_asset_materialization` + +Report an external (runless) materialization or observation for an asset. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key to report against, e.g. "my_asset" or "raw/events" | +| `eventType` | string | No | Event type to report: ASSET_MATERIALIZATION \(default\) or ASSET_OBSERVATION | +| `partitionKeys` | string | No | Comma-separated partition keys to report against \(optional\) | +| `description` | string | No | Human-readable description for the reported event \(optional\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the event was reported successfully | +| `assetKey` | string | Slash-joined asset key the event was reported against | + +### `dagster_wipe_asset` + +DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Dagster host URL \(e.g., https://myorg.dagster.cloud/prod or http://localhost:3001\) | +| `apiKey` | string | No | Dagster+ API token \(leave blank for OSS / self-hosted\) | +| `assetKey` | string | Yes | Slash-delimited asset key to wipe, e.g. "my_asset" or "raw/events" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the asset was wiped successfully | +| `assetKey` | string | Slash-joined asset key that was wiped | + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index a17c92d28e7..84dac39482d 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -27,6 +27,7 @@ "circleback", "clay", "clerk", + "clickhouse", "cloudflare", "cloudformation", "cloudwatch", diff --git a/apps/docs/content/docs/en/tools/tinybird.mdx b/apps/docs/content/docs/en/tools/tinybird.mdx index 0c3d74a9341..f67ac0c2ff0 100644 --- a/apps/docs/content/docs/en/tools/tinybird.mdx +++ b/apps/docs/content/docs/en/tools/tinybird.mdx @@ -1,6 +1,6 @@ --- title: Tinybird -description: Send events and query data with Tinybird +description: Send events, query data, and manage Data Sources with Tinybird --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -30,7 +30,7 @@ Connect Tinybird to your workflows today to accelerate data-driven features, aut ## Usage Instructions -Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources. +Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition. @@ -77,7 +77,110 @@ Execute SQL queries against Tinybird Pipes and Data Sources using the Query API. | Parameter | Type | Description | | --------- | ---- | ----------- | | `data` | json | Query result data. For FORMAT JSON: array of objects. For other formats \(CSV, TSV, etc.\): raw text string. | +| `meta` | array | Column metadata for the result set \(only available with FORMAT JSON\) | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Column data type | | `rows` | number | Number of rows returned \(only available with FORMAT JSON\) | +| `rows_before_limit_at_least` | number | Minimum number of rows there would be without a LIMIT clause \(only available with FORMAT JSON\) | | `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read \(only available with FORMAT JSON\) | +### `tinybird_query_pipe` + +Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `pipe` | string | Yes | Name of the published Pipe API Endpoint to call. Example: "top_pages" | +| `parameters` | json | No | Dynamic Pipe parameters as a JSON object, sent as query-string arguments. Example: \{"start_date": "2024-01-01", "limit": 10\} | +| `q` | string | No | Optional SQL to run on top of the Pipe result. Use "_" to reference the Pipe. Example: "SELECT count\(\) FROM _" | +| `token` | string | Yes | Tinybird API Token with PIPE:READ scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `data` | json | Pipe result data as an array of row objects | +| `meta` | array | Column metadata for the result set | +| ↳ `name` | string | Column name | +| ↳ `type` | string | Column data type | +| `rows` | number | Number of rows returned | +| `rows_before_limit_at_least` | number | Minimum number of rows there would be without a LIMIT clause | +| `statistics` | json | Query execution statistics - elapsed time, rows read, bytes read | +| ↳ `elapsed` | number | Query execution time in seconds | +| ↳ `rows_read` | number | Number of rows processed | +| ↳ `bytes_read` | number | Number of bytes processed | + +### `tinybird_append_datasource` + +Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the existing Data Source to append to. Example: "events_raw" | +| `url` | string | Yes | Publicly accessible URL of the file to append. Example: "https://example.com/data.csv" | +| `format` | string | No | Format of the source file: "csv" \(default\), "ndjson", or "parquet" | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Identifier of the append operation | +| `import_id` | string | Import identifier for the append job | +| `job_id` | string | Job identifier used to poll import status | +| `job_url` | string | URL to query the import job status | +| `status` | string | Initial job status \(e.g., "waiting"\) | +| `job` | json | Full import job details \(kind, id, status, created_at, datasource, ...\) | +| `datasource` | json | Target Data Source metadata \(id, name, ...\) | + +### `tinybird_truncate_datasource` + +Delete all rows from a Tinybird Data Source. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the Data Source to truncate. Example: "events_raw" | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `truncated` | boolean | Whether the Data Source was truncated successfully | +| `result` | json | Raw response body from the truncate endpoint, if any | + +### `tinybird_delete_datasource_rows` + +Delete rows from a Tinybird Data Source matching a SQL condition. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `base_url` | string | Yes | Tinybird API base URL \(e.g., https://api.tinybird.co\) | +| `datasource` | string | Yes | Name of the Data Source to delete rows from. Example: "events_raw" | +| `delete_condition` | string | Yes | SQL WHERE-clause condition selecting the rows to delete. Example: "country = \'ES\'" or "event_date < \'2024-01-01\'" | +| `dry_run` | boolean | No | When true, returns how many rows would be deleted without deleting them. Defaults to false. | +| `token` | string | Yes | Tinybird API Token with DATASOURCES:CREATE scope | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Identifier of the delete operation | +| `job_id` | string | Job identifier used to poll delete status | +| `delete_id` | string | Deletion identifier | +| `job_url` | string | URL to query the delete job status | +| `status` | string | Current job status \(e.g., "waiting", "done"\) | +| `job` | json | Full delete job details \(kind, id, status, delete_condition, rows_affected, ...\) | + diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ef0582c6f41..5f5ca00b33e 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -31,6 +31,7 @@ import { CirclebackIcon, ClayIcon, ClerkIcon, + ClickHouseIcon, CloudFormationIcon, CloudflareIcon, CloudWatchIcon, @@ -242,6 +243,7 @@ export const blockTypeToIconMap: Record = { circleback: CirclebackIcon, clay: ClayIcon, clerk: ClerkIcon, + clickhouse: ClickHouseIcon, cloudflare: CloudflareIcon, cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index f371f03ebe5..65df1c17364 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -2492,6 +2492,129 @@ "integrationTypes": ["security", "developer-tools"], "tags": ["identity", "automation"] }, + { + "type": "clickhouse", + "slug": "clickhouse", + "name": "ClickHouse", + "description": "Connect to a ClickHouse database", + "longDescription": "Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface.", + "bgColor": "#f9ff69", + "iconName": "ClickHouseIcon", + "docsUrl": "https://docs.sim.ai/tools/clickhouse", + "operations": [ + { + "name": "Query (SELECT)", + "description": "Execute a SELECT query on a ClickHouse database" + }, + { + "name": "Execute Raw SQL", + "description": "Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database" + }, + { + "name": "Insert Row", + "description": "Insert a row into a ClickHouse table" + }, + { + "name": "Insert Rows (Bulk)", + "description": "Insert multiple rows into a ClickHouse table" + }, + { + "name": "Update Data", + "description": "Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation" + }, + { + "name": "Delete Data", + "description": "Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation" + }, + { + "name": "List Databases", + "description": "List all databases on a ClickHouse server" + }, + { + "name": "List Tables", + "description": "List tables in the connected ClickHouse database" + }, + { + "name": "Describe Table", + "description": "Describe the columns of a ClickHouse table" + }, + { + "name": "Show Create Table", + "description": "Get the CREATE TABLE statement (DDL) for a ClickHouse table" + }, + { + "name": "Count Rows", + "description": "Count rows in a ClickHouse table, optionally filtered" + }, + { + "name": "Introspect Schema", + "description": "Introspect a ClickHouse database to retrieve table structures, columns, and engines" + }, + { + "name": "Create Database", + "description": "Create a new database on a ClickHouse server" + }, + { + "name": "Drop Database", + "description": "Drop a database from a ClickHouse server" + }, + { + "name": "Create Table", + "description": "Create a new MergeTree-family table in ClickHouse" + }, + { + "name": "Drop Table", + "description": "Drop a table from a ClickHouse database" + }, + { + "name": "Truncate Table", + "description": "Remove all rows from a ClickHouse table" + }, + { + "name": "Rename Table", + "description": "Rename a ClickHouse table" + }, + { + "name": "Optimize Table", + "description": "Trigger a merge of table parts via OPTIMIZE TABLE" + }, + { + "name": "List Partitions", + "description": "List active partitions for a ClickHouse table" + }, + { + "name": "Drop Partition", + "description": "Drop a partition from a ClickHouse table" + }, + { + "name": "List Mutations", + "description": "List mutations (async ALTER UPDATE/DELETE) for the connected database" + }, + { + "name": "List Running Queries", + "description": "List currently running queries on a ClickHouse server" + }, + { + "name": "Kill Query", + "description": "Kill a running query by its query ID" + }, + { + "name": "Table Stats", + "description": "Get row counts and on-disk size for tables in the connected database" + }, + { + "name": "List Clusters", + "description": "List configured clusters, shards, and replicas" + } + ], + "operationCount": 26, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["databases", "analytics"], + "tags": ["data-warehouse", "data-analytics"] + }, { "type": "cloudflare", "slug": "cloudflare", @@ -3110,7 +3233,7 @@ }, { "name": "List Runs", - "description": "List recent Dagster runs, optionally filtered by job name." + "description": "List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination." }, { "name": "List Jobs", @@ -3151,9 +3274,29 @@ { "name": "Stop Sensor", "description": "Disable (stop) a running sensor in Dagster." + }, + { + "name": "List Assets", + "description": "List assets tracked by a Dagster instance, optionally filtered by key prefix." + }, + { + "name": "Get Asset", + "description": "Get an asset definition and its latest materialization by asset key." + }, + { + "name": "Materialize Assets", + "description": "Materialize selected assets by launching their asset job with an asset selection." + }, + { + "name": "Report Asset Materialization", + "description": "Report an external (runless) materialization or observation for an asset." + }, + { + "name": "Wipe Asset", + "description": "DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone." } ], - "operationCount": 14, + "operationCount": 19, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -14219,11 +14362,11 @@ "type": "tinybird", "slug": "tinybird", "name": "Tinybird", - "description": "Send events and query data with Tinybird", - "longDescription": "Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.", + "description": "Send events, query data, and manage Data Sources with Tinybird", + "longDescription": "Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition.", "bgColor": "#2EF598", "iconName": "TinybirdIcon", - "docsUrl": "https://www.tinybird.co/docs/api-reference", + "docsUrl": "https://docs.sim.ai/tools/tinybird", "operations": [ { "name": "Send Events", @@ -14232,9 +14375,25 @@ { "name": "Query", "description": "Execute SQL queries against Tinybird Pipes and Data Sources using the Query API." + }, + { + "name": "Query Pipe Endpoint", + "description": "Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results." + }, + { + "name": "Append Data Source (from URL)", + "description": "Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet)." + }, + { + "name": "Truncate Data Source", + "description": "Delete all rows from a Tinybird Data Source." + }, + { + "name": "Delete Data Source Rows", + "description": "Delete rows from a Tinybird Data Source matching a SQL condition." } ], - "operationCount": 2, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "none", diff --git a/apps/sim/app/api/tools/clickhouse/count-rows/route.ts b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts new file mode 100644 index 00000000000..5b7b90821ca --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCountRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCountRows } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCountRowsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse count rows attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCountRowsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const count = await executeClickHouseCountRows(params, params.table, params.where) + + return NextResponse.json({ + message: `Table contains ${count} row(s).`, + count, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse count rows failed:`, error) + + return NextResponse.json( + { error: `ClickHouse count rows failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/create-database/route.ts b/apps/sim/app/api/tools/clickhouse/create-database/route.ts new file mode 100644 index 00000000000..b748a20595e --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/create-database/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCreateDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCreateDatabase } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCreateDatabaseAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse create database attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCreateDatabaseContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseCreateDatabase(params, params.name) + + return NextResponse.json({ + message: `Database '${params.name}' created.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse create database failed:`, error) + + return NextResponse.json( + { error: `ClickHouse create database failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/create-table/route.ts b/apps/sim/app/api/tools/clickhouse/create-table/route.ts new file mode 100644 index 00000000000..47cc3ff5f7f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/create-table/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseCreateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseCreateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse create table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseCreateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseCreateTable( + params, + params.table, + params.columns, + params.engine, + params.orderBy, + params.partitionBy + ) + + return NextResponse.json({ + message: `Table '${params.table}' created.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse create table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse create table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/delete/route.ts b/apps/sim/app/api/tools/clickhouse/delete/route.ts new file mode 100644 index 00000000000..f773aabba4a --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/delete/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDeleteContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDelete } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDeleteAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse delete attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDeleteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseDelete(params, params.table, params.where) + + logger.info(`[${requestId}] Delete mutation submitted, ${result.rowCount} row(s) affected`) + + return NextResponse.json({ + message: `Delete mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse delete failed:`, error) + + return NextResponse.json( + { error: `ClickHouse delete failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/describe-table/route.ts b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts new file mode 100644 index 00000000000..e258d781bc1 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDescribeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDescribeTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDescribeTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse describe table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDescribeTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseDescribeTable(params, params.table) + + return NextResponse.json({ + message: `Described table with ${result.rowCount} column(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse describe table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse describe table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-database/route.ts b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts new file mode 100644 index 00000000000..e06f897b337 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropDatabase } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropDatabaseAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop database attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropDatabaseContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropDatabase(params, params.name) + + return NextResponse.json({ + message: `Database '${params.name}' dropped.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop database failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop database failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts new file mode 100644 index 00000000000..790526586ba --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropPartitionContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropPartition } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropPartitionAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop partition attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropPartitionContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropPartition(params, params.table, params.partition) + + return NextResponse.json({ + message: `Dropped partition from table '${params.table}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop partition failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop partition failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/drop-table/route.ts b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts new file mode 100644 index 00000000000..1ae9f6832a8 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseDropTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseDropTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseDropTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse drop table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseDropTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseDropTable(params, params.table) + + return NextResponse.json({ + message: `Table '${params.table}' dropped.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse drop table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse drop table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/execute/route.ts b/apps/sim/app/api/tools/clickhouse/execute/route.ts new file mode 100644 index 00000000000..3e2c4baacf6 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/execute/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseExecuteContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseExecuteAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse execute attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseExecuteContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Executing ClickHouse statement on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseQuery(params, params.query) + + logger.info(`[${requestId}] Statement executed successfully, ${result.rowCount} row(s)`) + + return NextResponse.json({ + message: `Statement executed successfully. ${result.rowCount} row(s) returned or affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse execute failed:`, error) + + return NextResponse.json( + { error: `ClickHouse execute failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts new file mode 100644 index 00000000000..fb4f90b8634 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseInsertRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseInsertRows } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseInsertRowsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse insert rows attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseInsertRowsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseInsertRows(params, params.table, params.rows) + + return NextResponse.json({ + message: `Inserted ${result.rowCount} row(s) into '${params.table}'.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse insert rows failed:`, error) + + return NextResponse.json( + { error: `ClickHouse insert rows failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/insert/route.ts b/apps/sim/app/api/tools/clickhouse/insert/route.ts new file mode 100644 index 00000000000..a7cc4ed908f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/insert/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseInsertContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseInsert } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseInsertAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse insert attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseInsertContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseInsert(params, params.table, params.data) + + logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`) + + return NextResponse.json({ + message: `Data inserted successfully. ${result.rowCount} row(s) affected.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse insert failed:`, error) + + return NextResponse.json( + { error: `ClickHouse insert failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/introspect/route.ts b/apps/sim/app/api/tools/clickhouse/introspect/route.ts new file mode 100644 index 00000000000..cd3257c6275 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/introspect/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseIntrospectContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseIntrospect } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseIntrospectAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse introspect attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseIntrospectContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Introspecting ClickHouse schema on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseIntrospect(params) + + logger.info( + `[${requestId}] Introspection completed successfully, found ${result.tables.length} tables` + ) + + return NextResponse.json({ + message: `Schema introspection completed. Found ${result.tables.length} table(s) in database '${params.database}'.`, + tables: result.tables, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse introspection failed:`, error) + + return NextResponse.json( + { error: `ClickHouse introspection failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/kill-query/route.ts b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts new file mode 100644 index 00000000000..c46f6d1393c --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseKillQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseKillQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseKillQueryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse kill query attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseKillQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseKillQuery(params, params.queryId) + + return NextResponse.json({ + message: `Kill command executed for query '${params.queryId}'.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse kill query failed:`, error) + + return NextResponse.json( + { error: `ClickHouse kill query failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts new file mode 100644 index 00000000000..643c7be9621 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListClustersContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListClusters } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListClustersAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list clusters attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListClustersContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListClusters(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} cluster node(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list clusters failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list clusters failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-databases/route.ts b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts new file mode 100644 index 00000000000..c524b162474 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListDatabasesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListDatabases } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListDatabasesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list databases attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListDatabasesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListDatabases(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} database(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list databases failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list databases failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts new file mode 100644 index 00000000000..84034b42436 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListMutationsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListMutations } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListMutationsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list mutations attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListMutationsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListMutations(params, params.table, params.onlyRunning) + + return NextResponse.json({ + message: `Found ${result.rowCount} mutation(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list mutations failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list mutations failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts new file mode 100644 index 00000000000..d064850ad1f --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListPartitionsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListPartitions } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListPartitionsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list partitions attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListPartitionsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListPartitions(params, params.table) + + return NextResponse.json({ + message: `Found ${result.rowCount} partition(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list partitions failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list partitions failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts new file mode 100644 index 00000000000..d542966d5d0 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListRunningQueriesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListRunningQueries } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListRunningQueriesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list running queries attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListRunningQueriesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListRunningQueries(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} running query(ies).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list running queries failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list running queries failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/list-tables/route.ts b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts new file mode 100644 index 00000000000..4d9df7a2dc7 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseListTablesContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseListTables } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseListTablesAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse list tables attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseListTablesContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseListTables(params) + + return NextResponse.json({ + message: `Found ${result.rowCount} table(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse list tables failed:`, error) + + return NextResponse.json( + { error: `ClickHouse list tables failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts new file mode 100644 index 00000000000..3d22b8b3788 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseOptimizeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseOptimizeTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseOptimizeTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse optimize table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseOptimizeTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseOptimizeTable(params, params.table, params.final) + + return NextResponse.json({ + message: `Optimize submitted for table '${params.table}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse optimize table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse optimize table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/query/route.ts b/apps/sim/app/api/tools/clickhouse/query/route.ts new file mode 100644 index 00000000000..4d70b48b55b --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/query/route.ts @@ -0,0 +1,46 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseQueryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse query attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseQueryContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Executing ClickHouse query on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseQuery(params, params.query, { enforceReadOnly: true }) + + logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`) + + return NextResponse.json({ + message: `Query executed successfully. ${result.rowCount} row(s) returned.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse query failed:`, error) + + return NextResponse.json({ error: `ClickHouse query failed: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/rename-table/route.ts b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts new file mode 100644 index 00000000000..eec1f7ec436 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseRenameTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseRenameTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseRenameTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse rename table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseRenameTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseRenameTable(params, params.table, params.newTable) + + return NextResponse.json({ + message: `Renamed table '${params.table}' to '${params.newTable}'.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse rename table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse rename table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts new file mode 100644 index 00000000000..8c93d402803 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts @@ -0,0 +1,42 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseShowCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseShowCreateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseShowCreateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse show create table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseShowCreateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const ddl = await executeClickHouseShowCreateTable(params, params.table) + + return NextResponse.json({ + message: 'Retrieved CREATE statement.', + ddl, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse show create table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse show create table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/table-stats/route.ts b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts new file mode 100644 index 00000000000..405fbaf06cc --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseTableStatsContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseTableStats } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseTableStatsAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse table stats attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseTableStatsContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + const result = await executeClickHouseTableStats(params, params.table) + + return NextResponse.json({ + message: `Retrieved stats for ${result.rowCount} table(s).`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse table stats failed:`, error) + + return NextResponse.json( + { error: `ClickHouse table stats failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts new file mode 100644 index 00000000000..27452eb9849 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts @@ -0,0 +1,43 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseTruncateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseTruncateTable } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseTruncateTableAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse truncate table attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseTruncateTableContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + await executeClickHouseTruncateTable(params, params.table) + + return NextResponse.json({ + message: `Table '${params.table}' truncated.`, + rows: [], + rowCount: 0, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse truncate table failed:`, error) + + return NextResponse.json( + { error: `ClickHouse truncate table failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/update/route.ts b/apps/sim/app/api/tools/clickhouse/update/route.ts new file mode 100644 index 00000000000..9d43755da4c --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/update/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { clickhouseUpdateContract } from '@/lib/api/contracts/tools/databases/clickhouse' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { executeClickHouseUpdate } from '@/app/api/tools/clickhouse/utils' + +const logger = createLogger('ClickHouseUpdateAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + try { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized ClickHouse update attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseToolRequest(clickhouseUpdateContract, request, { logger }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info( + `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}` + ) + + const result = await executeClickHouseUpdate(params, params.table, params.data, params.where) + + logger.info(`[${requestId}] Update mutation submitted, ${result.rowCount} row(s) written`) + + return NextResponse.json({ + message: `Update mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) written.`, + rows: result.rows, + rowCount: result.rowCount, + }) + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] ClickHouse update failed:`, error) + + return NextResponse.json( + { error: `ClickHouse update failed: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/clickhouse/utils.ts b/apps/sim/app/api/tools/clickhouse/utils.ts new file mode 100644 index 00000000000..71853591048 --- /dev/null +++ b/apps/sim/app/api/tools/clickhouse/utils.ts @@ -0,0 +1,863 @@ +import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types' + +const REQUEST_TIMEOUT_MS = 30_000 + +interface ClickHouseSummary { + read_rows?: string + written_rows?: string + result_rows?: string +} + +interface ClickHouseHttpResult { + text: string + summary: ClickHouseSummary | null +} + +export interface ClickHouseRowsResult { + rows: unknown[] + rowCount: number +} + +interface ClickHouseColumnRow { + table: string + name: string + type: string + default_kind?: string + default_expression?: string + is_in_primary_key?: number | string + is_in_sorting_key?: number | string + position?: number | string +} + +interface ClickHouseTableRow { + name: string + engine?: string + total_rows?: number | string | null +} + +export interface ClickHouseIntrospectionResult { + tables: Array<{ + name: string + database: string + engine: string + totalRows?: number + columns: Array<{ + name: string + type: string + defaultKind?: string + defaultExpression?: string + isInPrimaryKey: boolean + isInSortingKey: boolean + }> + }> +} + +/** + * Sends a single statement to the ClickHouse HTTP interface and returns the raw + * response body alongside the parsed `X-ClickHouse-Summary` header. + * @see https://clickhouse.com/docs/interfaces/http + */ +async function clickhouseRequest( + config: ClickHouseConnectionConfig, + statement: string +): Promise { + const hostValidation = await validateDatabaseHost(config.host, 'host') + if (!hostValidation.isValid) { + throw new Error(hostValidation.error) + } + + const protocol = config.secure ? 'https' : 'http' + const url = new URL(`${protocol}://${config.host}:${config.port}/`) + url.searchParams.set('database', config.database) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + let response: Response + try { + response = await fetch(url.toString(), { + method: 'POST', + headers: { + 'X-ClickHouse-User': config.username, + 'X-ClickHouse-Key': config.password, + 'Content-Type': 'text/plain; charset=utf-8', + }, + body: statement, + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + } + + const text = await response.text() + + if (!response.ok) { + throw new Error(text.trim() || `ClickHouse request failed with status ${response.status}`) + } + + return { text, summary: parseSummary(response.headers.get('x-clickhouse-summary')) } +} + +function parseSummary(header: string | null): ClickHouseSummary | null { + if (!header) return null + try { + return JSON.parse(header) as ClickHouseSummary + } catch { + return null + } +} + +/** + * Parses a ClickHouse `FORMAT JSON` response body into rows, falling back to the + * summary header's row counts for statements that do not return a result set. + */ +function parseRowsResult(result: ClickHouseHttpResult): ClickHouseRowsResult { + const trimmed = result.text.trim() + if (trimmed) { + try { + const parsed = JSON.parse(trimmed) as { data?: unknown[]; rows?: number } + if (parsed && Array.isArray(parsed.data)) { + const rowCount = typeof parsed.rows === 'number' ? parsed.rows : parsed.data.length + return { rows: parsed.data, rowCount } + } + } catch { + // Body was not JSON (e.g. a non-SELECT statement); fall through to summary. + } + } + + const written = Number(result.summary?.written_rows ?? 0) + const read = Number(result.summary?.read_rows ?? 0) + return { rows: [], rowCount: written || read || 0 } +} + +/** Read-only statement leaders that return a result set and never mutate data. */ +const READ_ONLY_STATEMENT = /^(select|with|show|describe|desc|explain|exists)\b/i + +/** + * Normalizes the output format of a read statement to JSON so the HTTP response + * can always be parsed into rows. Strips every `FORMAT ` clause — wherever + * it sits relative to a trailing `SETTINGS` clause — and appends a single canonical + * `FORMAT JSON`. The `format()` function and `FORMAT`/format names appearing inside + * strings or comments are ignored (the scan runs on comment/string-masked SQL). + * Non-read statements are returned untouched (their own FORMAT, e.g. JSONEachRow + * for inserts, is preserved). + */ +function ensureJsonFormat(query: string): string { + const trimmed = query.trim().replace(/;+\s*$/, '') + if (!READ_ONLY_STATEMENT.test(trimmed)) { + return trimmed + } + const masked = maskSqlNoise(trimmed) + const formatClause = /\bformat\s+[a-z0-9_]+\b/gi + const spans: Array<[number, number]> = [] + for (let match = formatClause.exec(masked); match !== null; match = formatClause.exec(masked)) { + spans.push([match.index, match.index + match[0].length]) + } + let result = trimmed + for (let i = spans.length - 1; i >= 0; i--) { + result = result.slice(0, spans[i][0]) + result.slice(spans[i][1]) + } + return `${result.replace(/\s+$/, '')}\nFORMAT JSON` +} + +/** + * Replaces string literals ('...'), quoted identifiers ("..." / `...`), and SQL + * comments (`-- …` and `/* … *​/`) with spaces so that structural scans (e.g. for + * statement-chaining semicolons) only see actual SQL code, not data or comments. + */ +function maskSqlNoise(sql: string): string { + let out = '' + let i = 0 + while (i < sql.length) { + const ch = sql[i] + if (ch === "'" || ch === '"' || ch === '`') { + out += ' ' + i++ + while (i < sql.length && sql[i] !== ch) { + if (ch !== '`' && sql[i] === '\\') { + out += ' ' + i += 2 + continue + } + out += ' ' + i++ + } + if (i < sql.length) { + out += ' ' + i++ + } + continue + } + if (ch === '-' && sql[i + 1] === '-') { + const newline = sql.indexOf('\n', i + 2) + const end = newline === -1 ? sql.length : newline + out += ' '.repeat(end - i) + i = end + continue + } + if (ch === '/' && sql[i + 1] === '*') { + const close = sql.indexOf('*/', i + 2) + const end = close === -1 ? sql.length : close + 2 + out += ' '.repeat(end - i) + i = end + continue + } + out += ch + i++ + } + return out +} + +/** + * Detects whether a statement chains a second statement after a `;`, ignoring + * semicolons inside string literals, quoted identifiers, and comments. A trailing + * semicolon (with only whitespace/comments after it) is allowed. + */ +function hasChainedStatement(sql: string): boolean { + return /;\s*\S/.test(maskSqlNoise(sql)) +} + +/** + * Write/DDL statement shapes that must never run under the read-only query + * operation, even when wrapped by a leading `WITH` CTE (e.g. `WITH … INSERT INTO …`). + * Patterns require the keyword's statement context (e.g. `insert into`, `alter table`) + * so SQL functions/columns like `truncate(x)` or `created_at` are not false-positives. + */ +const MUTATING_STATEMENT = [ + /\binsert\s+into\b/i, + /\bdelete\s+from\b/i, + /\bupdate\s+[\w.`"]+\s+set\b/i, + /\balter\s+table\b/i, + /\b(?:create|attach)\s+(?:or\s+replace\s+)?(?:temporary\s+)?(?:table|database|dictionary|view|materialized\s+view|live\s+view|function|user|role)\b/i, + /\bdrop\s+(?:table|database|dictionary|view|column|partition|index|function|user|role)\b/i, + /\btruncate\s+table\b/i, + /\brename\s+(?:table|database|dictionary)\b/i, + /\bdetach\s+(?:table|database|dictionary|view|permanently)\b/i, + /\b(?:grant|revoke)\b/i, + /\boptimize\s+table\b/i, +] + +/** Whether a statement performs a write/DDL anywhere (comments and strings masked out). */ +function isMutatingStatement(sql: string): boolean { + const masked = maskSqlNoise(sql) + return MUTATING_STATEMENT.some((pattern) => pattern.test(masked)) +} + +/** + * Strips leading whitespace, `--`/`/* … *​/` comments, and opening parens from a + * statement so the read-only leader keyword can be detected even when a query + * starts with a comment (e.g. `-- note\nSELECT …`) or wrapping parens. + */ +function stripLeadingNoise(sql: string): string { + let s = sql.trim() + for (;;) { + if (s.startsWith('--')) { + const newline = s.indexOf('\n') + s = (newline === -1 ? '' : s.slice(newline + 1)).trim() + } else if (s.startsWith('/*')) { + const close = s.indexOf('*/') + s = (close === -1 ? '' : s.slice(close + 2)).trim() + } else if (s.startsWith('(')) { + s = s.slice(1).trim() + } else { + return s + } + } +} + +export async function executeClickHouseQuery( + config: ClickHouseConnectionConfig, + query: string, + options: { enforceReadOnly?: boolean } = {} +): Promise { + if (options.enforceReadOnly) { + // Strip leading comments/parens so wrapped or commented selects still validate. + const leader = stripLeadingNoise(query) + if (!READ_ONLY_STATEMENT.test(leader)) { + throw new Error( + 'The query operation only allows read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS). Use the Execute Raw SQL operation to run writes or DDL.' + ) + } + if (hasChainedStatement(query)) { + throw new Error( + 'The query operation only allows a single statement; chained statements separated by ";" are not allowed. Use the Execute Raw SQL operation to run multiple statements.' + ) + } + if (isMutatingStatement(query)) { + throw new Error( + 'The query operation only allows read-only statements; a write or DDL statement (e.g. INSERT/ALTER/DROP, including after a WITH clause) was detected. Use the Execute Raw SQL operation instead.' + ) + } + } + const result = await clickhouseRequest(config, ensureJsonFormat(query)) + return parseRowsResult(result) +} + +export async function executeClickHouseInsert( + config: ClickHouseConnectionConfig, + table: string, + data: Record +): Promise { + const sanitizedTable = sanitizeIdentifier(table) + const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${JSON.stringify(data)}` + const result = await clickhouseRequest(config, statement) + const written = Number(result.summary?.written_rows ?? 0) + return { rows: [], rowCount: written || 1 } +} + +export async function executeClickHouseUpdate( + config: ClickHouseConnectionConfig, + table: string, + data: Record, + where: string +): Promise { + validateWhereClause(where) + const sanitizedTable = sanitizeIdentifier(table) + const assignments = Object.entries(data) + .map(([column, value]) => `${sanitizeIdentifier(column)} = ${formatValue(value)}`) + .join(', ') + + if (!assignments) { + throw new Error('Update data object cannot be empty') + } + + const statement = `ALTER TABLE ${sanitizedTable} UPDATE ${assignments} WHERE ${where}` + const result = await clickhouseRequest(config, statement) + return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) } +} + +export async function executeClickHouseDelete( + config: ClickHouseConnectionConfig, + table: string, + where: string +): Promise { + validateWhereClause(where) + const sanitizedTable = sanitizeIdentifier(table) + const statement = `ALTER TABLE ${sanitizedTable} DELETE WHERE ${where}` + const result = await clickhouseRequest(config, statement) + return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) } +} + +export async function executeClickHouseIntrospect( + config: ClickHouseConnectionConfig +): Promise { + const database = quoteString(config.database) + + const tablesResult = await clickhouseRequest( + config, + `SELECT name, engine, total_rows FROM system.tables WHERE database = ${database} ORDER BY name FORMAT JSON` + ) + const tableRows = parseDataArray(tablesResult.text) + + const columnsResult = await clickhouseRequest( + config, + `SELECT table, name, type, default_kind, default_expression, is_in_primary_key, is_in_sorting_key, position FROM system.columns WHERE database = ${database} ORDER BY table, position FORMAT JSON` + ) + const columnRows = parseDataArray(columnsResult.text) + + const columnsByTable = new Map< + string, + ClickHouseIntrospectionResult['tables'][number]['columns'] + >() + for (const column of columnRows) { + const columns = columnsByTable.get(column.table) ?? [] + columns.push({ + name: column.name, + type: column.type, + defaultKind: column.default_kind || undefined, + defaultExpression: column.default_expression || undefined, + isInPrimaryKey: toBoolean(column.is_in_primary_key), + isInSortingKey: toBoolean(column.is_in_sorting_key), + }) + columnsByTable.set(column.table, columns) + } + + const tables = tableRows.map((table) => ({ + name: table.name, + database: config.database, + engine: table.engine ?? '', + totalRows: table.total_rows != null ? Number(table.total_rows) : undefined, + columns: columnsByTable.get(table.name) ?? [], + })) + + return { tables } +} + +function parseDataArray(text: string): T[] { + const trimmed = text.trim() + if (!trimmed) return [] + try { + const parsed = JSON.parse(trimmed) as { data?: T[] } + return Array.isArray(parsed.data) ? parsed.data : [] + } catch { + return [] + } +} + +function toBoolean(value: number | string | undefined): boolean { + return value === 1 || value === '1' +} + +/** + * Quotes and escapes a value for inline use in a ClickHouse statement. + * Strings use ClickHouse's backslash escaping for single quotes and backslashes. + */ +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return 'NULL' + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : 'NULL' + } + if (typeof value === 'boolean') { + return value ? '1' : '0' + } + if (typeof value === 'object') { + return quoteString(JSON.stringify(value)) + } + return quoteString(String(value)) +} + +function quoteString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` +} + +/** + * Validates and backtick-quotes a ClickHouse identifier, supporting + * `database.table` qualified names. + */ +export function sanitizeIdentifier(identifier: string): string { + if (identifier.includes('.')) { + return identifier + .split('.') + .map((part) => sanitizeSingleIdentifier(part)) + .join('.') + } + return sanitizeSingleIdentifier(identifier) +} + +function sanitizeSingleIdentifier(identifier: string): string { + const cleaned = identifier.replace(/`/g, '') + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) { + throw new Error( + `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.` + ) + } + return `\`${cleaned}\`` +} + +/** + * Rejects WHERE clauses containing patterns commonly used in SQL injection so + * that user-supplied conditions cannot escape the intended mutation. + */ +function validateWhereClause(where: string): void { + const dangerousPatterns = [ + /;\s*(drop|delete|insert|alter|create|truncate|rename|grant|revoke)/i, + /union\s+(all\s+)?select/i, + /into\s+outfile/i, + /--/, + /\/\*/, + /\*\//, + /\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i, + /\bor\s+true\b/i, + /\bor\s+false\b/i, + /\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i, + /\band\s+true\b/i, + /\band\s+false\b/i, + /\bsleep\s*\(/i, + /;\s*\w+/, + // Constant / tautological conditions that don't reference columns and would + // broaden a mutation to all rows (e.g. "1=1", "1 < 2", "'a'='a'", bare "1"/"true"). + /\b\d+\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+\b/, + /(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/, + /\b(\w+)\s*=\s*\1\b/i, + /^\s*(?:\d+|true|false)\s*$/i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(where)) { + throw new Error('WHERE clause contains potentially dangerous operation') + } + } +} + +/** + * Runs a SELECT statement (which must already include `FORMAT JSON`) and returns + * the parsed rows and row count. + */ +async function runSelect( + config: ClickHouseConnectionConfig, + statement: string +): Promise { + const result = await clickhouseRequest(config, statement) + return parseRowsResult(result) +} + +/** + * Runs a statement that does not return a result set (DDL or mutation) and + * returns the number of written rows reported by the summary header. + */ +async function runStatement( + config: ClickHouseConnectionConfig, + statement: string +): Promise { + const result = await clickhouseRequest(config, statement) + return Number(result.summary?.written_rows ?? 0) +} + +/** + * Validates a free-form SQL expression (ORDER BY, PARTITION BY, engine args) + * rejecting statement terminators and comment sequences. + */ +function validateExpression(expression: string, label: string): void { + if (/;|--|\/\*|\*\//.test(expression)) { + throw new Error(`${label} contains a disallowed character`) + } +} + +/** + * Validates an ORDER BY / PARTITION BY expression that is spliced inside wrapping + * parentheses in the generated DDL. In addition to rejecting terminators/comments, + * it requires balanced parentheses (quote-aware) so the expression cannot close + * the wrapping `(...)` early and append extra clauses (e.g. `id) SETTINGS …`). + */ +function validateClauseExpression(expression: string, label: string): void { + const trimmed = expression.trim() + if (!trimmed) { + throw new Error(`${label} is required`) + } + if (/;|--|\/\*|\*\//.test(trimmed)) { + throw new Error(`${label} contains a disallowed sequence`) + } + let depth = 0 + let inString = false + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i] + if (inString) { + if (ch === '\\') i++ + else if (ch === "'") inString = false + continue + } + if (ch === "'") inString = true + else if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth < 0) { + throw new Error(`${label} has unbalanced parentheses`) + } + } + } + if (inString || depth !== 0) { + throw new Error(`${label} has unbalanced parentheses or quotes`) + } +} + +/** + * Validates a partition value for `DROP PARTITION`. ClickHouse partition values + * are literals (signed numbers or single-quoted strings) or a parenthesised tuple + * of such literals, so anything else is rejected — barewords like `ALL`, function + * calls, operators, and extra tokens that could broaden the statement beyond + * dropping a single partition. + */ +function validatePartitionExpression(partition: string): void { + const partitionPattern = + /^\(?\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?))*\s*\)?$/ + if (!partitionPattern.test(partition.trim())) { + throw new Error( + "Partition must be a literal value or a tuple of literals (number or single-quoted string), e.g. 202401, '2024-01', or (2024, 'EU')" + ) + } +} + +export function executeClickHouseListDatabases( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT name, engine, comment FROM system.databases ORDER BY name FORMAT JSON' + ) +} + +export function executeClickHouseListTables( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + `SELECT name, engine, total_rows AS totalRows, total_bytes AS totalBytes, comment FROM system.tables WHERE database = ${quoteString(config.database)} ORDER BY name FORMAT JSON` + ) +} + +export function executeClickHouseDescribeTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const tableName = stripDatabasePrefix(table) + return runSelect( + config, + `SELECT name, type, default_kind AS defaultKind, default_expression AS defaultExpression, comment, is_in_primary_key AS isInPrimaryKey, is_in_sorting_key AS isInSortingKey FROM system.columns WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} ORDER BY position FORMAT JSON` + ) +} + +export async function executeClickHouseShowCreateTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const result = await runSelect( + config, + `SHOW CREATE TABLE ${sanitizeIdentifier(table)} FORMAT JSON` + ) + const firstRow = result.rows[0] as Record | undefined + if (!firstRow) { + return '' + } + // ClickHouse returns the DDL in a single String column (named `statement`); + // fall back to the first column value to stay robust to column-name changes. + const value = firstRow.statement ?? Object.values(firstRow)[0] + return typeof value === 'string' ? value : '' +} + +export async function executeClickHouseCountRows( + config: ClickHouseConnectionConfig, + table: string, + where?: string +): Promise { + let statement = `SELECT count() AS count FROM ${sanitizeIdentifier(table)}` + if (where?.trim()) { + validateWhereClause(where) + statement += ` WHERE ${where}` + } + const result = await runSelect(config, `${statement} FORMAT JSON`) + const firstRow = result.rows[0] as { count?: number | string } | undefined + return firstRow?.count != null ? Number(firstRow.count) : 0 +} + +export function executeClickHouseListPartitions( + config: ClickHouseConnectionConfig, + table: string +): Promise { + const tableName = stripDatabasePrefix(table) + return runSelect( + config, + `SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk FROM system.parts WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} AND active GROUP BY partition ORDER BY partition FORMAT JSON` + ) +} + +export function executeClickHouseListMutations( + config: ClickHouseConnectionConfig, + table?: string, + onlyRunning = false +): Promise { + const filters = [`database = ${quoteString(config.database)}`] + if (table?.trim()) { + filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`) + } + if (onlyRunning) { + filters.push('is_done = 0') + } + return runSelect( + config, + `SELECT table, mutation_id AS mutationId, command, create_time AS createTime, is_done AS isDone, parts_to_do AS partsToDo, latest_fail_reason AS latestFailReason FROM system.mutations WHERE ${filters.join(' AND ')} ORDER BY create_time DESC FORMAT JSON` + ) +} + +export function executeClickHouseListRunningQueries( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT query_id AS queryId, user, toFloat64(elapsed) AS elapsedSeconds, formatReadableSize(memory_usage) AS memoryUsage, query FROM system.processes ORDER BY elapsed DESC FORMAT JSON' + ) +} + +export function executeClickHouseTableStats( + config: ClickHouseConnectionConfig, + table?: string +): Promise { + const filters = ['active', `database = ${quoteString(config.database)}`] + if (table?.trim()) { + filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`) + } + return runSelect( + config, + `SELECT database, table, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk, formatReadableSize(sum(bytes_on_disk)) AS sizeOnDisk, count() AS parts FROM system.parts WHERE ${filters.join(' AND ')} GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC FORMAT JSON` + ) +} + +export function executeClickHouseListClusters( + config: ClickHouseConnectionConfig +): Promise { + return runSelect( + config, + 'SELECT cluster, shard_num AS shardNum, replica_num AS replicaNum, host_name AS hostName, port, is_local AS isLocal FROM system.clusters ORDER BY cluster, shard_num, replica_num FORMAT JSON' + ) +} + +export async function executeClickHouseCreateDatabase( + config: ClickHouseConnectionConfig, + name: string +): Promise { + await clickhouseRequest(config, `CREATE DATABASE IF NOT EXISTS ${sanitizeIdentifier(name)}`) +} + +export async function executeClickHouseDropDatabase( + config: ClickHouseConnectionConfig, + name: string +): Promise { + await clickhouseRequest(config, `DROP DATABASE IF EXISTS ${sanitizeIdentifier(name)}`) +} + +/** + * Validates a single ClickHouse column type. Types may legitimately contain + * commas, single-quoted strings, `=`, and `-` inside their parameter parentheses + * (e.g. `Decimal(10, 2)`, `Enum8('a' = 1, 'b' = -2)`, `Map(String, UInt64)`, + * `Array(Tuple(a UInt8, b String))`). We allow those but reject anything that + * could break out of the single type literal and inject another column or SQL: + * comment/terminator sequences, a top-level (unparenthesised) comma, or an + * unbalanced closing paren. + */ +function validateColumnType(type: string): void { + const trimmed = type.trim() + if (!trimmed || !/^[A-Za-z_]/.test(trimmed)) { + throw new Error(`Invalid column type: ${type}`) + } + if (!/^[A-Za-z0-9_(),.\s'"=-]+$/.test(trimmed) || /--|;/.test(trimmed)) { + throw new Error(`Invalid column type: ${type}`) + } + let depth = 0 + let inString = false + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i] + if (inString) { + if (ch === '\\') i++ + else if (ch === "'") inString = false + continue + } + if (ch === "'") inString = true + else if (ch === '(') depth++ + else if (ch === ')') { + depth-- + if (depth < 0) throw new Error(`Invalid column type: ${type}`) + } else if (ch === ',' && depth === 0) { + throw new Error(`Invalid column type: ${type}`) + } + } + if (inString || depth !== 0) { + throw new Error(`Invalid column type: ${type}`) + } +} + +export async function executeClickHouseCreateTable( + config: ClickHouseConnectionConfig, + table: string, + columns: Array<{ name: string; type: string }>, + engine: string, + orderBy: string, + partitionBy?: string +): Promise { + if (!Array.isArray(columns) || columns.length === 0) { + throw new Error('At least one column definition is required') + } + + const columnDefs = columns.map((column) => { + if (!column?.name || !column?.type) { + throw new Error('Each column requires a name and type') + } + validateColumnType(column.type) + return `${sanitizeIdentifier(column.name)} ${column.type.trim()}` + }) + + if (!/^[A-Za-z][A-Za-z0-9]*(\(.*\))?$/.test(engine.trim())) { + throw new Error(`Invalid table engine: ${engine}`) + } + validateExpression(engine, 'Engine') + + if (!orderBy?.trim()) { + throw new Error('ORDER BY expression is required') + } + validateClauseExpression(orderBy, 'ORDER BY') + + let statement = `CREATE TABLE IF NOT EXISTS ${sanitizeIdentifier(table)} (${columnDefs.join(', ')}) ENGINE = ${engine.trim()}` + if (partitionBy?.trim()) { + validateClauseExpression(partitionBy, 'PARTITION BY') + statement += ` PARTITION BY (${partitionBy.trim()})` + } + statement += ` ORDER BY (${orderBy.trim()})` + + await clickhouseRequest(config, statement) +} + +export async function executeClickHouseDropTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + await clickhouseRequest(config, `DROP TABLE IF EXISTS ${sanitizeIdentifier(table)}`) +} + +export async function executeClickHouseTruncateTable( + config: ClickHouseConnectionConfig, + table: string +): Promise { + await clickhouseRequest(config, `TRUNCATE TABLE IF EXISTS ${sanitizeIdentifier(table)}`) +} + +export async function executeClickHouseRenameTable( + config: ClickHouseConnectionConfig, + fromTable: string, + toTable: string +): Promise { + await clickhouseRequest( + config, + `RENAME TABLE ${sanitizeIdentifier(fromTable)} TO ${sanitizeIdentifier(toTable)}` + ) +} + +export async function executeClickHouseOptimizeTable( + config: ClickHouseConnectionConfig, + table: string, + final: boolean +): Promise { + await clickhouseRequest( + config, + `OPTIMIZE TABLE ${sanitizeIdentifier(table)}${final ? ' FINAL' : ''}` + ) +} + +export async function executeClickHouseDropPartition( + config: ClickHouseConnectionConfig, + table: string, + partition: string +): Promise { + validatePartitionExpression(partition) + await clickhouseRequest( + config, + `ALTER TABLE ${sanitizeIdentifier(table)} DROP PARTITION ${partition.trim()}` + ) +} + +export function executeClickHouseKillQuery( + config: ClickHouseConnectionConfig, + queryId: string +): Promise { + return runSelect(config, `KILL QUERY WHERE query_id = ${quoteString(queryId)} SYNC FORMAT JSON`) +} + +export async function executeClickHouseInsertRows( + config: ClickHouseConnectionConfig, + table: string, + rows: Array> +): Promise { + if (!Array.isArray(rows) || rows.length === 0) { + throw new Error('At least one row is required') + } + const sanitizedTable = sanitizeIdentifier(table) + const payload = rows.map((row) => JSON.stringify(row)).join('\n') + const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${payload}` + const written = await runStatement(config, statement) + return { rows: [], rowCount: written || rows.length } +} + +function stripDatabasePrefix(table: string): string { + const parts = table.split('.') + return parts[parts.length - 1].replace(/`/g, '') +} diff --git a/apps/sim/blocks/blocks/clickhouse.ts b/apps/sim/blocks/blocks/clickhouse.ts new file mode 100644 index 00000000000..41562f2a351 --- /dev/null +++ b/apps/sim/blocks/blocks/clickhouse.ts @@ -0,0 +1,466 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { ClickHouseIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { IntegrationType } from '@/blocks/types' +import type { ClickHouseResponse } from '@/tools/clickhouse/types' + +const CLICKHOUSE_QUERY_PROMPT = `You are an expert ClickHouse database developer. Write ClickHouse SQL queries based on the user's request. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the SQL query. Do not include any explanations, markdown formatting, comments, or additional text. Just the raw SQL query. + +### QUERY GUIDELINES +1. **Syntax**: Use ClickHouse-specific SQL syntax and functions +2. **Performance**: Filter on primary/sorting key columns and use PREWHERE where helpful +3. **Readability**: Format queries with proper indentation and spacing +4. **Best Practices**: Add a LIMIT clause for exploratory queries + +### CLICKHOUSE FEATURES +- Use ClickHouse functions (toDateTime, toStartOfInterval, uniqExact, quantile, arrayJoin, etc.) +- Use ClickHouse data types (UInt64, Float64, String, DateTime, LowCardinality, etc.) +- Leverage aggregate combinators (-If, -Array, -State, -Merge) when appropriate + +### EXAMPLES + +**Simple Select**: "Get the 100 most recent events" +→ SELECT event_time, user_id, event_type + FROM events + ORDER BY event_time DESC + LIMIT 100; + +**Aggregation**: "Count unique users per day for the last 7 days" +→ SELECT + toDate(event_time) AS day, + uniqExact(user_id) AS unique_users + FROM events + WHERE event_time >= now() - INTERVAL 7 DAY + GROUP BY day + ORDER BY day; + +### REMEMBER +Return ONLY the SQL query - no explanations, no markdown, no extra text.` + +const TABLE_REQUIRED_OPERATIONS = [ + 'insert', + 'insert_rows', + 'update', + 'delete', + 'describe_table', + 'show_create_table', + 'count_rows', + 'list_partitions', + 'create_table', + 'drop_table', + 'truncate_table', + 'rename_table', + 'optimize_table', + 'drop_partition', +] + +export const ClickHouseBlock: BlockConfig = { + type: 'clickhouse', + name: 'ClickHouse', + description: 'Connect to a ClickHouse database', + longDescription: + 'Integrate ClickHouse into the workflow. Query and insert data, manage databases and tables, inspect schemas, monitor mutations and running queries, manage partitions, and execute raw SQL over the ClickHouse HTTP interface.', + docsLink: 'https://docs.sim.ai/tools/clickhouse', + category: 'tools', + integrationType: IntegrationType.Databases, + tags: ['data-warehouse', 'data-analytics'], + bgColor: '#f9ff69', + icon: ClickHouseIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Query (SELECT)', id: 'query' }, + { label: 'Execute Raw SQL', id: 'execute' }, + { label: 'Insert Row', id: 'insert' }, + { label: 'Insert Rows (Bulk)', id: 'insert_rows' }, + { label: 'Update Data', id: 'update' }, + { label: 'Delete Data', id: 'delete' }, + { label: 'List Databases', id: 'list_databases' }, + { label: 'List Tables', id: 'list_tables' }, + { label: 'Describe Table', id: 'describe_table' }, + { label: 'Show Create Table', id: 'show_create_table' }, + { label: 'Count Rows', id: 'count_rows' }, + { label: 'Introspect Schema', id: 'introspect' }, + { label: 'Create Database', id: 'create_database' }, + { label: 'Drop Database', id: 'drop_database' }, + { label: 'Create Table', id: 'create_table' }, + { label: 'Drop Table', id: 'drop_table' }, + { label: 'Truncate Table', id: 'truncate_table' }, + { label: 'Rename Table', id: 'rename_table' }, + { label: 'Optimize Table', id: 'optimize_table' }, + { label: 'List Partitions', id: 'list_partitions' }, + { label: 'Drop Partition', id: 'drop_partition' }, + { label: 'List Mutations', id: 'list_mutations' }, + { label: 'List Running Queries', id: 'list_running_queries' }, + { label: 'Kill Query', id: 'kill_query' }, + { label: 'Table Stats', id: 'table_stats' }, + { label: 'List Clusters', id: 'list_clusters' }, + ], + value: () => 'query', + }, + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'your-instance.clickhouse.cloud', + required: true, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '8443', + value: () => '8443', + required: true, + }, + { + id: 'database', + title: 'Database Name', + type: 'short-input', + placeholder: 'default', + value: () => 'default', + required: true, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'default', + value: () => 'default', + required: true, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Your ClickHouse password', + }, + { + id: 'secure', + title: 'Use HTTPS', + type: 'switch', + value: () => 'true', + }, + { + id: 'table', + title: 'Table Name', + type: 'short-input', + placeholder: 'events', + condition: { field: 'operation', value: TABLE_REQUIRED_OPERATIONS }, + required: { field: 'operation', value: TABLE_REQUIRED_OPERATIONS }, + }, + { + id: 'table', + title: 'Table Name (Optional)', + type: 'short-input', + placeholder: 'Leave blank for all tables', + condition: { field: 'operation', value: ['list_mutations', 'table_stats'] }, + }, + { + id: 'query', + title: 'SQL Query', + type: 'code', + placeholder: 'SELECT * FROM events ORDER BY event_time DESC LIMIT 100', + condition: { field: 'operation', value: 'query' }, + required: { field: 'operation', value: 'query' }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: CLICKHOUSE_QUERY_PROMPT, + placeholder: 'Describe the ClickHouse query you need...', + generationType: 'sql-query', + }, + }, + { + id: 'query', + title: 'SQL Statement', + type: 'code', + placeholder: 'CREATE TABLE events (id UInt64, name String) ENGINE = MergeTree ORDER BY id', + condition: { field: 'operation', value: 'execute' }, + required: { field: 'operation', value: 'execute' }, + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: CLICKHOUSE_QUERY_PROMPT, + placeholder: 'Describe the ClickHouse statement you need...', + generationType: 'sql-query', + }, + }, + { + id: 'data', + title: 'Data (JSON)', + type: 'code', + placeholder: '{\n "id": 1,\n "name": "Example",\n "created_at": "2024-01-01 00:00:00"\n}', + condition: { field: 'operation', value: 'insert' }, + required: { field: 'operation', value: 'insert' }, + }, + { + id: 'rows', + title: 'Rows (JSON Array)', + type: 'code', + placeholder: '[\n { "id": 1, "name": "A" },\n { "id": 2, "name": "B" }\n]', + condition: { field: 'operation', value: 'insert_rows' }, + required: { field: 'operation', value: 'insert_rows' }, + }, + { + id: 'data', + title: 'Update Data (JSON)', + type: 'code', + placeholder: '{\n "name": "Updated name",\n "status": "active"\n}', + condition: { field: 'operation', value: 'update' }, + required: { field: 'operation', value: 'update' }, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'update' }, + required: { field: 'operation', value: 'update' }, + }, + { + id: 'where', + title: 'WHERE Condition', + type: 'short-input', + placeholder: 'id = 1', + condition: { field: 'operation', value: 'delete' }, + required: { field: 'operation', value: 'delete' }, + }, + { + id: 'where', + title: 'WHERE Condition (Optional)', + type: 'short-input', + placeholder: "status = 'active'", + condition: { field: 'operation', value: 'count_rows' }, + }, + { + id: 'name', + title: 'Database Name', + type: 'short-input', + placeholder: 'analytics', + condition: { field: 'operation', value: ['create_database', 'drop_database'] }, + required: { field: 'operation', value: ['create_database', 'drop_database'] }, + }, + { + id: 'columns', + title: 'Columns (JSON Array)', + type: 'code', + placeholder: + '[\n { "name": "id", "type": "UInt64" },\n { "name": "ts", "type": "DateTime" }\n]', + condition: { field: 'operation', value: 'create_table' }, + required: { field: 'operation', value: 'create_table' }, + }, + { + id: 'engine', + title: 'Engine', + type: 'short-input', + placeholder: 'MergeTree', + value: () => 'MergeTree', + condition: { field: 'operation', value: 'create_table' }, + }, + { + id: 'orderBy', + title: 'Order By', + type: 'short-input', + placeholder: 'id or (id, ts)', + condition: { field: 'operation', value: 'create_table' }, + required: { field: 'operation', value: 'create_table' }, + }, + { + id: 'partitionBy', + title: 'Partition By (Optional)', + type: 'short-input', + placeholder: 'toYYYYMM(ts)', + condition: { field: 'operation', value: 'create_table' }, + }, + { + id: 'newTable', + title: 'New Table Name', + type: 'short-input', + placeholder: 'events_archive', + condition: { field: 'operation', value: 'rename_table' }, + required: { field: 'operation', value: 'rename_table' }, + }, + { + id: 'final', + title: 'Force Final Merge', + type: 'switch', + condition: { field: 'operation', value: 'optimize_table' }, + }, + { + id: 'partition', + title: 'Partition', + type: 'short-input', + placeholder: "202401 or '2024-01'", + condition: { field: 'operation', value: 'drop_partition' }, + required: { field: 'operation', value: 'drop_partition' }, + }, + { + id: 'queryId', + title: 'Query ID', + type: 'short-input', + placeholder: 'The query_id to kill', + condition: { field: 'operation', value: 'kill_query' }, + required: { field: 'operation', value: 'kill_query' }, + }, + { + id: 'onlyRunning', + title: 'Only Running Mutations', + type: 'switch', + condition: { field: 'operation', value: 'list_mutations' }, + }, + ], + tools: { + access: [ + 'clickhouse_query', + 'clickhouse_execute', + 'clickhouse_insert', + 'clickhouse_insert_rows', + 'clickhouse_update', + 'clickhouse_delete', + 'clickhouse_list_databases', + 'clickhouse_list_tables', + 'clickhouse_describe_table', + 'clickhouse_show_create_table', + 'clickhouse_count_rows', + 'clickhouse_introspect', + 'clickhouse_create_database', + 'clickhouse_drop_database', + 'clickhouse_create_table', + 'clickhouse_drop_table', + 'clickhouse_truncate_table', + 'clickhouse_rename_table', + 'clickhouse_optimize_table', + 'clickhouse_list_partitions', + 'clickhouse_drop_partition', + 'clickhouse_list_mutations', + 'clickhouse_list_running_queries', + 'clickhouse_kill_query', + 'clickhouse_table_stats', + 'clickhouse_list_clusters', + ], + config: { + tool: (params) => { + if (!params.operation) { + throw new Error('Operation is required') + } + return `clickhouse_${params.operation}` + }, + params: (params) => { + const { operation, data, columns, rows, secure, ...rest } = params + + const parseJsonField = (value: unknown, label: string): unknown => { + if (value && typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (parseError) { + const errorMsg = getErrorMessage(parseError, 'Unknown JSON error') + throw new Error(`Invalid JSON in ${label}: ${errorMsg}. Please check your syntax.`) + } + } + if (value && typeof value === 'object') { + return value + } + return undefined + } + + const parsedData = parseJsonField(data, 'data') + const parsedColumns = parseJsonField(columns, 'columns') + const parsedRows = parseJsonField(rows, 'rows') + + const isSecure = secure !== false && secure !== 'false' + + const result: Record = { + host: rest.host, + port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 8443, + database: rest.database || 'default', + username: rest.username || 'default', + password: rest.password ?? '', + secure: isSecure, + } + + if (rest.table) result.table = rest.table + if (rest.query) result.query = rest.query + if (rest.where) result.where = rest.where + if (rest.name) result.name = rest.name + if (rest.newTable) result.newTable = rest.newTable + if (rest.partition) result.partition = rest.partition + if (rest.queryId) result.queryId = rest.queryId + if (rest.engine) result.engine = rest.engine + if (rest.orderBy) result.orderBy = rest.orderBy + if (rest.partitionBy) result.partitionBy = rest.partitionBy + if (rest.final !== undefined) { + result.final = rest.final === true || rest.final === 'true' + } + if (rest.onlyRunning !== undefined) { + result.onlyRunning = rest.onlyRunning === true || rest.onlyRunning === 'true' + } + if (parsedData !== undefined) result.data = parsedData + if (parsedColumns !== undefined) result.columns = parsedColumns + if (parsedRows !== undefined) result.rows = parsedRows + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Database operation to perform' }, + host: { type: 'string', description: 'ClickHouse host' }, + port: { type: 'string', description: 'ClickHouse HTTP port' }, + database: { type: 'string', description: 'Database name' }, + username: { type: 'string', description: 'ClickHouse username' }, + password: { type: 'string', description: 'ClickHouse password' }, + secure: { type: 'boolean', description: 'Use a secure HTTPS connection' }, + table: { type: 'string', description: 'Table name' }, + query: { type: 'string', description: 'SQL statement to execute' }, + data: { type: 'json', description: 'Data for insert/update operations' }, + rows: { type: 'json', description: 'Array of row objects for bulk insert' }, + columns: { type: 'json', description: 'Column definitions for create table' }, + where: { type: 'string', description: 'WHERE clause for update/delete/count' }, + name: { type: 'string', description: 'Database name for create/drop database' }, + newTable: { type: 'string', description: 'Target table name for rename' }, + partition: { type: 'string', description: 'Partition expression for drop partition' }, + queryId: { type: 'string', description: 'Query ID for kill query' }, + engine: { type: 'string', description: 'Table engine for create table' }, + orderBy: { type: 'string', description: 'ORDER BY expression for create table' }, + partitionBy: { type: 'string', description: 'PARTITION BY expression for create table' }, + final: { type: 'boolean', description: 'Force a final merge for optimize table' }, + onlyRunning: { type: 'boolean', description: 'Filter to running mutations only' }, + }, + outputs: { + message: { + type: 'string', + description: 'Success or error message describing the operation outcome', + }, + rows: { + type: 'array', + description: 'Array of rows returned from the operation', + }, + rowCount: { + type: 'number', + description: 'Number of rows returned or affected by the operation', + }, + count: { + type: 'number', + description: 'Row count (count rows operation)', + }, + ddl: { + type: 'string', + description: 'CREATE TABLE statement (show create table operation)', + }, + tables: { + type: 'array', + description: 'Array of table schemas with columns and engines (introspect operation)', + }, + }, +} diff --git a/apps/sim/blocks/blocks/dagster.ts b/apps/sim/blocks/blocks/dagster.ts index 2446a4af417..0a907e4eac0 100644 --- a/apps/sim/blocks/blocks/dagster.ts +++ b/apps/sim/blocks/blocks/dagster.ts @@ -3,6 +3,13 @@ import type { BlockConfig } from '@/blocks/types' import { IntegrationType } from '@/blocks/types' import type { DagsterResponse } from '@/tools/dagster/types' +/** Coerces a subBlock value to a finite number, returning undefined for empty or non-numeric input. */ +function toFiniteNumber(value: unknown): number | undefined { + if (value == null || value === '') return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + export const DagsterBlock: BlockConfig = { type: 'dagster', name: 'Dagster', @@ -37,6 +44,11 @@ export const DagsterBlock: BlockConfig = { { label: 'List Sensors', id: 'list_sensors' }, { label: 'Start Sensor', id: 'start_sensor' }, { label: 'Stop Sensor', id: 'stop_sensor' }, + { label: 'List Assets', id: 'list_assets' }, + { label: 'Get Asset', id: 'get_asset' }, + { label: 'Materialize Assets', id: 'materialize_assets' }, + { label: 'Report Asset Materialization', id: 'report_asset_materialization' }, + { label: 'Wipe Asset', id: 'wipe_asset' }, ], value: () => 'launch_run', }, @@ -49,11 +61,25 @@ export const DagsterBlock: BlockConfig = { placeholder: 'e.g., my_code_location', condition: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, required: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, }, { @@ -63,11 +89,25 @@ export const DagsterBlock: BlockConfig = { placeholder: 'e.g., __repository__', condition: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, required: { field: 'operation', - value: ['launch_run', 'list_schedules', 'start_schedule', 'list_sensors', 'start_sensor'], + value: [ + 'launch_run', + 'list_schedules', + 'start_schedule', + 'list_sensors', + 'start_sensor', + 'materialize_assets', + ], }, }, @@ -105,7 +145,7 @@ Return ONLY a valid JSON object - no explanations, no extra text.`, title: 'Tags', type: 'code', placeholder: '[{"key": "env", "value": "prod"}]', - condition: { field: 'operation', value: 'launch_run' }, + condition: { field: 'operation', value: ['launch_run', 'materialize_assets'] }, mode: 'advanced', wandConfig: { enabled: true, @@ -210,6 +250,46 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` condition: { field: 'operation', value: 'list_runs' }, mode: 'advanced', }, + { + id: 'createdAfter', + title: 'Created After', + type: 'short-input', + placeholder: 'Unix timestamp in seconds (optional)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of a start time into a Unix timestamp in seconds. + +Return ONLY the integer Unix timestamp in seconds - no explanations, no extra text.`, + placeholder: 'Describe the earliest creation time...', + generationType: 'timestamp', + }, + }, + { + id: 'createdBefore', + title: 'Created Before', + type: 'short-input', + placeholder: 'Unix timestamp in seconds (optional)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Convert the user's description of an end time into a Unix timestamp in seconds. + +Return ONLY the integer Unix timestamp in seconds - no explanations, no extra text.`, + placeholder: 'Describe the latest creation time...', + generationType: 'timestamp', + }, + }, + { + id: 'runsCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Run ID from a previous response cursor (for pagination)', + condition: { field: 'operation', value: 'list_runs' }, + mode: 'advanced', + }, // ── Schedule operations ──────────────────────────────────────────────────── { @@ -267,6 +347,95 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` required: { field: 'operation', value: ['stop_schedule', 'stop_sensor'] }, }, + // ── Asset operations ─────────────────────────────────────────────────────── + { + id: 'assetKey', + title: 'Asset Key', + type: 'short-input', + placeholder: 'e.g., my_asset or raw/events', + condition: { + field: 'operation', + value: ['get_asset', 'report_asset_materialization', 'wipe_asset'], + }, + required: { + field: 'operation', + value: ['get_asset', 'report_asset_materialization', 'wipe_asset'], + }, + }, + { + id: 'assetJobName', + title: 'Asset Job', + type: 'short-input', + placeholder: 'e.g., __ASSET_JOB or a named asset job', + condition: { field: 'operation', value: 'materialize_assets' }, + required: { field: 'operation', value: 'materialize_assets' }, + }, + { + id: 'assetSelection', + title: 'Asset Selection', + type: 'long-input', + placeholder: 'Comma- or newline-separated asset keys, e.g. raw/events, summary', + condition: { field: 'operation', value: 'materialize_assets' }, + required: { field: 'operation', value: 'materialize_assets' }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Dagster asset keys to materialize based on the user's description. Multi-part keys use slashes (e.g. raw/events). + +Return ONLY the comma-separated asset keys - no explanations, no extra text.`, + placeholder: 'Describe which assets to materialize...', + }, + }, + { + id: 'assetPrefix', + title: 'Key Prefix', + type: 'short-input', + placeholder: 'Filter by asset key prefix, e.g. raw (optional)', + condition: { field: 'operation', value: 'list_assets' }, + }, + { + id: 'assetsLimit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: 'list_assets' }, + mode: 'advanced', + }, + { + id: 'assetsCursor', + title: 'Cursor', + type: 'short-input', + placeholder: 'Cursor from a previous list_assets response (for pagination)', + condition: { field: 'operation', value: 'list_assets' }, + mode: 'advanced', + }, + { + id: 'reportEventType', + title: 'Event Type', + type: 'dropdown', + options: [ + { label: 'Materialization', id: 'ASSET_MATERIALIZATION' }, + { label: 'Observation', id: 'ASSET_OBSERVATION' }, + ], + value: () => 'ASSET_MATERIALIZATION', + condition: { field: 'operation', value: 'report_asset_materialization' }, + }, + { + id: 'reportPartitionKeys', + title: 'Partition Keys', + type: 'short-input', + placeholder: 'Comma-separated partition keys (optional)', + condition: { field: 'operation', value: 'report_asset_materialization' }, + mode: 'advanced', + }, + { + id: 'reportDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Description for the reported event (optional)', + condition: { field: 'operation', value: 'report_asset_materialization' }, + mode: 'advanced', + }, + // ── Connection (common to all operations) ────────────────────────────────── { id: 'host', @@ -300,22 +469,29 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` 'dagster_list_sensors', 'dagster_start_sensor', 'dagster_stop_sensor', + 'dagster_list_assets', + 'dagster_get_asset', + 'dagster_materialize_assets', + 'dagster_report_asset_materialization', + 'dagster_wipe_asset', ], config: { tool: (params) => `dagster_${params.operation}`, params: (params) => { const result: Record = {} - // list_runs: type-coerce limit and remap job name filter + // list_runs: type-coerce limit + time filters, remap job name filter and cursor if (params.operation === 'list_runs') { - if (params.limit != null && params.limit !== '') result.limit = Number(params.limit) + result.limit = toFiniteNumber(params.limit) result.jobName = params.listRunsJobName || undefined + result.createdAfter = toFiniteNumber(params.createdAfter) + result.createdBefore = toFiniteNumber(params.createdBefore) + result.cursor = params.runsCursor || undefined } // get_run_logs: remap logsLimit → limit if (params.operation === 'get_run_logs') { - if (params.logsLimit != null && params.logsLimit !== '') - result.limit = Number(params.logsLimit) + result.limit = toFiniteNumber(params.logsLimit) } // reexecute_run: remap runId → parentRunId @@ -331,6 +507,25 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` result.sensorStatus = undefined } + // list_assets: type-coerce limit and remap prefix/cursor + if (params.operation === 'list_assets') { + result.prefix = params.assetPrefix || undefined + result.limit = toFiniteNumber(params.assetsLimit) + result.cursor = params.assetsCursor || undefined + } + + // materialize_assets: remap asset job name → jobName + if (params.operation === 'materialize_assets') { + result.jobName = params.assetJobName + } + + // report_asset_materialization: remap report-prefixed fields to tool params + if (params.operation === 'report_asset_materialization') { + result.eventType = params.reportEventType || 'ASSET_MATERIALIZATION' + result.partitionKeys = params.reportPartitionKeys || undefined + result.description = params.reportDescription || undefined + } + return result }, }, @@ -362,6 +557,15 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` // List Runs listRunsJobName: { type: 'string', description: 'Filter list_runs by job name' }, statuses: { type: 'string', description: 'Comma-separated run statuses to filter by' }, + createdAfter: { + type: 'number', + description: 'Only return runs created at/after this Unix time', + }, + createdBefore: { + type: 'number', + description: 'Only return runs created at/before this Unix time', + }, + runsCursor: { type: 'string', description: 'Run ID cursor for list_runs pagination' }, limit: { type: 'number', description: 'Maximum results to return' }, // Schedules scheduleName: { type: 'string', description: 'Schedule name' }, @@ -374,6 +578,25 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` sensorStatus: { type: 'string', description: 'Filter sensors by status (RUNNING or STOPPED)' }, // Stop schedule / sensor instigationStateId: { type: 'string', description: 'InstigationState ID for stop operations' }, + // Assets + assetKey: { type: 'string', description: 'Slash-delimited asset key' }, + assetJobName: { type: 'string', description: 'Asset job to launch for materialization' }, + assetSelection: { + type: 'string', + description: 'Comma/newline-separated asset keys to materialize', + }, + assetPrefix: { type: 'string', description: 'Filter list_assets by key prefix' }, + assetsLimit: { type: 'number', description: 'Maximum assets to return' }, + assetsCursor: { type: 'string', description: 'Cursor for list_assets pagination' }, + reportEventType: { + type: 'string', + description: 'Runless event type (ASSET_MATERIALIZATION or ASSET_OBSERVATION)', + }, + reportPartitionKeys: { + type: 'string', + description: 'Comma-separated partition keys for the reported event', + }, + reportDescription: { type: 'string', description: 'Description for the reported event' }, }, outputs: { @@ -382,8 +605,14 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` // Get Run jobName: { type: 'string', description: 'Job name the run belongs to' }, status: { type: 'string', description: 'Run or schedule/sensor status' }, + mode: { type: 'string', description: 'Execution mode of the run' }, startTime: { type: 'number', description: 'Run start time (Unix timestamp)' }, endTime: { type: 'number', description: 'Run end time (Unix timestamp)' }, + creationTime: { type: 'number', description: 'Run creation time (Unix timestamp)' }, + updateTime: { type: 'number', description: 'Run last-update time (Unix timestamp)' }, + parentRunId: { type: 'string', description: 'Immediate parent run ID (re-executions)' }, + rootRunId: { type: 'string', description: 'Root run ID of the re-execution group' }, + canTerminate: { type: 'boolean', description: 'Whether the run can be terminated' }, runConfigYaml: { type: 'string', description: 'Run configuration as YAML' }, tags: { type: 'json', description: 'Run tags as array of {key, value} objects' }, // List Runs @@ -401,10 +630,13 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` type: 'json', description: 'Log events (type, message, timestamp, level, stepKey, eventType)', }, - cursor: { type: 'string', description: 'Pagination cursor for the next page of logs' }, + cursor: { + type: 'string', + description: 'Pagination cursor for the next page (logs, runs, or assets)', + }, hasMore: { type: 'boolean', - description: 'Whether more log events are available beyond this page', + description: 'Whether more items are available beyond this page', }, // List Schedules schedules: { @@ -419,5 +651,21 @@ Return ONLY the comma-separated status values - no explanations, no extra text.` }, // Start/Stop schedule or sensor id: { type: 'string', description: 'Instigator state ID of the schedule or sensor' }, + // Get Run / Get Asset (asset key selection) + assetSelection: { type: 'json', description: 'Asset keys targeted by the run' }, + // List Assets + assets: { type: 'json', description: 'List of assets (assetKey, path)' }, + // Get Asset + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + groupName: { type: 'string', description: 'Asset group name' }, + description: { type: 'string', description: 'Asset description' }, + jobNames: { type: 'json', description: 'Jobs that can materialize the asset' }, + computeKind: { type: 'string', description: 'Asset compute kind tag' }, + isPartitioned: { type: 'boolean', description: 'Whether the asset is partitioned' }, + latestMaterialization: { + type: 'json', + description: 'Latest materialization (runId, timestamp, partition, stepKey)', + }, }, } diff --git a/apps/sim/blocks/blocks/tinybird.ts b/apps/sim/blocks/blocks/tinybird.ts index e1b1e08d4da..b2d02a998de 100644 --- a/apps/sim/blocks/blocks/tinybird.ts +++ b/apps/sim/blocks/blocks/tinybird.ts @@ -6,11 +6,11 @@ import type { TinybirdResponse } from '@/tools/tinybird/types' export const TinybirdBlock: BlockConfig = { type: 'tinybird', name: 'Tinybird', - description: 'Send events and query data with Tinybird', + description: 'Send events, query data, and manage Data Sources with Tinybird', authMode: AuthMode.ApiKey, longDescription: - 'Interact with Tinybird using the Events API to stream JSON or NDJSON events, or use the Query API to execute SQL queries against Pipes and Data Sources.', - docsLink: 'https://www.tinybird.co/docs/api-reference', + 'Interact with Tinybird: stream JSON or NDJSON events with the Events API, run SQL with the Query API, call published Pipe API Endpoints by name with dynamic parameters, and manage Data Sources by appending from a URL, truncating, or deleting rows by condition.', + docsLink: 'https://docs.sim.ai/tools/tinybird', category: 'tools', integrationType: IntegrationType.Analytics, tags: ['data-warehouse', 'data-analytics'], @@ -24,6 +24,10 @@ export const TinybirdBlock: BlockConfig = { options: [ { label: 'Send Events', id: 'tinybird_events' }, { label: 'Query', id: 'tinybird_query' }, + { label: 'Query Pipe Endpoint', id: 'tinybird_query_pipe' }, + { label: 'Append Data Source (from URL)', id: 'tinybird_append_datasource' }, + { label: 'Truncate Data Source', id: 'tinybird_truncate_datasource' }, + { label: 'Delete Data Source Rows', id: 'tinybird_delete_datasource_rows' }, ], value: () => 'tinybird_events', }, @@ -42,13 +46,21 @@ export const TinybirdBlock: BlockConfig = { password: true, required: true, }, - // Send Events operation inputs + // Data Source name (Send Events + Data Source management operations) { id: 'datasource', title: 'Data Source', type: 'short-input', placeholder: 'my_events_datasource', - condition: { field: 'operation', value: 'tinybird_events' }, + condition: { + field: 'operation', + value: [ + 'tinybird_events', + 'tinybird_append_datasource', + 'tinybird_truncate_datasource', + 'tinybird_delete_datasource_rows', + ], + }, required: true, }, { @@ -105,11 +117,95 @@ export const TinybirdBlock: BlockConfig = { title: 'Pipeline Name', type: 'short-input', placeholder: 'my_pipe (optional)', + mode: 'advanced', condition: { field: 'operation', value: 'tinybird_query' }, }, + // Query Pipe Endpoint operation inputs + { + id: 'pipe', + title: 'Pipe Name', + type: 'short-input', + placeholder: 'top_pages', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + required: true, + }, + { + id: 'parameters', + title: 'Parameters', + type: 'code', + placeholder: '{\n "start_date": "2024-01-01",\n "limit": 10\n}', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + wandConfig: { + enabled: true, + generationType: 'json-object', + placeholder: 'Describe the Pipe parameters to pass', + prompt: + 'Generate a JSON object of dynamic parameters to pass to a Tinybird Pipe API Endpoint. Keys are parameter names and values are their values. Return ONLY the JSON object - no explanations, no extra text.', + }, + }, + { + id: 'pipe_sql', + title: 'SQL (on top of Pipe)', + type: 'code', + placeholder: 'SELECT count() FROM _', + mode: 'advanced', + condition: { field: 'operation', value: 'tinybird_query_pipe' }, + }, + // Append Data Source operation inputs + { + id: 'source_url', + title: 'Source File URL', + type: 'short-input', + placeholder: 'https://example.com/data.csv', + condition: { field: 'operation', value: 'tinybird_append_datasource' }, + required: true, + }, + { + id: 'source_format', + title: 'Source Format', + type: 'dropdown', + options: [ + { label: 'CSV', id: 'csv' }, + { label: 'NDJSON', id: 'ndjson' }, + { label: 'Parquet', id: 'parquet' }, + ], + value: () => 'csv', + condition: { field: 'operation', value: 'tinybird_append_datasource' }, + }, + // Delete Data Source Rows operation inputs + { + id: 'delete_condition', + title: 'Delete Condition', + type: 'long-input', + placeholder: "country = 'ES'", + condition: { field: 'operation', value: 'tinybird_delete_datasource_rows' }, + required: true, + wandConfig: { + enabled: true, + generationType: 'sql-query', + placeholder: 'Describe which rows to delete', + prompt: + 'Generate a SQL WHERE-clause condition (without the WHERE keyword) selecting rows to delete from a table. Example: "event_date < \'2024-01-01\'". Return ONLY the SQL condition - no explanations, no extra text.', + }, + }, + { + id: 'dry_run', + title: 'Dry Run', + type: 'switch', + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'tinybird_delete_datasource_rows' }, + }, ], tools: { - access: ['tinybird_events', 'tinybird_query'], + access: [ + 'tinybird_events', + 'tinybird_query', + 'tinybird_query_pipe', + 'tinybird_append_datasource', + 'tinybird_truncate_datasource', + 'tinybird_delete_datasource_rows', + ], config: { tool: (params) => params.operation || 'tinybird_events', params: (params) => { @@ -133,7 +229,6 @@ export const TinybirdBlock: BlockConfig = { result.format = params.format || 'ndjson' result.compression = params.compression || 'none' - // Convert wait from string to boolean // Convert wait from string to boolean if (params.wait !== undefined) { const waitValue = @@ -150,6 +245,56 @@ export const TinybirdBlock: BlockConfig = { if (params.pipeline) { result.pipeline = params.pipeline } + } else if (operation === 'tinybird_query_pipe') { + // Query Pipe Endpoint operation + if (!params.pipe) { + throw new Error('Pipe Name is required for Query Pipe Endpoint operation') + } + + result.pipe = params.pipe + if (params.parameters) { + result.parameters = params.parameters + } + if (params.pipe_sql) { + result.q = params.pipe_sql + } + } else if (operation === 'tinybird_append_datasource') { + // Append Data Source from URL operation + if (!params.datasource) { + throw new Error('Data Source is required for Append Data Source operation') + } + if (!params.source_url) { + throw new Error('Source File URL is required for Append Data Source operation') + } + + result.datasource = params.datasource + result.url = params.source_url + result.format = params.source_format || 'csv' + } else if (operation === 'tinybird_truncate_datasource') { + // Truncate Data Source operation + if (!params.datasource) { + throw new Error('Data Source is required for Truncate Data Source operation') + } + + result.datasource = params.datasource + } else if (operation === 'tinybird_delete_datasource_rows') { + // Delete Data Source Rows operation + if (!params.datasource) { + throw new Error('Data Source is required for Delete Data Source Rows operation') + } + if (!params.delete_condition) { + throw new Error('Delete Condition is required for Delete Data Source Rows operation') + } + + result.datasource = params.datasource + result.delete_condition = params.delete_condition + + // Convert dry_run from string to boolean + if (params.dry_run !== undefined) { + const dryRunValue = + typeof params.dry_run === 'string' ? params.dry_run.toLowerCase() : params.dry_run + result.dry_run = dryRunValue === 'true' || dryRunValue === true + } } return result @@ -180,6 +325,16 @@ export const TinybirdBlock: BlockConfig = { // Query inputs query: { type: 'string', description: 'SQL query to execute' }, pipeline: { type: 'string', description: 'Optional pipeline name' }, + // Query Pipe Endpoint inputs + pipe: { type: 'string', description: 'Published Pipe API Endpoint name' }, + parameters: { type: 'json', description: 'Dynamic Pipe parameters as a JSON object' }, + pipe_sql: { type: 'string', description: 'Optional SQL to run on top of the Pipe result' }, + // Append Data Source inputs + source_url: { type: 'string', description: 'URL of the file to append' }, + source_format: { type: 'string', description: 'Source file format (csv, ndjson, parquet)' }, + // Delete Data Source Rows inputs + delete_condition: { type: 'string', description: 'SQL condition selecting rows to delete' }, + dry_run: { type: 'boolean', description: 'Test the delete without removing data' }, // Common token: { type: 'string', description: 'Tinybird API Token' }, }, @@ -199,11 +354,33 @@ export const TinybirdBlock: BlockConfig = { description: 'Query result data. FORMAT JSON: array of objects. Other formats (CSV, TSV, etc.): raw text string.', }, + meta: { + type: 'json', + description: 'Column metadata for the result set: [{name, type}] (only with FORMAT JSON)', + }, rows: { type: 'number', description: 'Number of rows returned (only with FORMAT JSON)' }, + rows_before_limit_at_least: { + type: 'number', + description: 'Minimum rows without a LIMIT clause (only with FORMAT JSON)', + }, statistics: { type: 'json', description: 'Query execution statistics - elapsed time, rows read, bytes read (only with FORMAT JSON)', }, + // Data Source management outputs (append / truncate / delete) + id: { type: 'string', description: 'Operation identifier (append/delete)' }, + import_id: { type: 'string', description: 'Import identifier (append)' }, + job_id: { type: 'string', description: 'Job identifier to poll status (append/delete)' }, + delete_id: { type: 'string', description: 'Deletion identifier (delete)' }, + job_url: { type: 'string', description: 'URL to query job status (append/delete)' }, + status: { type: 'string', description: 'Current job status (append/delete)' }, + job: { + type: 'json', + description: 'Full job details: kind, id, status, datasource, rows_affected (append/delete)', + }, + datasource: { type: 'json', description: 'Target Data Source metadata (append)' }, + truncated: { type: 'boolean', description: 'Whether the Data Source was truncated' }, + result: { type: 'json', description: 'Raw truncate response body, if any' }, }, } diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index d13aa5d2bcc..10371ed1646 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -28,6 +28,7 @@ import { ChatTriggerBlock } from '@/blocks/blocks/chat_trigger' import { CirclebackBlock } from '@/blocks/blocks/circleback' import { ClayBlock } from '@/blocks/blocks/clay' import { ClerkBlock } from '@/blocks/blocks/clerk' +import { ClickHouseBlock } from '@/blocks/blocks/clickhouse' import { CloudflareBlock } from '@/blocks/blocks/cloudflare' import { CloudFormationBlock } from '@/blocks/blocks/cloudformation' import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch' @@ -285,6 +286,7 @@ export const registry: Record = { crowdstrike: CrowdStrikeBlock, clay: ClayBlock, clerk: ClerkBlock, + clickhouse: ClickHouseBlock, condition: ConditionBlock, credential: CredentialBlock, confluence: ConfluenceBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 49d1d7ddf38..dce91bf9720 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2743,6 +2743,18 @@ export function ClerkIcon(props: SVGProps) { ) } +export function ClickHouseIcon(props: SVGProps) { + return ( + + + + + ) +} + export function MicrosoftIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts b/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts new file mode 100644 index 00000000000..57158d3de8e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/databases/clickhouse.ts @@ -0,0 +1,319 @@ +import { z } from 'zod' +import { + introspectionResponseSchema, + nonEmptyRecordSchema, + sqlRowsResponseSchema, +} from '@/lib/api/contracts/tools/databases/shared' +import { + type ContractBodyInput, + type ContractJsonResponse, + defineRouteContract, +} from '@/lib/api/contracts/types' + +const secureFlagSchema = z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(true) + +export const clickhouseConnectionBodySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().default(''), + secure: secureFlagSchema, +}) + +export const clickhouseQueryBodySchema = clickhouseConnectionBodySchema.extend({ + query: z.string().min(1, 'Query is required'), +}) + +export const clickhouseExecuteBodySchema = clickhouseQueryBodySchema + +export const clickhouseInsertBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: nonEmptyRecordSchema('Data object cannot be empty'), +}) + +export const clickhouseUpdateBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + data: nonEmptyRecordSchema('Data object cannot be empty'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const clickhouseDeleteBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + where: z.string().min(1, 'WHERE clause is required'), +}) + +export const clickhouseIntrospectBodySchema = clickhouseConnectionBodySchema + +export const clickhouseQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/query', + body: clickhouseQueryBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseExecuteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/execute', + body: clickhouseExecuteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseInsertContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/insert', + body: clickhouseInsertBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseUpdateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/update', + body: clickhouseUpdateBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDeleteContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/delete', + body: clickhouseDeleteBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseIntrospectContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/introspect', + body: clickhouseIntrospectBodySchema, + response: { mode: 'json', schema: introspectionResponseSchema }, +}) + +const clickhouseTableBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), +}) + +const clickhouseCountResponseSchema = z.object({ + message: z.string(), + count: z.number(), +}) + +const clickhouseDdlResponseSchema = z.object({ + message: z.string(), + ddl: z.string(), +}) + +export const clickhouseListDatabasesBodySchema = clickhouseConnectionBodySchema +export const clickhouseListTablesBodySchema = clickhouseConnectionBodySchema +export const clickhouseDescribeTableBodySchema = clickhouseTableBodySchema +export const clickhouseShowCreateTableBodySchema = clickhouseTableBodySchema +export const clickhouseCountRowsBodySchema = clickhouseTableBodySchema.extend({ + where: z.string().optional(), +}) +export const clickhouseListPartitionsBodySchema = clickhouseTableBodySchema +export const clickhouseListMutationsBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().optional(), + onlyRunning: z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(false), +}) +export const clickhouseListRunningQueriesBodySchema = clickhouseConnectionBodySchema +export const clickhouseTableStatsBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().optional(), +}) +export const clickhouseListClustersBodySchema = clickhouseConnectionBodySchema +export const clickhouseCreateDatabaseBodySchema = clickhouseConnectionBodySchema.extend({ + name: z.string().min(1, 'Database name is required'), +}) +export const clickhouseDropDatabaseBodySchema = clickhouseConnectionBodySchema.extend({ + name: z.string().min(1, 'Database name is required'), +}) +export const clickhouseCreateTableBodySchema = clickhouseConnectionBodySchema.extend({ + table: z.string().min(1, 'Table name is required'), + columns: z + .array( + z.object({ + name: z.string().min(1, 'Column name is required'), + type: z.string().min(1, 'Column type is required'), + }) + ) + .min(1, 'At least one column is required'), + engine: z.string().min(1).default('MergeTree'), + orderBy: z.string().min(1, 'ORDER BY expression is required'), + partitionBy: z.string().optional(), +}) +export const clickhouseDropTableBodySchema = clickhouseTableBodySchema +export const clickhouseTruncateTableBodySchema = clickhouseTableBodySchema +export const clickhouseRenameTableBodySchema = clickhouseTableBodySchema.extend({ + newTable: z.string().min(1, 'New table name is required'), +}) +export const clickhouseOptimizeTableBodySchema = clickhouseTableBodySchema.extend({ + final: z + .union([z.boolean(), z.string()]) + .transform((value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value)) + .default(false), +}) +export const clickhouseDropPartitionBodySchema = clickhouseTableBodySchema.extend({ + partition: z.string().min(1, 'Partition expression is required'), +}) +export const clickhouseKillQueryBodySchema = clickhouseConnectionBodySchema.extend({ + queryId: z.string().min(1, 'Query ID is required'), +}) +export const clickhouseInsertRowsBodySchema = clickhouseTableBodySchema.extend({ + rows: z.array(z.record(z.string(), z.unknown())).min(1, 'At least one row is required'), +}) + +export const clickhouseListDatabasesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-databases', + body: clickhouseListDatabasesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-tables', + body: clickhouseListTablesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDescribeTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/describe-table', + body: clickhouseDescribeTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseShowCreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/show-create-table', + body: clickhouseShowCreateTableBodySchema, + response: { mode: 'json', schema: clickhouseDdlResponseSchema }, +}) + +export const clickhouseCountRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/count-rows', + body: clickhouseCountRowsBodySchema, + response: { mode: 'json', schema: clickhouseCountResponseSchema }, +}) + +export const clickhouseListPartitionsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-partitions', + body: clickhouseListPartitionsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListMutationsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-mutations', + body: clickhouseListMutationsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListRunningQueriesContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-running-queries', + body: clickhouseListRunningQueriesBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseTableStatsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/table-stats', + body: clickhouseTableStatsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseListClustersContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/list-clusters', + body: clickhouseListClustersBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseCreateDatabaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/create-database', + body: clickhouseCreateDatabaseBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropDatabaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-database', + body: clickhouseDropDatabaseBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseCreateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/create-table', + body: clickhouseCreateTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-table', + body: clickhouseDropTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseTruncateTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/truncate-table', + body: clickhouseTruncateTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseRenameTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/rename-table', + body: clickhouseRenameTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseOptimizeTableContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/optimize-table', + body: clickhouseOptimizeTableBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseDropPartitionContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/drop-partition', + body: clickhouseDropPartitionBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseKillQueryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/kill-query', + body: clickhouseKillQueryBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export const clickhouseInsertRowsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/clickhouse/insert-rows', + body: clickhouseInsertRowsBodySchema, + response: { mode: 'json', schema: sqlRowsResponseSchema }, +}) + +export type ClickHouseQueryRequest = ContractBodyInput +export type ClickHouseQueryResponse = ContractJsonResponse +export type ClickHouseExecuteRequest = ContractBodyInput +export type ClickHouseExecuteResponse = ContractJsonResponse +export type ClickHouseInsertRequest = ContractBodyInput +export type ClickHouseInsertResponse = ContractJsonResponse +export type ClickHouseUpdateRequest = ContractBodyInput +export type ClickHouseUpdateResponse = ContractJsonResponse +export type ClickHouseDeleteRequest = ContractBodyInput +export type ClickHouseDeleteResponse = ContractJsonResponse +export type ClickHouseIntrospectRequest = ContractBodyInput +export type ClickHouseIntrospectResponse = ContractJsonResponse diff --git a/apps/sim/tools/clickhouse/count-rows.ts b/apps/sim/tools/clickhouse/count-rows.ts new file mode 100644 index 00000000000..0bef7b83b59 --- /dev/null +++ b/apps/sim/tools/clickhouse/count-rows.ts @@ -0,0 +1,100 @@ +import type { ClickHouseCountResponse, ClickHouseCountRowsParams } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const countRowsTool: ToolConfig = { + id: 'clickhouse_count_rows', + name: 'ClickHouse Count Rows', + description: 'Count rows in a ClickHouse table, optionally filtered', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to count rows in', + }, + where: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional WHERE clause condition without the WHERE keyword', + }, + }, + + request: { + url: '/api/tools/clickhouse/count-rows', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse count rows failed') + } + + return { + success: true, + output: { + message: data.message || 'Row count retrieved', + count: data.count ?? 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + count: { type: 'number', description: 'Number of rows' }, + }, +} diff --git a/apps/sim/tools/clickhouse/create-database.ts b/apps/sim/tools/clickhouse/create-database.ts new file mode 100644 index 00000000000..422a646fcfd --- /dev/null +++ b/apps/sim/tools/clickhouse/create-database.ts @@ -0,0 +1,97 @@ +import type { + ClickHouseCreateDatabaseParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const createDatabaseTool: ToolConfig< + ClickHouseCreateDatabaseParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_create_database', + name: 'ClickHouse Create Database', + description: 'Create a new database on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the database to create', + }, + }, + + request: { + url: '/api/tools/clickhouse/create-database', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse create database failed') + } + + return { + success: true, + output: { + message: data.message || 'Database created', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/create-table.ts b/apps/sim/tools/clickhouse/create-table.ts new file mode 100644 index 00000000000..aff3deb4170 --- /dev/null +++ b/apps/sim/tools/clickhouse/create-table.ts @@ -0,0 +1,123 @@ +import type { + ClickHouseCreateTableParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const createTableTool: ToolConfig = { + id: 'clickhouse_create_table', + name: 'ClickHouse Create Table', + description: 'Create a new MergeTree-family table in ClickHouse', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to create', + }, + columns: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of column definitions, each an object with name and type, e.g. [{"name":"id","type":"UInt64"},{"name":"ts","type":"DateTime"}]', + }, + engine: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Table engine (default MergeTree)', + }, + orderBy: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ORDER BY expression, e.g. "id" or "(id, ts)"', + }, + partitionBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional PARTITION BY expression, e.g. toYYYYMM(ts)', + }, + }, + + request: { + url: '/api/tools/clickhouse/create-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + columns: params.columns, + engine: params.engine, + orderBy: params.orderBy, + partitionBy: params.partitionBy, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse create table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table created', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/delete.ts b/apps/sim/tools/clickhouse/delete.ts new file mode 100644 index 00000000000..3455d965ea9 --- /dev/null +++ b/apps/sim/tools/clickhouse/delete.ts @@ -0,0 +1,102 @@ +import type { ClickHouseDeleteParams, ClickHouseDeleteResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteTool: ToolConfig = { + id: 'clickhouse_delete', + name: 'ClickHouse Delete', + description: 'Delete rows from a ClickHouse table via an ALTER TABLE ... DELETE mutation', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to delete data from', + }, + where: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WHERE clause condition (without the WHERE keyword)', + }, + }, + + request: { + url: '/api/tools/clickhouse/delete', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse delete failed') + } + + return { + success: true, + output: { + message: data.message || 'Delete mutation submitted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Deleted rows (empty for ClickHouse mutations)' }, + rowCount: { type: 'number', description: 'Number of rows affected by the mutation' }, + }, +} diff --git a/apps/sim/tools/clickhouse/describe-table.ts b/apps/sim/tools/clickhouse/describe-table.ts new file mode 100644 index 00000000000..ed394defd25 --- /dev/null +++ b/apps/sim/tools/clickhouse/describe-table.ts @@ -0,0 +1,99 @@ +import type { + ClickHouseDescribeTableParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const describeTableTool: ToolConfig = + { + id: 'clickhouse_describe_table', + name: 'ClickHouse Describe Table', + description: 'Describe the columns of a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to describe', + }, + }, + + request: { + url: '/api/tools/clickhouse/describe-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse describe table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table described', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/drop-database.ts b/apps/sim/tools/clickhouse/drop-database.ts new file mode 100644 index 00000000000..3b644f81d13 --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-database.ts @@ -0,0 +1,95 @@ +import type { + ClickHouseDropDatabaseParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropDatabaseTool: ToolConfig = + { + id: 'clickhouse_drop_database', + name: 'ClickHouse Drop Database', + description: 'Drop a database from a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the database to drop', + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-database', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + name: params.name, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop database failed') + } + + return { + success: true, + output: { + message: data.message || 'Database dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, + } diff --git a/apps/sim/tools/clickhouse/drop-partition.ts b/apps/sim/tools/clickhouse/drop-partition.ts new file mode 100644 index 00000000000..dbee7aeefa3 --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-partition.ts @@ -0,0 +1,104 @@ +import type { + ClickHouseDropPartitionParams, + ClickHouseMessageResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropPartitionTool: ToolConfig< + ClickHouseDropPartitionParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_drop_partition', + name: 'ClickHouse Drop Partition', + description: 'Drop a partition from a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name', + }, + partition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Partition expression, e.g. '2024-01' or 202401", + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-partition', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + partition: params.partition, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop partition failed') + } + + return { + success: true, + output: { + message: data.message || 'Partition dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/drop-table.ts b/apps/sim/tools/clickhouse/drop-table.ts new file mode 100644 index 00000000000..dcf39b04e3e --- /dev/null +++ b/apps/sim/tools/clickhouse/drop-table.ts @@ -0,0 +1,91 @@ +import type { ClickHouseDropTableParams, ClickHouseMessageResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const dropTableTool: ToolConfig = { + id: 'clickhouse_drop_table', + name: 'ClickHouse Drop Table', + description: 'Drop a table from a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to drop', + }, + }, + + request: { + url: '/api/tools/clickhouse/drop-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse drop table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table dropped', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/execute.ts b/apps/sim/tools/clickhouse/execute.ts new file mode 100644 index 00000000000..a6455d8f874 --- /dev/null +++ b/apps/sim/tools/clickhouse/execute.ts @@ -0,0 +1,95 @@ +import type { ClickHouseExecuteParams, ClickHouseExecuteResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const executeTool: ToolConfig = { + id: 'clickhouse_execute', + name: 'ClickHouse Execute', + description: 'Execute raw SQL (DDL, mutations, or queries) on a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Raw SQL statement to execute', + }, + }, + + request: { + url: '/api/tools/clickhouse/execute', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse execute failed') + } + + return { + success: true, + output: { + message: data.message || 'Statement executed successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the statement' }, + rowCount: { type: 'number', description: 'Number of rows returned or affected' }, + }, +} diff --git a/apps/sim/tools/clickhouse/index.ts b/apps/sim/tools/clickhouse/index.ts new file mode 100644 index 00000000000..699fecc3925 --- /dev/null +++ b/apps/sim/tools/clickhouse/index.ts @@ -0,0 +1,53 @@ +import { countRowsTool } from './count-rows' +import { createDatabaseTool } from './create-database' +import { createTableTool } from './create-table' +import { deleteTool } from './delete' +import { describeTableTool } from './describe-table' +import { dropDatabaseTool } from './drop-database' +import { dropPartitionTool } from './drop-partition' +import { dropTableTool } from './drop-table' +import { executeTool } from './execute' +import { insertTool } from './insert' +import { insertRowsTool } from './insert-rows' +import { introspectTool } from './introspect' +import { killQueryTool } from './kill-query' +import { listClustersTool } from './list-clusters' +import { listDatabasesTool } from './list-databases' +import { listMutationsTool } from './list-mutations' +import { listPartitionsTool } from './list-partitions' +import { listRunningQueriesTool } from './list-running-queries' +import { listTablesTool } from './list-tables' +import { optimizeTableTool } from './optimize-table' +import { queryTool } from './query' +import { renameTableTool } from './rename-table' +import { showCreateTableTool } from './show-create-table' +import { tableStatsTool } from './table-stats' +import { truncateTableTool } from './truncate-table' +import { updateTool } from './update' + +export const clickhouseQueryTool = queryTool +export const clickhouseExecuteTool = executeTool +export const clickhouseInsertTool = insertTool +export const clickhouseInsertRowsTool = insertRowsTool +export const clickhouseUpdateTool = updateTool +export const clickhouseDeleteTool = deleteTool +export const clickhouseIntrospectTool = introspectTool +export const clickhouseListDatabasesTool = listDatabasesTool +export const clickhouseListTablesTool = listTablesTool +export const clickhouseDescribeTableTool = describeTableTool +export const clickhouseShowCreateTableTool = showCreateTableTool +export const clickhouseCountRowsTool = countRowsTool +export const clickhouseListPartitionsTool = listPartitionsTool +export const clickhouseListMutationsTool = listMutationsTool +export const clickhouseListRunningQueriesTool = listRunningQueriesTool +export const clickhouseTableStatsTool = tableStatsTool +export const clickhouseListClustersTool = listClustersTool +export const clickhouseCreateDatabaseTool = createDatabaseTool +export const clickhouseDropDatabaseTool = dropDatabaseTool +export const clickhouseCreateTableTool = createTableTool +export const clickhouseDropTableTool = dropTableTool +export const clickhouseTruncateTableTool = truncateTableTool +export const clickhouseRenameTableTool = renameTableTool +export const clickhouseOptimizeTableTool = optimizeTableTool +export const clickhouseDropPartitionTool = dropPartitionTool +export const clickhouseKillQueryTool = killQueryTool diff --git a/apps/sim/tools/clickhouse/insert-rows.ts b/apps/sim/tools/clickhouse/insert-rows.ts new file mode 100644 index 00000000000..981bb128c37 --- /dev/null +++ b/apps/sim/tools/clickhouse/insert-rows.ts @@ -0,0 +1,102 @@ +import type { ClickHouseInsertRowsParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const insertRowsTool: ToolConfig = { + id: 'clickhouse_insert_rows', + name: 'ClickHouse Insert Rows', + description: 'Insert multiple rows into a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to insert into', + }, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Array of row objects to insert, e.g. [{"id":1,"name":"a"},{"id":2,"name":"b"}]', + }, + }, + + request: { + url: '/api/tools/clickhouse/insert-rows', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + rows: params.rows, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse insert rows failed') + } + + return { + success: true, + output: { + message: data.message || 'Rows inserted', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Inserted rows (empty for ClickHouse inserts)' }, + rowCount: { type: 'number', description: 'Number of rows inserted' }, + }, +} diff --git a/apps/sim/tools/clickhouse/insert.ts b/apps/sim/tools/clickhouse/insert.ts new file mode 100644 index 00000000000..5c5ed2c60ca --- /dev/null +++ b/apps/sim/tools/clickhouse/insert.ts @@ -0,0 +1,102 @@ +import type { ClickHouseInsertParams, ClickHouseInsertResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const insertTool: ToolConfig = { + id: 'clickhouse_insert', + name: 'ClickHouse Insert', + description: 'Insert a row into a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to insert data into', + }, + data: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: 'Data object to insert (key-value pairs mapping column names to values)', + }, + }, + + request: { + url: '/api/tools/clickhouse/insert', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + data: params.data, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse insert failed') + } + + return { + success: true, + output: { + message: data.message || 'Data inserted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Inserted rows (empty for ClickHouse inserts)' }, + rowCount: { type: 'number', description: 'Number of rows inserted' }, + }, +} diff --git a/apps/sim/tools/clickhouse/introspect.ts b/apps/sim/tools/clickhouse/introspect.ts new file mode 100644 index 00000000000..e7f44dca020 --- /dev/null +++ b/apps/sim/tools/clickhouse/introspect.ts @@ -0,0 +1,99 @@ +import { + CLICKHOUSE_TABLE_OUTPUT_PROPERTIES, + type ClickHouseIntrospectParams, + type ClickHouseIntrospectResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const introspectTool: ToolConfig = + { + id: 'clickhouse_introspect', + name: 'ClickHouse Introspect', + description: + 'Introspect a ClickHouse database to retrieve table structures, columns, and engines', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to introspect', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/introspect', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse introspection failed') + } + + return { + success: true, + output: { + message: data.message || 'Schema introspection completed successfully', + tables: data.tables || [], + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + tables: { + type: 'array', + description: 'Array of table schemas with columns and engines', + items: { + type: 'object', + properties: CLICKHOUSE_TABLE_OUTPUT_PROPERTIES, + }, + }, + }, + } diff --git a/apps/sim/tools/clickhouse/kill-query.ts b/apps/sim/tools/clickhouse/kill-query.ts new file mode 100644 index 00000000000..ffbaef1a2b8 --- /dev/null +++ b/apps/sim/tools/clickhouse/kill-query.ts @@ -0,0 +1,95 @@ +import type { ClickHouseKillQueryParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const killQueryTool: ToolConfig = { + id: 'clickhouse_kill_query', + name: 'ClickHouse Kill Query', + description: 'Kill a running query by its query ID', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + queryId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The query_id of the running query to kill', + }, + }, + + request: { + url: '/api/tools/clickhouse/kill-query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + queryId: params.queryId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse kill query failed') + } + + return { + success: true, + output: { + message: data.message || 'Kill command executed', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Kill status rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-clusters.ts b/apps/sim/tools/clickhouse/list-clusters.ts new file mode 100644 index 00000000000..1b8e188f0d6 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-clusters.ts @@ -0,0 +1,88 @@ +import type { ClickHouseListClustersParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listClustersTool: ToolConfig = { + id: 'clickhouse_list_clusters', + name: 'ClickHouse List Clusters', + description: 'List configured clusters, shards, and replicas', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-clusters', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list clusters failed') + } + + return { + success: true, + output: { + message: data.message || 'Clusters retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of cluster node rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-databases.ts b/apps/sim/tools/clickhouse/list-databases.ts new file mode 100644 index 00000000000..2fcfb92cd3e --- /dev/null +++ b/apps/sim/tools/clickhouse/list-databases.ts @@ -0,0 +1,92 @@ +import type { + ClickHouseListDatabasesParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listDatabasesTool: ToolConfig = + { + id: 'clickhouse_list_databases', + name: 'ClickHouse List Databases', + description: 'List all databases on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-databases', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list databases failed') + } + + return { + success: true, + output: { + message: data.message || 'Databases retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'List of databases with engine and comment' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/list-mutations.ts b/apps/sim/tools/clickhouse/list-mutations.ts new file mode 100644 index 00000000000..0fac681667a --- /dev/null +++ b/apps/sim/tools/clickhouse/list-mutations.ts @@ -0,0 +1,106 @@ +import type { + ClickHouseListMutationsParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listMutationsTool: ToolConfig = + { + id: 'clickhouse_list_mutations', + name: 'ClickHouse List Mutations', + description: 'List mutations (async ALTER UPDATE/DELETE) for the connected database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional table name to filter mutations', + }, + onlyRunning: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only show mutations that are still running', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-mutations', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + onlyRunning: params.onlyRunning, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list mutations failed') + } + + return { + success: true, + output: { + message: data.message || 'Mutations retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of mutation rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + } diff --git a/apps/sim/tools/clickhouse/list-partitions.ts b/apps/sim/tools/clickhouse/list-partitions.ts new file mode 100644 index 00000000000..e2b92fd44ea --- /dev/null +++ b/apps/sim/tools/clickhouse/list-partitions.ts @@ -0,0 +1,101 @@ +import type { + ClickHouseListPartitionsParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listPartitionsTool: ToolConfig< + ClickHouseListPartitionsParams, + ClickHouseRowsResponse +> = { + id: 'clickhouse_list_partitions', + name: 'ClickHouse List Partitions', + description: 'List active partitions for a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to inspect partitions for', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-partitions', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list partitions failed') + } + + return { + success: true, + output: { + message: data.message || 'Partitions retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-running-queries.ts b/apps/sim/tools/clickhouse/list-running-queries.ts new file mode 100644 index 00000000000..cce6bbf21f3 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-running-queries.ts @@ -0,0 +1,94 @@ +import type { + ClickHouseListRunningQueriesParams, + ClickHouseRowsResponse, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listRunningQueriesTool: ToolConfig< + ClickHouseListRunningQueriesParams, + ClickHouseRowsResponse +> = { + id: 'clickhouse_list_running_queries', + name: 'ClickHouse List Running Queries', + description: 'List currently running queries on a ClickHouse server', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-running-queries', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list running queries failed') + } + + return { + success: true, + output: { + message: data.message || 'Running queries retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/list-tables.ts b/apps/sim/tools/clickhouse/list-tables.ts new file mode 100644 index 00000000000..b80a7f63cd0 --- /dev/null +++ b/apps/sim/tools/clickhouse/list-tables.ts @@ -0,0 +1,88 @@ +import type { ClickHouseListTablesParams, ClickHouseRowsResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const listTablesTool: ToolConfig = { + id: 'clickhouse_list_tables', + name: 'ClickHouse List Tables', + description: 'List tables in the connected ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + }, + + request: { + url: '/api/tools/clickhouse/list-tables', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse list tables failed') + } + + return { + success: true, + output: { + message: data.message || 'Tables retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/optimize-table.ts b/apps/sim/tools/clickhouse/optimize-table.ts new file mode 100644 index 00000000000..13632d61154 --- /dev/null +++ b/apps/sim/tools/clickhouse/optimize-table.ts @@ -0,0 +1,104 @@ +import type { + ClickHouseMessageResponse, + ClickHouseOptimizeTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const optimizeTableTool: ToolConfig< + ClickHouseOptimizeTableParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_optimize_table', + name: 'ClickHouse Optimize Table', + description: 'Trigger a merge of table parts via OPTIMIZE TABLE', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table to optimize', + }, + final: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Force a merge to a single part using FINAL', + }, + }, + + request: { + url: '/api/tools/clickhouse/optimize-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + final: params.final, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse optimize table failed') + } + + return { + success: true, + output: { + message: data.message || 'Optimize submitted', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/query.ts b/apps/sim/tools/clickhouse/query.ts new file mode 100644 index 00000000000..fb1e5b568b3 --- /dev/null +++ b/apps/sim/tools/clickhouse/query.ts @@ -0,0 +1,95 @@ +import type { ClickHouseQueryParams, ClickHouseQueryResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const queryTool: ToolConfig = { + id: 'clickhouse_query', + name: 'ClickHouse Query', + description: 'Execute a SELECT query on a ClickHouse database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'SQL SELECT query to execute', + }, + }, + + request: { + url: '/api/tools/clickhouse/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + query: params.query, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse query failed') + } + + return { + success: true, + output: { + message: data.message || 'Query executed successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of rows returned from the query' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/rename-table.ts b/apps/sim/tools/clickhouse/rename-table.ts new file mode 100644 index 00000000000..11ddce83644 --- /dev/null +++ b/apps/sim/tools/clickhouse/rename-table.ts @@ -0,0 +1,101 @@ +import type { + ClickHouseMessageResponse, + ClickHouseRenameTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const renameTableTool: ToolConfig = { + id: 'clickhouse_rename_table', + name: 'ClickHouse Rename Table', + description: 'Rename a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Current table name', + }, + newTable: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New table name', + }, + }, + + request: { + url: '/api/tools/clickhouse/rename-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + newTable: params.newTable, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse rename table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table renamed', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/show-create-table.ts b/apps/sim/tools/clickhouse/show-create-table.ts new file mode 100644 index 00000000000..fe8ec40fd76 --- /dev/null +++ b/apps/sim/tools/clickhouse/show-create-table.ts @@ -0,0 +1,99 @@ +import type { + ClickHouseDdlResponse, + ClickHouseShowCreateTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const showCreateTableTool: ToolConfig< + ClickHouseShowCreateTableParams, + ClickHouseDdlResponse +> = { + id: 'clickhouse_show_create_table', + name: 'ClickHouse Show Create Table', + description: 'Get the CREATE TABLE statement (DDL) for a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to get the CREATE statement for', + }, + }, + + request: { + url: '/api/tools/clickhouse/show-create-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse show create table failed') + } + + return { + success: true, + output: { + message: data.message || 'CREATE statement retrieved', + ddl: data.ddl ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + ddl: { type: 'string', description: 'The CREATE TABLE statement' }, + }, +} diff --git a/apps/sim/tools/clickhouse/table-stats.ts b/apps/sim/tools/clickhouse/table-stats.ts new file mode 100644 index 00000000000..2c24f53ee7b --- /dev/null +++ b/apps/sim/tools/clickhouse/table-stats.ts @@ -0,0 +1,95 @@ +import type { ClickHouseRowsResponse, ClickHouseTableStatsParams } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const tableStatsTool: ToolConfig = { + id: 'clickhouse_table_stats', + name: 'ClickHouse Table Stats', + description: 'Get row counts and on-disk size for tables in the connected database', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional table name to get stats for', + }, + }, + + request: { + url: '/api/tools/clickhouse/table-stats', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse table stats failed') + } + + return { + success: true, + output: { + message: data.message || 'Table stats retrieved', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Array of table stats rows' }, + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, +} diff --git a/apps/sim/tools/clickhouse/truncate-table.ts b/apps/sim/tools/clickhouse/truncate-table.ts new file mode 100644 index 00000000000..cf653823fa9 --- /dev/null +++ b/apps/sim/tools/clickhouse/truncate-table.ts @@ -0,0 +1,97 @@ +import type { + ClickHouseMessageResponse, + ClickHouseTruncateTableParams, +} from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const truncateTableTool: ToolConfig< + ClickHouseTruncateTableParams, + ClickHouseMessageResponse +> = { + id: 'clickhouse_truncate_table', + name: 'ClickHouse Truncate Table', + description: 'Remove all rows from a ClickHouse table', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to truncate', + }, + }, + + request: { + url: '/api/tools/clickhouse/truncate-table', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse truncate table failed') + } + + return { + success: true, + output: { + message: data.message || 'Table truncated', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + }, +} diff --git a/apps/sim/tools/clickhouse/types.ts b/apps/sim/tools/clickhouse/types.ts new file mode 100644 index 00000000000..99258d04f7e --- /dev/null +++ b/apps/sim/tools/clickhouse/types.ts @@ -0,0 +1,213 @@ +import type { OutputProperty, ToolResponse } from '@/tools/types' + +/** + * Output property definitions for ClickHouse introspection and query responses. + * @see https://clickhouse.com/docs/sql-reference/statements/system + */ + +/** + * Output definition for table column objects from introspection. + * @see https://clickhouse.com/docs/operations/system-tables/columns + */ +export const CLICKHOUSE_COLUMN_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'ClickHouse data type (e.g., UInt32, String, DateTime)' }, + defaultKind: { + type: 'string', + description: 'Kind of default expression (DEFAULT, MATERIALIZED, ALIAS)', + optional: true, + }, + defaultExpression: { + type: 'string', + description: 'Default value expression for the column', + optional: true, + }, + isInPrimaryKey: { type: 'boolean', description: 'Whether the column is part of the primary key' }, + isInSortingKey: { type: 'boolean', description: 'Whether the column is part of the sorting key' }, +} as const satisfies Record + +/** + * Output definition for table schema objects from introspection. + * @see https://clickhouse.com/docs/operations/system-tables/tables + */ +export const CLICKHOUSE_TABLE_OUTPUT_PROPERTIES = { + name: { type: 'string', description: 'Table name' }, + database: { type: 'string', description: 'Database the table belongs to' }, + engine: { type: 'string', description: 'Table engine (e.g., MergeTree, Log)' }, + totalRows: { + type: 'number', + description: 'Approximate total number of rows in the table', + optional: true, + }, + columns: { + type: 'array', + description: 'Table columns', + items: { + type: 'object', + properties: CLICKHOUSE_COLUMN_OUTPUT_PROPERTIES, + }, + }, +} as const satisfies Record + +export interface ClickHouseConnectionConfig { + host: string + port: number + database: string + username: string + password: string + secure: boolean +} + +export interface ClickHouseQueryParams extends ClickHouseConnectionConfig { + query: string +} + +export interface ClickHouseExecuteParams extends ClickHouseConnectionConfig { + query: string +} + +export interface ClickHouseInsertParams extends ClickHouseConnectionConfig { + table: string + data: Record +} + +export interface ClickHouseUpdateParams extends ClickHouseConnectionConfig { + table: string + data: Record + where: string +} + +export interface ClickHouseDeleteParams extends ClickHouseConnectionConfig { + table: string + where: string +} + +export interface ClickHouseIntrospectParams extends ClickHouseConnectionConfig {} + +export interface ClickHouseRowsResponse extends ToolResponse { + output: { + message: string + rows: unknown[] + rowCount: number + } + error?: string +} + +export interface ClickHouseMessageResponse extends ToolResponse { + output: { + message: string + } + error?: string +} + +export interface ClickHouseCountResponse extends ToolResponse { + output: { + message: string + count: number + } + error?: string +} + +export interface ClickHouseDdlResponse extends ToolResponse { + output: { + message: string + ddl: string + } + error?: string +} + +export interface ClickHouseListDatabasesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseListTablesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseDescribeTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseShowCreateTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseCountRowsParams extends ClickHouseConnectionConfig { + table: string + where?: string +} +export interface ClickHouseListPartitionsParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseListMutationsParams extends ClickHouseConnectionConfig { + table?: string + onlyRunning?: boolean +} +export interface ClickHouseListRunningQueriesParams extends ClickHouseConnectionConfig {} +export interface ClickHouseTableStatsParams extends ClickHouseConnectionConfig { + table?: string +} +export interface ClickHouseListClustersParams extends ClickHouseConnectionConfig {} +export interface ClickHouseCreateDatabaseParams extends ClickHouseConnectionConfig { + name: string +} +export interface ClickHouseDropDatabaseParams extends ClickHouseConnectionConfig { + name: string +} +export interface ClickHouseCreateTableParams extends ClickHouseConnectionConfig { + table: string + columns: Array<{ name: string; type: string }> + engine: string + orderBy: string + partitionBy?: string +} +export interface ClickHouseDropTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseTruncateTableParams extends ClickHouseConnectionConfig { + table: string +} +export interface ClickHouseRenameTableParams extends ClickHouseConnectionConfig { + table: string + newTable: string +} +export interface ClickHouseOptimizeTableParams extends ClickHouseConnectionConfig { + table: string + final?: boolean +} +export interface ClickHouseDropPartitionParams extends ClickHouseConnectionConfig { + table: string + partition: string +} +export interface ClickHouseKillQueryParams extends ClickHouseConnectionConfig { + queryId: string +} +export interface ClickHouseInsertRowsParams extends ClickHouseConnectionConfig { + table: string + rows: Array> +} + +export interface ClickHouseQueryResponse extends ClickHouseRowsResponse {} +export interface ClickHouseExecuteResponse extends ClickHouseRowsResponse {} +export interface ClickHouseInsertResponse extends ClickHouseRowsResponse {} +export interface ClickHouseUpdateResponse extends ClickHouseRowsResponse {} +export interface ClickHouseDeleteResponse extends ClickHouseRowsResponse {} + +interface ClickHouseTableColumn { + name: string + type: string + defaultKind?: string + defaultExpression?: string + isInPrimaryKey: boolean + isInSortingKey: boolean +} + +interface ClickHouseTableSchema { + name: string + database: string + engine: string + totalRows?: number + columns: ClickHouseTableColumn[] +} + +export interface ClickHouseIntrospectResponse extends ToolResponse { + output: { + message: string + tables: ClickHouseTableSchema[] + } + error?: string +} + +export interface ClickHouseResponse extends ClickHouseRowsResponse {} diff --git a/apps/sim/tools/clickhouse/update.ts b/apps/sim/tools/clickhouse/update.ts new file mode 100644 index 00000000000..79d766cf776 --- /dev/null +++ b/apps/sim/tools/clickhouse/update.ts @@ -0,0 +1,109 @@ +import type { ClickHouseUpdateParams, ClickHouseUpdateResponse } from '@/tools/clickhouse/types' +import type { ToolConfig } from '@/tools/types' + +export const updateTool: ToolConfig = { + id: 'clickhouse_update', + name: 'ClickHouse Update', + description: 'Update rows in a ClickHouse table via an ALTER TABLE ... UPDATE mutation', + version: '1.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse server hostname (e.g., your-instance.clickhouse.cloud)', + }, + port: { + type: 'number', + required: true, + visibility: 'user-only', + description: 'ClickHouse HTTP interface port (8443 for HTTPS, 8123 for HTTP)', + }, + database: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Database name to connect to', + }, + username: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'ClickHouse username', + }, + password: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'ClickHouse password', + }, + secure: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Use a secure HTTPS connection (default: true)', + }, + table: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Table name to update data in', + }, + data: { + type: 'object', + required: true, + visibility: 'user-or-llm', + description: 'Data object with fields to update (key-value pairs)', + }, + where: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WHERE clause condition (without the WHERE keyword)', + }, + }, + + request: { + url: '/api/tools/clickhouse/update', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + host: params.host, + port: Number(params.port), + database: params.database, + username: params.username, + password: params.password, + secure: params.secure, + table: params.table, + data: params.data, + where: params.where, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'ClickHouse update failed') + } + + return { + success: true, + output: { + message: data.message || 'Update mutation submitted successfully', + rows: data.rows || [], + rowCount: data.rowCount || 0, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + rows: { type: 'array', description: 'Updated rows (empty for ClickHouse mutations)' }, + rowCount: { type: 'number', description: 'Number of rows written by the mutation' }, + }, +} diff --git a/apps/sim/tools/dagster/delete_run.ts b/apps/sim/tools/dagster/delete_run.ts index 202bebecff4..e6975b4c21d 100644 --- a/apps/sim/tools/dagster/delete_run.ts +++ b/apps/sim/tools/dagster/delete_run.ts @@ -1,5 +1,10 @@ import type { DagsterDeleteRunParams, DagsterDeleteRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DeleteRunResult { @@ -57,13 +62,9 @@ export const deleteRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: DELETE_RUN_MUTATION, variables: { runId: params.runId }, diff --git a/apps/sim/tools/dagster/get_asset.ts b/apps/sim/tools/dagster/get_asset.ts new file mode 100644 index 00000000000..cfa836a85d9 --- /dev/null +++ b/apps/sim/tools/dagster/get_asset.ts @@ -0,0 +1,167 @@ +import type { DagsterGetAssetParams, DagsterGetAssetResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +/** Fields selected on `assetOrError` when the union resolves to `Asset`. */ +interface DagsterGetAssetGraphqlAsset { + key: { path: string[] } + definition: { + groupName: string | null + description: string | null + jobNames: string[] | null + computeKind: string | null + isPartitioned: boolean | null + } | null + assetMaterializations: Array<{ + runId: string + timestamp: string + partition: string | null + stepKey: string | null + }> | null +} + +const GET_ASSET_QUERY = ` + query GetAsset($assetKey: AssetKeyInput!) { + assetOrError(assetKey: $assetKey) { + ... on Asset { + key { + path + } + definition { + groupName + description + jobNames + computeKind + isPartitioned + } + assetMaterializations(limit: 1) { + runId + timestamp + partition + stepKey + } + } + ... on AssetNotFoundError { + __typename + message + } + } + } +` + +export const getAssetTool: ToolConfig = { + id: 'dagster_get_asset', + name: 'Dagster Get Asset', + description: 'Get an asset definition and its latest materialization by asset key.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key, e.g. "my_asset" or "raw/events"', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => ({ + query: GET_ASSET_QUERY, + variables: { assetKey: { path: parseAssetKeyPath(params.assetKey) } }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ assetOrError?: unknown }>(response) + + const raw = data.data?.assetOrError + if (!raw || typeof raw !== 'object') throw new Error('Unexpected response from Dagster') + + if (!('key' in raw)) { + const errResult = raw as { message?: string } + throw new Error(errResult.message ?? 'Asset not found') + } + + const asset = raw as DagsterGetAssetGraphqlAsset + const latest = asset.assetMaterializations?.[0] ?? null + + return { + success: true, + output: { + assetKey: asset.key.path.join('/'), + path: asset.key.path, + groupName: asset.definition?.groupName ?? null, + description: asset.definition?.description ?? null, + jobNames: asset.definition?.jobNames ?? null, + computeKind: asset.definition?.computeKind ?? null, + isPartitioned: asset.definition?.isPartitioned ?? null, + latestMaterialization: latest + ? { + runId: latest.runId, + timestamp: latest.timestamp, + partition: latest.partition ?? null, + stepKey: latest.stepKey ?? null, + } + : null, + }, + } + }, + + outputs: { + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + groupName: { + type: 'string', + description: 'Asset group the definition belongs to', + optional: true, + }, + description: { type: 'string', description: 'Asset description', optional: true }, + jobNames: { + type: 'json', + description: 'Names of jobs that can materialize this asset', + optional: true, + }, + computeKind: { + type: 'string', + description: 'Compute kind tag (e.g., python, dbt, spark)', + optional: true, + }, + isPartitioned: { + type: 'boolean', + description: 'Whether the asset is partitioned', + optional: true, + }, + latestMaterialization: { + type: 'json', + description: 'Most recent materialization (runId, timestamp, partition, stepKey)', + optional: true, + properties: { + runId: { type: 'string', description: 'Run that produced the materialization' }, + timestamp: { type: 'string', description: 'Materialization timestamp (epoch ms string)' }, + partition: { type: 'string', description: 'Partition key, if partitioned', optional: true }, + stepKey: { type: 'string', description: 'Step key that emitted it', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/dagster/get_run.ts b/apps/sim/tools/dagster/get_run.ts index ca22ae2dfba..b19a3caa41a 100644 --- a/apps/sim/tools/dagster/get_run.ts +++ b/apps/sim/tools/dagster/get_run.ts @@ -1,5 +1,10 @@ import type { DagsterGetRunParams, DagsterGetRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' /** Fields selected on `runOrError` when the union resolves to `Run`. */ @@ -7,8 +12,15 @@ interface DagsterGetRunGraphqlRun { runId: string jobName: string | null status: string + mode: string | null startTime: number | null endTime: number | null + creationTime: number | null + updateTime: number | null + parentRunId: string | null + rootRunId: string | null + canTerminate: boolean + assetSelection: Array<{ path: string[] }> | null runConfigYaml: string | null tags: Array<{ key: string; value: string }> | null } @@ -20,8 +32,17 @@ const GET_RUN_QUERY = ` runId jobName status + mode startTime endTime + creationTime + updateTime + parentRunId + rootRunId + canTerminate + assetSelection { + path + } runConfigYaml tags { key @@ -69,13 +90,9 @@ export const getRunTool: ToolConfig }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: GET_RUN_QUERY, variables: { runId: params.runId }, @@ -102,8 +119,17 @@ export const getRunTool: ToolConfig runId: run.runId, jobName: run.jobName ?? null, status: run.status, + mode: run.mode ?? null, startTime: run.startTime ?? null, endTime: run.endTime ?? null, + creationTime: run.creationTime ?? null, + updateTime: run.updateTime ?? null, + parentRunId: run.parentRunId ?? null, + rootRunId: run.rootRunId ?? null, + canTerminate: run.canTerminate ?? false, + assetSelection: run.assetSelection + ? run.assetSelection.map((key) => key.path.join('/')) + : null, runConfigYaml: run.runConfigYaml ?? null, tags: run.tags ?? null, }, @@ -125,6 +151,11 @@ export const getRunTool: ToolConfig description: 'Run status (QUEUED, NOT_STARTED, STARTING, MANAGED, STARTED, SUCCESS, FAILURE, CANCELING, CANCELED)', }, + mode: { + type: 'string', + description: 'Execution mode of the run', + optional: true, + }, startTime: { type: 'number', description: 'Run start time as Unix timestamp', @@ -135,6 +166,35 @@ export const getRunTool: ToolConfig description: 'Run end time as Unix timestamp', optional: true, }, + creationTime: { + type: 'number', + description: 'Time the run was created as Unix timestamp', + optional: true, + }, + updateTime: { + type: 'number', + description: 'Time the run was last updated as Unix timestamp', + optional: true, + }, + parentRunId: { + type: 'string', + description: 'ID of the immediate parent run (for re-executions)', + optional: true, + }, + rootRunId: { + type: 'string', + description: 'ID of the root run in the re-execution group', + optional: true, + }, + canTerminate: { + type: 'boolean', + description: 'Whether the run can currently be terminated', + }, + assetSelection: { + type: 'json', + description: 'Asset keys targeted by the run, as slash-joined strings', + optional: true, + }, runConfigYaml: { type: 'string', description: 'Run configuration as YAML', diff --git a/apps/sim/tools/dagster/get_run_logs.ts b/apps/sim/tools/dagster/get_run_logs.ts index accbc24038f..aef20e14807 100644 --- a/apps/sim/tools/dagster/get_run_logs.ts +++ b/apps/sim/tools/dagster/get_run_logs.ts @@ -1,5 +1,9 @@ import type { DagsterGetRunLogsParams, DagsterGetRunLogsResponse } from '@/tools/dagster/types' -import { parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DagsterRunEvent { @@ -87,13 +91,9 @@ export const getRunLogsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const variables: Record = { runId: params.runId } if (params.afterCursor) variables.afterCursor = params.afterCursor diff --git a/apps/sim/tools/dagster/index.ts b/apps/sim/tools/dagster/index.ts index d189f4a4ae0..697eab984e6 100644 --- a/apps/sim/tools/dagster/index.ts +++ b/apps/sim/tools/dagster/index.ts @@ -1,17 +1,22 @@ import { deleteRunTool } from '@/tools/dagster/delete_run' +import { getAssetTool } from '@/tools/dagster/get_asset' import { getRunTool } from '@/tools/dagster/get_run' import { getRunLogsTool } from '@/tools/dagster/get_run_logs' import { launchRunTool } from '@/tools/dagster/launch_run' +import { listAssetsTool } from '@/tools/dagster/list_assets' import { listJobsTool } from '@/tools/dagster/list_jobs' import { listRunsTool } from '@/tools/dagster/list_runs' import { listSchedulesTool } from '@/tools/dagster/list_schedules' import { listSensorsTool } from '@/tools/dagster/list_sensors' +import { materializeAssetsTool } from '@/tools/dagster/materialize_assets' import { reexecuteRunTool } from '@/tools/dagster/reexecute_run' +import { reportAssetMaterializationTool } from '@/tools/dagster/report_asset_materialization' import { startScheduleTool } from '@/tools/dagster/start_schedule' import { startSensorTool } from '@/tools/dagster/start_sensor' import { stopScheduleTool } from '@/tools/dagster/stop_schedule' import { stopSensorTool } from '@/tools/dagster/stop_sensor' import { terminateRunTool } from '@/tools/dagster/terminate_run' +import { wipeAssetTool } from '@/tools/dagster/wipe_asset' export const dagsterLaunchRunTool = launchRunTool export const dagsterGetRunTool = getRunTool @@ -27,5 +32,10 @@ export const dagsterStopScheduleTool = stopScheduleTool export const dagsterListSensorsTool = listSensorsTool export const dagsterStartSensorTool = startSensorTool export const dagsterStopSensorTool = stopSensorTool +export const dagsterListAssetsTool = listAssetsTool +export const dagsterGetAssetTool = getAssetTool +export const dagsterMaterializeAssetsTool = materializeAssetsTool +export const dagsterReportAssetMaterializationTool = reportAssetMaterializationTool +export const dagsterWipeAssetTool = wipeAssetTool export * from './types' diff --git a/apps/sim/tools/dagster/launch_run.ts b/apps/sim/tools/dagster/launch_run.ts index c71cb17d1d3..e58f5ea3d7c 100644 --- a/apps/sim/tools/dagster/launch_run.ts +++ b/apps/sim/tools/dagster/launch_run.ts @@ -1,5 +1,10 @@ import type { DagsterLaunchRunParams, DagsterLaunchRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface LaunchRunResult { @@ -143,13 +148,9 @@ export const launchRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const variables: Record = { repositoryLocationName: params.repositoryLocationName, diff --git a/apps/sim/tools/dagster/list_assets.ts b/apps/sim/tools/dagster/list_assets.ts new file mode 100644 index 00000000000..8b1db2ddfb5 --- /dev/null +++ b/apps/sim/tools/dagster/list_assets.ts @@ -0,0 +1,147 @@ +import type { DagsterListAssetsParams, DagsterListAssetsResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +/** Default page size applied when the caller omits `limit`, so paging stays bounded and `hasMore` is meaningful. */ +const DEFAULT_LIST_ASSETS_LIMIT = 100 + +/** Shape of each asset node in the `assetsOrError` → `AssetConnection.nodes` selection set. */ +interface DagsterAssetGraphqlNode { + key: { path: string[] } +} + +const LIST_ASSETS_QUERY = ` + query ListAssets($cursor: String, $limit: Int, $prefix: [String!]) { + assetsOrError(cursor: $cursor, limit: $limit, prefix: $prefix) { + ... on AssetConnection { + nodes { + key { + path + } + } + cursor + } + ... on PythonError { + __typename + message + } + } + } +` + +export const listAssetsTool: ToolConfig = { + id: 'dagster_list_assets', + name: 'Dagster List Assets', + description: 'List assets tracked by a Dagster instance, optionally filtered by key prefix.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + prefix: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Slash-delimited asset key prefix to filter by, e.g. "raw" or "raw/events" (optional)', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Asset key cursor from a previous response, for pagination (optional)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of assets to return per page (default 100)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const pageSize = params.limit ?? DEFAULT_LIST_ASSETS_LIMIT + // Request one extra row so `hasMore` is exact even when the final page is exactly `pageSize` long. + const variables: Record = { limit: pageSize + 1 } + if (params.prefix) variables.prefix = parseAssetKeyPath(params.prefix) + if (params.cursor) variables.cursor = params.cursor + return { query: LIST_ASSETS_QUERY, variables } + }, + }, + + transformResponse: async (response: Response, params?: DagsterListAssetsParams) => { + const data = await parseDagsterGraphqlResponse<{ assetsOrError?: unknown }>(response) + + const result = data.data?.assetsOrError as + | { nodes?: DagsterAssetGraphqlNode[]; cursor?: string | null; message?: string } + | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (!Array.isArray(result.nodes)) { + throw new Error(dagsterUnionErrorMessage(result, 'List assets failed')) + } + + const pageSize = params?.limit ?? DEFAULT_LIST_ASSETS_LIMIT + const hasMore = result.nodes.length > pageSize + const pageNodes = hasMore ? result.nodes.slice(0, pageSize) : result.nodes + + const assets = pageNodes.map((node) => ({ + assetKey: node.key.path.join('/'), + path: node.key.path, + })) + + // Asset cursors are the JSON-serialized key path; Dagster normalizes JS/Python whitespace on the + // way back in, so we derive the cursor from the last RETURNED asset (not the extra probe row). + const lastPath = pageNodes.length > 0 ? pageNodes[pageNodes.length - 1].key.path : null + + return { + success: true, + output: { + assets, + cursor: lastPath ? JSON.stringify(lastPath) : null, + hasMore, + }, + } + }, + + outputs: { + assets: { + type: 'json', + description: 'Array of assets (assetKey, path)', + properties: { + assetKey: { type: 'string', description: 'Slash-joined asset key' }, + path: { type: 'json', description: 'Asset key path segments' }, + }, + }, + cursor: { + type: 'string', + description: 'Cursor to pass on the next call to fetch more assets', + optional: true, + }, + hasMore: { + type: 'boolean', + description: 'Whether more assets are likely available beyond this page', + }, + }, +} diff --git a/apps/sim/tools/dagster/list_jobs.ts b/apps/sim/tools/dagster/list_jobs.ts index 87df1cdae47..c4461f6add4 100644 --- a/apps/sim/tools/dagster/list_jobs.ts +++ b/apps/sim/tools/dagster/list_jobs.ts @@ -1,5 +1,10 @@ import type { DagsterBaseParams, DagsterListJobsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' const LIST_JOBS_QUERY = ` @@ -48,13 +53,9 @@ export const listJobsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: () => ({ query: LIST_JOBS_QUERY, variables: {}, diff --git a/apps/sim/tools/dagster/list_runs.ts b/apps/sim/tools/dagster/list_runs.ts index 49bb06f927c..273fa2e5c34 100644 --- a/apps/sim/tools/dagster/list_runs.ts +++ b/apps/sim/tools/dagster/list_runs.ts @@ -1,7 +1,15 @@ import type { DagsterListRunsParams, DagsterListRunsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' +/** Default page size applied when the caller omits `limit`, so paging stays bounded and `hasMore` is meaningful. */ +const DEFAULT_LIST_RUNS_LIMIT = 20 + /** Shape of each run in the `runsOrError` → `Runs.results` GraphQL selection set. */ interface DagsterListRunsGraphqlRow { runId: string @@ -14,8 +22,8 @@ interface DagsterListRunsGraphqlRow { function buildListRunsQuery(hasFilter: boolean) { return ` - query ListRuns($limit: Int${hasFilter ? ', $filter: RunsFilter' : ''}) { - runsOrError(limit: $limit${hasFilter ? ', filter: $filter' : ''}) { + query ListRuns($limit: Int, $cursor: String${hasFilter ? ', $filter: RunsFilter' : ''}) { + runsOrError(limit: $limit, cursor: $cursor${hasFilter ? ', filter: $filter' : ''}) { ... on Runs { results { runId @@ -45,7 +53,8 @@ function buildListRunsQuery(hasFilter: boolean) { export const listRunsTool: ToolConfig = { id: 'dagster_list_runs', name: 'Dagster List Runs', - description: 'List recent Dagster runs, optionally filtered by job name.', + description: + 'List Dagster runs with optional filters by job name, status, and creation-time range, plus cursor pagination.', version: '1.0.0', params: { @@ -74,6 +83,25 @@ export const listRunsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const filter: Record = {} if (params.jobName) filter.pipelineName = params.jobName @@ -99,9 +123,14 @@ export const listRunsTool: ToolConfig s.trim()) .filter(Boolean) } + if (params.createdAfter != null) filter.createdAfter = params.createdAfter + if (params.createdBefore != null) filter.createdBefore = params.createdBefore const hasFilter = Object.keys(filter).length > 0 - const variables: Record = { limit: params.limit || 20 } + const pageSize = params.limit || DEFAULT_LIST_RUNS_LIMIT + // Request one extra row so `hasMore` is exact even when the final page is exactly `pageSize` long. + const variables: Record = { limit: pageSize + 1 } + if (params.cursor) variables.cursor = params.cursor if (hasFilter) variables.filter = filter return { @@ -111,7 +140,7 @@ export const listRunsTool: ToolConfig { + transformResponse: async (response: Response, params?: DagsterListRunsParams) => { const data = await parseDagsterGraphqlResponse<{ runsOrError?: unknown }>(response) const result = data.data?.runsOrError as @@ -123,7 +152,11 @@ export const listRunsTool: ToolConfig ({ + const pageSize = params?.limit || DEFAULT_LIST_RUNS_LIMIT + const hasMore = result.results.length > pageSize + const pageRows = hasMore ? result.results.slice(0, pageSize) : result.results + + const runs = pageRows.map((r: DagsterListRunsGraphqlRow) => ({ runId: r.runId, jobName: r.jobName ?? null, status: r.status, @@ -134,7 +167,11 @@ export const listRunsTool: ToolConfig 0 ? runs[runs.length - 1].runId : null, + hasMore, + }, } }, @@ -151,5 +188,14 @@ export const listRunsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const hasStatus = Boolean(params.scheduleStatus) const variables: Record = { diff --git a/apps/sim/tools/dagster/list_sensors.ts b/apps/sim/tools/dagster/list_sensors.ts index 34372a6bdad..e5f0df34bcc 100644 --- a/apps/sim/tools/dagster/list_sensors.ts +++ b/apps/sim/tools/dagster/list_sensors.ts @@ -1,5 +1,10 @@ import type { DagsterListSensorsParams, DagsterListSensorsResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface DagsterSensorGraphql { @@ -81,13 +86,9 @@ export const listSensorsTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => { const hasStatus = Boolean(params.sensorStatus) const variables: Record = { @@ -136,7 +137,7 @@ export const listSensorsTool: ToolConfig +} + +function buildMaterializeMutation(hasTags: boolean) { + const varDefs = [ + '$repositoryLocationName: String!', + '$repositoryName: String!', + '$jobName: String!', + '$assetSelection: [AssetKeyInput!]', + ] + if (hasTags) varDefs.push('$tags: [ExecutionTag!]') + + const execParams = [ + `selector: { + repositoryLocationName: $repositoryLocationName + repositoryName: $repositoryName + jobName: $jobName + assetSelection: $assetSelection + }`, + ] + if (hasTags) execParams.push('executionMetadata: { tags: $tags }') + + return ` + mutation MaterializeAssets(${varDefs.join(', ')}) { + launchRun( + executionParams: { + ${execParams.join('\n ')} + } + ) { + type: __typename + ... on LaunchRunSuccess { + run { + runId + } + } + ... on RunConfigValidationInvalid { + errors { + message + } + } + ... on PipelineNotFoundError { + message + } + ... on InvalidSubsetError { + message + } + ... on UnauthorizedError { + message + } + ... on ConflictingExecutionParamsError { + message + } + ... on PresetNotFoundError { + message + } + ... on RunConflict { + message + } + ... on PythonError { + message + } + } + } + ` +} + +export const materializeAssetsTool: ToolConfig< + DagsterMaterializeAssetsParams, + DagsterMaterializeAssetsResponse +> = { + id: 'dagster_materialize_assets', + name: 'Dagster Materialize Assets', + description: 'Materialize selected assets by launching their asset job with an asset selection.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + repositoryLocationName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Repository location (code location) name', + }, + repositoryName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Repository name within the code location', + }, + jobName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Asset job that contains the assets, e.g. "__ASSET_JOB" or a named asset job', + }, + assetSelection: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma- or newline-separated asset keys to materialize, each slash-delimited (e.g. "raw/events, summary")', + }, + tags: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tags as a JSON array of {key, value} objects (optional)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const assetSelection = parseAssetSelection(params.assetSelection) + if (assetSelection.length === 0) { + throw new Error('assetSelection must contain at least one asset key') + } + + const variables: Record = { + repositoryLocationName: params.repositoryLocationName, + repositoryName: params.repositoryName, + jobName: params.jobName, + assetSelection, + } + + let hasTags = false + if (params.tags) { + try { + variables.tags = JSON.parse(params.tags) + hasTags = true + } catch { + throw new Error('Invalid JSON in tags') + } + } + + return { query: buildMaterializeMutation(hasTags), variables } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ launchRun?: unknown }>(response) + + const result = data.data?.launchRun as MaterializeAssetsResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'LaunchRunSuccess' && result.run) { + return { + success: true, + output: { runId: result.run.runId }, + } + } + + if (result.type === 'RunConfigValidationInvalid' && result.errors?.length) { + throw new Error( + `RunConfigValidationInvalid: ${result.errors.map((e) => e.message).join('; ')}` + ) + } + + throw new Error( + `${result.type}: ${dagsterUnionErrorMessage(result, 'Materialize assets failed')}` + ) + }, + + outputs: { + runId: { + type: 'string', + description: 'The globally unique ID of the launched materialization run', + }, + }, +} diff --git a/apps/sim/tools/dagster/reexecute_run.ts b/apps/sim/tools/dagster/reexecute_run.ts index f3ce6d3a366..5815cb5776c 100644 --- a/apps/sim/tools/dagster/reexecute_run.ts +++ b/apps/sim/tools/dagster/reexecute_run.ts @@ -1,5 +1,10 @@ import type { DagsterReexecuteRunParams, DagsterReexecuteRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ReexecuteRunResult { @@ -106,13 +111,9 @@ export const reexecuteRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: REEXECUTE_RUN_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/report_asset_materialization.ts b/apps/sim/tools/dagster/report_asset_materialization.ts new file mode 100644 index 00000000000..3362fa27376 --- /dev/null +++ b/apps/sim/tools/dagster/report_asset_materialization.ts @@ -0,0 +1,140 @@ +import type { + DagsterReportAssetMaterializationParams, + DagsterReportAssetMaterializationResponse, +} from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +interface ReportAssetEventResult { + type: string + assetKey?: { path: string[] } + message?: string +} + +const REPORT_ASSET_EVENT_MUTATION = ` + mutation ReportRunlessAssetEvents($eventParams: ReportRunlessAssetEventsParams!) { + reportRunlessAssetEvents(eventParams: $eventParams) { + type: __typename + ... on ReportRunlessAssetEventsSuccess { + assetKey { + path + } + } + ... on UnauthorizedError { + message + } + ... on PythonError { + message + } + } + } +` + +export const reportAssetMaterializationTool: ToolConfig< + DagsterReportAssetMaterializationParams, + DagsterReportAssetMaterializationResponse +> = { + id: 'dagster_report_asset_materialization', + name: 'Dagster Report Asset Materialization', + description: 'Report an external (runless) materialization or observation for an asset.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key to report against, e.g. "my_asset" or "raw/events"', + }, + eventType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Event type to report: ASSET_MATERIALIZATION (default) or ASSET_OBSERVATION', + }, + partitionKeys: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated partition keys to report against (optional)', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Human-readable description for the reported event (optional)', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => { + const eventParams: Record = { + eventType: params.eventType || 'ASSET_MATERIALIZATION', + assetKey: { path: parseAssetKeyPath(params.assetKey) }, + } + if (params.partitionKeys) { + eventParams.partitionKeys = params.partitionKeys + .split(',') + .map((key) => key.trim()) + .filter(Boolean) + } + if (params.description) eventParams.description = params.description + + return { query: REPORT_ASSET_EVENT_MUTATION, variables: { eventParams } } + }, + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ reportRunlessAssetEvents?: unknown }>(response) + + const result = data.data?.reportRunlessAssetEvents as ReportAssetEventResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'ReportRunlessAssetEventsSuccess' && result.assetKey) { + return { + success: true, + output: { + success: true, + assetKey: result.assetKey.path.join('/'), + }, + } + } + + throw new Error( + `${result.type}: ${dagsterUnionErrorMessage(result, 'Report asset event failed')}` + ) + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the event was reported successfully', + }, + assetKey: { + type: 'string', + description: 'Slash-joined asset key the event was reported against', + }, + }, +} diff --git a/apps/sim/tools/dagster/start_schedule.ts b/apps/sim/tools/dagster/start_schedule.ts index 83ea7fdd1e3..168b94fdd0f 100644 --- a/apps/sim/tools/dagster/start_schedule.ts +++ b/apps/sim/tools/dagster/start_schedule.ts @@ -2,7 +2,12 @@ import type { DagsterScheduleMutationResponse, DagsterStartScheduleParams, } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ScheduleMutationResult { @@ -84,13 +89,9 @@ export const startScheduleTool: ToolConfig< }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: START_SCHEDULE_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/start_sensor.ts b/apps/sim/tools/dagster/start_sensor.ts index 66a07541dbd..c478515f933 100644 --- a/apps/sim/tools/dagster/start_sensor.ts +++ b/apps/sim/tools/dagster/start_sensor.ts @@ -1,5 +1,10 @@ import type { DagsterSensorMutationResponse, DagsterStartSensorParams } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface SensorMutationResult { @@ -79,13 +84,9 @@ export const startSensorTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: START_SENSOR_MUTATION, variables: { diff --git a/apps/sim/tools/dagster/stop_schedule.ts b/apps/sim/tools/dagster/stop_schedule.ts index d6e89848ef2..20fd2fa6534 100644 --- a/apps/sim/tools/dagster/stop_schedule.ts +++ b/apps/sim/tools/dagster/stop_schedule.ts @@ -2,7 +2,12 @@ import type { DagsterScheduleMutationResponse, DagsterStopScheduleParams, } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface ScheduleMutationResult { @@ -73,13 +78,9 @@ export const stopScheduleTool: ToolConfig< }, request: { - url: (params) => `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: STOP_SCHEDULE_MUTATION, variables: { id: params.instigationStateId }, diff --git a/apps/sim/tools/dagster/stop_sensor.ts b/apps/sim/tools/dagster/stop_sensor.ts index cf31c26ac1e..47afc075197 100644 --- a/apps/sim/tools/dagster/stop_sensor.ts +++ b/apps/sim/tools/dagster/stop_sensor.ts @@ -1,5 +1,10 @@ import type { DagsterSensorMutationResponse, DagsterStopSensorParams } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' interface StopSensorResult { @@ -63,13 +68,9 @@ export const stopSensorTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: STOP_SENSOR_MUTATION, variables: { id: params.instigationStateId }, diff --git a/apps/sim/tools/dagster/terminate_run.ts b/apps/sim/tools/dagster/terminate_run.ts index 30f5ccb0473..8d49375e993 100644 --- a/apps/sim/tools/dagster/terminate_run.ts +++ b/apps/sim/tools/dagster/terminate_run.ts @@ -1,5 +1,10 @@ import type { DagsterTerminateRunParams, DagsterTerminateRunResponse } from '@/tools/dagster/types' -import { dagsterUnionErrorMessage, parseDagsterGraphqlResponse } from '@/tools/dagster/utils' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' import type { ToolConfig } from '@/tools/types' /** Fields returned from `terminateRun` for all union members. */ @@ -67,13 +72,9 @@ export const terminateRunTool: ToolConfig `${params.host.replace(/\/$/, '')}/graphql`, + url: (params) => dagsterGraphqlUrl(params.host), method: 'POST', - headers: (params) => { - const headers: Record = { 'Content-Type': 'application/json' } - if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey - return headers - }, + headers: (params) => dagsterRequestHeaders(params), body: (params) => ({ query: TERMINATE_RUN_MUTATION, variables: { runId: params.runId }, diff --git a/apps/sim/tools/dagster/types.ts b/apps/sim/tools/dagster/types.ts index 89fa8f6c2d5..1ed08af3186 100644 --- a/apps/sim/tools/dagster/types.ts +++ b/apps/sim/tools/dagster/types.ts @@ -28,8 +28,15 @@ export interface DagsterGetRunResponse extends ToolResponse { runId: string jobName: string | null status: string + mode: string | null startTime: number | null endTime: number | null + creationTime: number | null + updateTime: number | null + parentRunId: string | null + rootRunId: string | null + canTerminate: boolean + assetSelection: string[] | null runConfigYaml: string | null tags: Array<{ key: string; value: string }> | null } @@ -38,6 +45,9 @@ export interface DagsterGetRunResponse extends ToolResponse { export interface DagsterListRunsParams extends DagsterBaseParams { jobName?: string statuses?: string + createdAfter?: number + createdBefore?: number + cursor?: string limit?: number } @@ -51,6 +61,8 @@ export interface DagsterListRunsResponse extends ToolResponse { startTime: number | null endTime: number | null }> + cursor: string | null + hasMore: boolean } } @@ -189,6 +201,81 @@ export interface DagsterStopSensorParams extends DagsterBaseParams { instigationStateId: string } +export interface DagsterListAssetsParams extends DagsterBaseParams { + prefix?: string + cursor?: string + limit?: number +} + +export interface DagsterListAssetsResponse extends ToolResponse { + output: { + assets: Array<{ assetKey: string; path: string[] }> + cursor: string | null + hasMore: boolean + } +} + +export interface DagsterGetAssetParams extends DagsterBaseParams { + assetKey: string +} + +export interface DagsterGetAssetResponse extends ToolResponse { + output: { + assetKey: string + path: string[] + groupName: string | null + description: string | null + jobNames: string[] | null + computeKind: string | null + isPartitioned: boolean | null + latestMaterialization: { + runId: string + timestamp: string + partition: string | null + stepKey: string | null + } | null + } +} + +export interface DagsterMaterializeAssetsParams extends DagsterBaseParams { + repositoryLocationName: string + repositoryName: string + jobName: string + assetSelection: string + tags?: string +} + +export interface DagsterMaterializeAssetsResponse extends ToolResponse { + output: { + runId: string + } +} + +export interface DagsterReportAssetMaterializationParams extends DagsterBaseParams { + assetKey: string + eventType?: string + partitionKeys?: string + description?: string +} + +export interface DagsterReportAssetMaterializationResponse extends ToolResponse { + output: { + success: boolean + assetKey: string + } +} + +export interface DagsterWipeAssetParams extends DagsterBaseParams { + assetKey: string +} + +export interface DagsterWipeAssetResponse extends ToolResponse { + output: { + success: boolean + assetKey: string + } +} + export type DagsterResponse = | DagsterLaunchRunResponse | DagsterGetRunResponse @@ -202,3 +289,8 @@ export type DagsterResponse = | DagsterScheduleMutationResponse | DagsterListSensorsResponse | DagsterSensorMutationResponse + | DagsterListAssetsResponse + | DagsterGetAssetResponse + | DagsterMaterializeAssetsResponse + | DagsterReportAssetMaterializationResponse + | DagsterWipeAssetResponse diff --git a/apps/sim/tools/dagster/utils.ts b/apps/sim/tools/dagster/utils.ts index b41fc135339..13869ddde96 100644 --- a/apps/sim/tools/dagster/utils.ts +++ b/apps/sim/tools/dagster/utils.ts @@ -1,3 +1,44 @@ +/** + * Builds the GraphQL endpoint URL from a Dagster host, tolerating surrounding whitespace and a + * trailing slash (e.g. `https://myorg.dagster.cloud/prod` → `https://myorg.dagster.cloud/prod/graphql`). + */ +export function dagsterGraphqlUrl(host: string): string { + return `${host.trim().replace(/\/$/, '')}/graphql` +} + +/** + * Builds the request headers for a Dagster GraphQL call, attaching the Dagster+ API token when one + * is provided (omitted for OSS / self-hosted instances). + */ +export function dagsterRequestHeaders(params: { apiKey?: string }): Record { + const headers: Record = { 'Content-Type': 'application/json' } + if (params.apiKey) headers['Dagster-Cloud-Api-Token'] = params.apiKey.trim() + return headers +} + +/** + * Splits a slash-delimited asset key string into a Dagster asset key path + * (e.g. `prefix/my_asset` → `['prefix', 'my_asset']`). + */ +export function parseAssetKeyPath(input: string): string[] { + return input + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) +} + +/** + * Parses a comma- or newline-separated list of slash-delimited asset keys into the + * `[AssetKeyInput!]` shape expected by Dagster (`{ path: string[] }[]`). + */ +export function parseAssetSelection(input: string): Array<{ path: string[] }> { + return input + .split(/[\n,]/) + .map((key) => key.trim()) + .filter(Boolean) + .map((key) => ({ path: parseAssetKeyPath(key) })) +} + /** * Parses a Dagster GraphQL JSON body and throws if the HTTP status is not OK or the payload * contains top-level GraphQL errors. diff --git a/apps/sim/tools/dagster/wipe_asset.ts b/apps/sim/tools/dagster/wipe_asset.ts new file mode 100644 index 00000000000..e35a5c25212 --- /dev/null +++ b/apps/sim/tools/dagster/wipe_asset.ts @@ -0,0 +1,115 @@ +import type { DagsterWipeAssetParams, DagsterWipeAssetResponse } from '@/tools/dagster/types' +import { + dagsterGraphqlUrl, + dagsterRequestHeaders, + dagsterUnionErrorMessage, + parseAssetKeyPath, + parseDagsterGraphqlResponse, +} from '@/tools/dagster/utils' +import type { ToolConfig } from '@/tools/types' + +interface WipeAssetResult { + type: string + assetPartitionRanges?: Array<{ assetKey: { path: string[] } }> + message?: string +} + +const WIPE_ASSET_MUTATION = ` + mutation WipeAsset($assetPartitionRanges: [PartitionsByAssetSelector!]!) { + wipeAssets(assetPartitionRanges: $assetPartitionRanges) { + type: __typename + ... on AssetWipeSuccess { + assetPartitionRanges { + assetKey { + path + } + } + } + ... on AssetNotFoundError { + message + } + ... on UnauthorizedError { + message + } + ... on UnsupportedOperationError { + message + } + ... on PythonError { + message + } + } + } +` + +export const wipeAssetTool: ToolConfig = { + id: 'dagster_wipe_asset', + name: 'Dagster Wipe Asset', + description: + 'DESTRUCTIVE: permanently wipes ALL materialization history (every partition) for an asset. This cannot be undone.', + version: '1.0.0', + + params: { + host: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Dagster host URL (e.g., https://myorg.dagster.cloud/prod or http://localhost:3001)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Dagster+ API token (leave blank for OSS / self-hosted)', + }, + assetKey: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Slash-delimited asset key to wipe, e.g. "my_asset" or "raw/events"', + }, + }, + + request: { + url: (params) => dagsterGraphqlUrl(params.host), + method: 'POST', + headers: (params) => dagsterRequestHeaders(params), + body: (params) => ({ + query: WIPE_ASSET_MUTATION, + variables: { + assetPartitionRanges: [{ assetKey: { path: parseAssetKeyPath(params.assetKey) } }], + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await parseDagsterGraphqlResponse<{ wipeAssets?: unknown }>(response) + + const result = data.data?.wipeAssets as WipeAssetResult | undefined + if (!result) throw new Error('Unexpected response from Dagster') + + if (result.type === 'AssetWipeSuccess') { + const wipedKey = result.assetPartitionRanges?.[0]?.assetKey.path.join('/') ?? '' + return { + success: true, + output: { + success: true, + assetKey: wipedKey, + }, + } + } + + throw new Error(`${result.type}: ${dagsterUnionErrorMessage(result, 'Wipe asset failed')}`) + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the asset was wiped successfully', + }, + assetKey: { + type: 'string', + description: 'Slash-joined asset key that was wiped', + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index ff6a5ce0b86..83ef9e9ebcd 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -343,6 +343,34 @@ import { clerkRevokeSessionTool, clerkUpdateUserTool, } from '@/tools/clerk' +import { + clickhouseCountRowsTool, + clickhouseCreateDatabaseTool, + clickhouseCreateTableTool, + clickhouseDeleteTool, + clickhouseDescribeTableTool, + clickhouseDropDatabaseTool, + clickhouseDropPartitionTool, + clickhouseDropTableTool, + clickhouseExecuteTool, + clickhouseInsertRowsTool, + clickhouseInsertTool, + clickhouseIntrospectTool, + clickhouseKillQueryTool, + clickhouseListClustersTool, + clickhouseListDatabasesTool, + clickhouseListMutationsTool, + clickhouseListPartitionsTool, + clickhouseListRunningQueriesTool, + clickhouseListTablesTool, + clickhouseOptimizeTableTool, + clickhouseQueryTool, + clickhouseRenameTableTool, + clickhouseShowCreateTableTool, + clickhouseTableStatsTool, + clickhouseTruncateTableTool, + clickhouseUpdateTool, +} from '@/tools/clickhouse' import { cloudflareCreateDnsRecordTool, cloudflareCreateZoneTool, @@ -460,19 +488,24 @@ import { } from '@/tools/cursor' import { dagsterDeleteRunTool, + dagsterGetAssetTool, dagsterGetRunLogsTool, dagsterGetRunTool, dagsterLaunchRunTool, + dagsterListAssetsTool, dagsterListJobsTool, dagsterListRunsTool, dagsterListSchedulesTool, dagsterListSensorsTool, + dagsterMaterializeAssetsTool, dagsterReexecuteRunTool, + dagsterReportAssetMaterializationTool, dagsterStartScheduleTool, dagsterStartSensorTool, dagsterStopScheduleTool, dagsterStopSensorTool, dagsterTerminateRunTool, + dagsterWipeAssetTool, } from '@/tools/dagster' import { databricksCancelRunTool, @@ -2974,7 +3007,14 @@ import { } from '@/tools/telegram' import { textractParserTool, textractParserV2Tool } from '@/tools/textract' import { thinkingTool } from '@/tools/thinking' -import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird' +import { + tinybirdAppendDatasourceTool, + tinybirdDeleteDatasourceRowsTool, + tinybirdEventsTool, + tinybirdQueryPipeTool, + tinybirdQueryTool, + tinybirdTruncateDatasourceTool, +} from '@/tools/tinybird' import { trelloAddCommentTool, trelloCreateCardTool, @@ -4124,19 +4164,24 @@ export const tools: Record = { devin_archive_session: devinArchiveSessionTool, devin_terminate_session: devinTerminateSessionTool, dagster_delete_run: dagsterDeleteRunTool, + dagster_get_asset: dagsterGetAssetTool, dagster_get_run: dagsterGetRunTool, dagster_get_run_logs: dagsterGetRunLogsTool, dagster_launch_run: dagsterLaunchRunTool, + dagster_list_assets: dagsterListAssetsTool, dagster_list_jobs: dagsterListJobsTool, dagster_list_runs: dagsterListRunsTool, dagster_list_schedules: dagsterListSchedulesTool, dagster_list_sensors: dagsterListSensorsTool, + dagster_materialize_assets: dagsterMaterializeAssetsTool, dagster_reexecute_run: dagsterReexecuteRunTool, + dagster_report_asset_materialization: dagsterReportAssetMaterializationTool, dagster_start_schedule: dagsterStartScheduleTool, dagster_start_sensor: dagsterStartSensorTool, dagster_stop_schedule: dagsterStopScheduleTool, dagster_stop_sensor: dagsterStopSensorTool, dagster_terminate_run: dagsterTerminateRunTool, + dagster_wipe_asset: dagsterWipeAssetTool, databricks_cancel_run: databricksCancelRunTool, databricks_execute_sql: databricksExecuteSqlTool, databricks_get_run: databricksGetRunTool, @@ -5106,6 +5151,10 @@ export const tools: Record = { thinking_tool: thinkingTool, tinybird_events: tinybirdEventsTool, tinybird_query: tinybirdQueryTool, + tinybird_query_pipe: tinybirdQueryPipeTool, + tinybird_append_datasource: tinybirdAppendDatasourceTool, + tinybird_truncate_datasource: tinybirdTruncateDatasourceTool, + tinybird_delete_datasource_rows: tinybirdDeleteDatasourceRowsTool, stagehand_extract: stagehandExtractTool, stagehand_agent: stagehandAgentTool, mem0_add_memories: mem0AddMemoriesTool, @@ -5205,6 +5254,32 @@ export const tools: Record = { telegram_send_video: telegramSendVideoTool, telegram_send_document: telegramSendDocumentTool, clay_populate: clayPopulateTool, + clickhouse_query: clickhouseQueryTool, + clickhouse_insert: clickhouseInsertTool, + clickhouse_insert_rows: clickhouseInsertRowsTool, + clickhouse_update: clickhouseUpdateTool, + clickhouse_delete: clickhouseDeleteTool, + clickhouse_execute: clickhouseExecuteTool, + clickhouse_introspect: clickhouseIntrospectTool, + clickhouse_list_databases: clickhouseListDatabasesTool, + clickhouse_list_tables: clickhouseListTablesTool, + clickhouse_describe_table: clickhouseDescribeTableTool, + clickhouse_show_create_table: clickhouseShowCreateTableTool, + clickhouse_count_rows: clickhouseCountRowsTool, + clickhouse_list_partitions: clickhouseListPartitionsTool, + clickhouse_list_mutations: clickhouseListMutationsTool, + clickhouse_list_running_queries: clickhouseListRunningQueriesTool, + clickhouse_table_stats: clickhouseTableStatsTool, + clickhouse_list_clusters: clickhouseListClustersTool, + clickhouse_create_database: clickhouseCreateDatabaseTool, + clickhouse_drop_database: clickhouseDropDatabaseTool, + clickhouse_create_table: clickhouseCreateTableTool, + clickhouse_drop_table: clickhouseDropTableTool, + clickhouse_truncate_table: clickhouseTruncateTableTool, + clickhouse_rename_table: clickhouseRenameTableTool, + clickhouse_optimize_table: clickhouseOptimizeTableTool, + clickhouse_drop_partition: clickhouseDropPartitionTool, + clickhouse_kill_query: clickhouseKillQueryTool, clerk_list_users: clerkListUsersTool, clerk_get_user: clerkGetUserTool, clerk_create_user: clerkCreateUserTool, diff --git a/apps/sim/tools/tinybird/append_datasource.ts b/apps/sim/tools/tinybird/append_datasource.ts new file mode 100644 index 00000000000..3e4e11a40c1 --- /dev/null +++ b/apps/sim/tools/tinybird/append_datasource.ts @@ -0,0 +1,143 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdAppendDatasourceParams, + TinybirdAppendDatasourceResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-append-datasource') + +/** + * Tinybird Append Data Source Tool + * + * Appends data to an existing Data Source from a remote file URL using the + * Data Sources API (`mode=append`). This is asynchronous and returns an import + * job that can be polled for completion. + */ +export const appendDatasourceTool: ToolConfig< + TinybirdAppendDatasourceParams, + TinybirdAppendDatasourceResponse +> = { + id: 'tinybird_append_datasource', + name: 'Tinybird Append Data Source', + description: + 'Append data to a Tinybird Data Source from a remote file URL (CSV, NDJSON, Parquet).', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the existing Data Source to append to. Example: "events_raw"', + }, + url: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Publicly accessible URL of the file to append. Example: "https://example.com/data.csv"', + }, + format: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Format of the source file: "csv" (default), "ndjson", or "parquet"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${params.token.trim()}`, + }), + body: (params) => { + const searchParams = new URLSearchParams() + searchParams.set('mode', 'append') + searchParams.set('name', params.datasource.trim()) + searchParams.set('url', params.url.trim()) + if (params.format) { + searchParams.set('format', params.format) + } + return searchParams.toString() + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Started Tinybird append-from-URL import', { + importId: data.import_id ?? data.job?.import_id, + status: data.status ?? data.job?.status, + }) + + return { + success: true, + output: { + id: data.id ?? null, + import_id: data.import_id ?? data.job?.import_id ?? null, + job_id: data.job_id ?? data.job?.job_id ?? data.job?.id ?? null, + job_url: data.job_url ?? data.job?.job_url ?? null, + status: data.status ?? data.job?.status ?? null, + job: data.job ?? null, + datasource: data.datasource ?? data.job?.datasource ?? null, + }, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Identifier of the append operation', + optional: true, + }, + import_id: { + type: 'string', + description: 'Import identifier for the append job', + optional: true, + }, + job_id: { + type: 'string', + description: 'Job identifier used to poll import status', + optional: true, + }, + job_url: { + type: 'string', + description: 'URL to query the import job status', + optional: true, + }, + status: { + type: 'string', + description: 'Initial job status (e.g., "waiting")', + optional: true, + }, + job: { + type: 'json', + description: 'Full import job details (kind, id, status, created_at, datasource, ...)', + optional: true, + }, + datasource: { + type: 'json', + description: 'Target Data Source metadata (id, name, ...)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/delete_datasource_rows.ts b/apps/sim/tools/tinybird/delete_datasource_rows.ts new file mode 100644 index 00000000000..29243cdbc5d --- /dev/null +++ b/apps/sim/tools/tinybird/delete_datasource_rows.ts @@ -0,0 +1,136 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdDeleteDatasourceRowsParams, + TinybirdDeleteDatasourceRowsResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-delete-datasource-rows') + +/** + * Tinybird Delete Data Source Rows Tool + * + * Deletes rows from a Data Source that match a SQL condition. This is asynchronous + * and returns a delete job that can be polled for completion. Set `dry_run` to test + * the condition without deleting any data. + */ +export const deleteDatasourceRowsTool: ToolConfig< + TinybirdDeleteDatasourceRowsParams, + TinybirdDeleteDatasourceRowsResponse +> = { + id: 'tinybird_delete_datasource_rows', + name: 'Tinybird Delete Data Source Rows', + description: 'Delete rows from a Tinybird Data Source matching a SQL condition.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Data Source to delete rows from. Example: "events_raw"', + }, + delete_condition: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'SQL WHERE-clause condition selecting the rows to delete. Example: "country = \'ES\'" or "event_date < \'2024-01-01\'"', + }, + dry_run: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: + 'When true, returns how many rows would be deleted without deleting them. Defaults to false.', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources/${encodeURIComponent(params.datasource.trim())}/delete` + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${params.token.trim()}`, + }), + body: (params) => { + const searchParams = new URLSearchParams() + searchParams.set('delete_condition', params.delete_condition.trim()) + if (params.dry_run) { + searchParams.set('dry_run', 'true') + } + return searchParams.toString() + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Started Tinybird delete-by-condition job', { + deleteId: data.delete_id, + status: data.status ?? data.job?.status, + }) + + return { + success: true, + output: { + id: data.id ?? null, + job_id: data.job_id ?? data.job?.job_id ?? data.job?.id ?? null, + delete_id: data.delete_id ?? null, + job_url: data.job_url ?? data.job?.job_url ?? null, + status: data.status ?? data.job?.status ?? null, + job: data.job ?? null, + }, + } + }, + + outputs: { + id: { + type: 'string', + description: 'Identifier of the delete operation', + optional: true, + }, + job_id: { + type: 'string', + description: 'Job identifier used to poll delete status', + optional: true, + }, + delete_id: { + type: 'string', + description: 'Deletion identifier', + optional: true, + }, + job_url: { + type: 'string', + description: 'URL to query the delete job status', + optional: true, + }, + status: { + type: 'string', + description: 'Current job status (e.g., "waiting", "done")', + optional: true, + }, + job: { + type: 'json', + description: + 'Full delete job details (kind, id, status, delete_condition, rows_affected, ...)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/events.ts b/apps/sim/tools/tinybird/events.ts index ec18c99649d..0a85dab88f8 100644 --- a/apps/sim/tools/tinybird/events.ts +++ b/apps/sim/tools/tinybird/events.ts @@ -64,9 +64,14 @@ export const eventsTool: ToolConfig { - const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url + const baseUrl = params.base_url.trim().replace(/\/+$/, '') const url = new URL(`${baseUrl}/v0/events`) - url.searchParams.set('name', params.datasource) + url.searchParams.set('name', params.datasource.trim()) + // Tinybird selects JSON parsing via the `format=json` query parameter, not the + // Content-Type header. Default (omitted) is NDJSON. + if (params.format === 'json') { + url.searchParams.set('format', 'json') + } if (params.wait) { url.searchParams.set('wait', 'true') } @@ -75,7 +80,7 @@ export const eventsTool: ToolConfig { const headers: Record = { - Authorization: `Bearer ${params.token}`, + Authorization: `Bearer ${params.token.trim()}`, } if (params.compression === 'gzip') { diff --git a/apps/sim/tools/tinybird/index.ts b/apps/sim/tools/tinybird/index.ts index 5eb7e6af0b4..17ca52f6b94 100644 --- a/apps/sim/tools/tinybird/index.ts +++ b/apps/sim/tools/tinybird/index.ts @@ -1,5 +1,13 @@ +import { appendDatasourceTool } from '@/tools/tinybird/append_datasource' +import { deleteDatasourceRowsTool } from '@/tools/tinybird/delete_datasource_rows' import { eventsTool } from '@/tools/tinybird/events' import { queryTool } from '@/tools/tinybird/query' +import { queryPipeTool } from '@/tools/tinybird/query_pipe' +import { truncateDatasourceTool } from '@/tools/tinybird/truncate_datasource' export const tinybirdEventsTool = eventsTool export const tinybirdQueryTool = queryTool +export const tinybirdQueryPipeTool = queryPipeTool +export const tinybirdAppendDatasourceTool = appendDatasourceTool +export const tinybirdTruncateDatasourceTool = truncateDatasourceTool +export const tinybirdDeleteDatasourceRowsTool = deleteDatasourceRowsTool diff --git a/apps/sim/tools/tinybird/query.ts b/apps/sim/tools/tinybird/query.ts index 5a64a36fc63..526ca5c1970 100644 --- a/apps/sim/tools/tinybird/query.ts +++ b/apps/sim/tools/tinybird/query.ts @@ -52,19 +52,19 @@ export const queryTool: ToolConfig = request: { url: (params) => { - const baseUrl = params.base_url.endsWith('/') ? params.base_url.slice(0, -1) : params.base_url + const baseUrl = params.base_url.trim().replace(/\/+$/, '') return `${baseUrl}/v0/sql` }, method: 'POST', headers: (params) => ({ 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Bearer ${params.token}`, + Authorization: `Bearer ${params.token.trim()}`, }), body: (params) => { const searchParams = new URLSearchParams() searchParams.set('q', params.query) if (params.pipeline) { - searchParams.set('pipeline', params.pipeline) + searchParams.set('pipeline', params.pipeline.trim()) } return searchParams.toString() }, @@ -88,8 +88,10 @@ export const queryTool: ToolConfig = return { success: true, output: { - data: data.data || [], - rows: data.rows || 0, + data: data.data ?? [], + meta: data.meta ?? undefined, + rows: data.rows ?? 0, + rows_before_limit_at_least: data.rows_before_limit_at_least ?? undefined, statistics: data.statistics ? { elapsed: data.statistics.elapsed, @@ -126,10 +128,28 @@ export const queryTool: ToolConfig = description: 'Query result data. For FORMAT JSON: array of objects. For other formats (CSV, TSV, etc.): raw text string.', }, + meta: { + type: 'array', + description: 'Column metadata for the result set (only available with FORMAT JSON)', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Column data type' }, + }, + }, + }, rows: { type: 'number', description: 'Number of rows returned (only available with FORMAT JSON)', }, + rows_before_limit_at_least: { + type: 'number', + description: + 'Minimum number of rows there would be without a LIMIT clause (only available with FORMAT JSON)', + optional: true, + }, statistics: { type: 'json', description: diff --git a/apps/sim/tools/tinybird/query_pipe.ts b/apps/sim/tools/tinybird/query_pipe.ts new file mode 100644 index 00000000000..0ba60ad302c --- /dev/null +++ b/apps/sim/tools/tinybird/query_pipe.ts @@ -0,0 +1,174 @@ +import { createLogger } from '@sim/logger' +import type { TinybirdQueryPipeParams, TinybirdQueryPipeResponse } from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-query-pipe') + +/** + * Parses the dynamic-parameters input, which may arrive as a JSON object or a + * JSON string from a code/json subBlock. An omitted or empty value means "no + * parameters"; a non-empty value that is not a valid JSON object throws, so a + * mistyped input fails loudly instead of silently dropping the filters. + */ +function parsePipeParameters( + input: TinybirdQueryPipeParams['parameters'] +): Record { + if (input === undefined || input === null) return {} + if (typeof input === 'object') return input as Record + + const trimmed = input.trim() + if (!trimmed) return {} + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + throw new Error( + 'Invalid Pipe parameters: expected a JSON object of key/value pairs (e.g. {"limit": 10})' + ) + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Invalid Pipe parameters: expected a JSON object, not a primitive or array') + } + return parsed as Record +} + +/** + * Tinybird Query Pipe Tool + * + * Calls a published Tinybird Pipe API Endpoint by name using the `.json` format, + * which is an alias for `SELECT * FROM {pipe}`. Templated Pipe parameters are passed + * as query-string arguments, and an optional `q` lets you run SQL on top of the result + * (using `_` to reference the Pipe). + */ +export const queryPipeTool: ToolConfig = { + id: 'tinybird_query_pipe', + name: 'Tinybird Query Pipe', + description: + 'Call a published Tinybird Pipe API Endpoint by name, passing dynamic parameters and receiving structured JSON results.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + pipe: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the published Pipe API Endpoint to call. Example: "top_pages"', + }, + parameters: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Dynamic Pipe parameters as a JSON object, sent as query-string arguments. Example: {"start_date": "2024-01-01", "limit": 10}', + }, + q: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Optional SQL to run on top of the Pipe result. Use "_" to reference the Pipe. Example: "SELECT count() FROM _"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with PIPE:READ scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + const url = new URL(`${baseUrl}/v0/pipes/${encodeURIComponent(params.pipe.trim())}.json`) + if (params.q) { + url.searchParams.set('q', params.q) + } + const dynamic = parsePipeParameters(params.parameters) + for (const [key, value] of Object.entries(dynamic)) { + // Don't let a dynamic parameter clobber the reserved `q` set above + if (key === 'q') continue + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)) + } + } + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.token.trim()}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('Successfully called Tinybird Pipe endpoint', { + rows: data.rows, + elapsed: data.statistics?.elapsed, + }) + + return { + success: true, + output: { + data: data.data ?? [], + meta: data.meta ?? undefined, + rows: data.rows ?? undefined, + rows_before_limit_at_least: data.rows_before_limit_at_least ?? undefined, + statistics: data.statistics + ? { + elapsed: data.statistics.elapsed, + rows_read: data.statistics.rows_read, + bytes_read: data.statistics.bytes_read, + } + : undefined, + }, + } + }, + + outputs: { + data: { + type: 'json', + description: 'Pipe result data as an array of row objects', + }, + meta: { + type: 'array', + description: 'Column metadata for the result set', + optional: true, + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Column name' }, + type: { type: 'string', description: 'Column data type' }, + }, + }, + }, + rows: { + type: 'number', + description: 'Number of rows returned', + optional: true, + }, + rows_before_limit_at_least: { + type: 'number', + description: 'Minimum number of rows there would be without a LIMIT clause', + optional: true, + }, + statistics: { + type: 'json', + description: 'Query execution statistics - elapsed time, rows read, bytes read', + optional: true, + properties: { + elapsed: { type: 'number', description: 'Query execution time in seconds' }, + rows_read: { type: 'number', description: 'Number of rows processed' }, + bytes_read: { type: 'number', description: 'Number of bytes processed' }, + }, + }, + }, +} diff --git a/apps/sim/tools/tinybird/truncate_datasource.ts b/apps/sim/tools/tinybird/truncate_datasource.ts new file mode 100644 index 00000000000..c630c3c696a --- /dev/null +++ b/apps/sim/tools/tinybird/truncate_datasource.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import type { + TinybirdTruncateDatasourceParams, + TinybirdTruncateDatasourceResponse, +} from '@/tools/tinybird/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('tinybird-truncate-datasource') + +/** + * Tinybird Truncate Data Source Tool + * + * Deletes all rows from a Data Source. Dependent Materialized Views are not + * truncated in cascade. The endpoint returns a minimal (often empty) body on success. + */ +export const truncateDatasourceTool: ToolConfig< + TinybirdTruncateDatasourceParams, + TinybirdTruncateDatasourceResponse +> = { + id: 'tinybird_truncate_datasource', + name: 'Tinybird Truncate Data Source', + description: 'Delete all rows from a Tinybird Data Source.', + version: '1.0.0', + errorExtractor: 'nested-error-object', + + params: { + base_url: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API base URL (e.g., https://api.tinybird.co)', + }, + datasource: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Data Source to truncate. Example: "events_raw"', + }, + token: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tinybird API Token with DATASOURCES:CREATE scope', + }, + }, + + request: { + url: (params) => { + const baseUrl = params.base_url.trim().replace(/\/+$/, '') + return `${baseUrl}/v0/datasources/${encodeURIComponent(params.datasource.trim())}/truncate` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.token.trim()}`, + }), + }, + + transformResponse: async (response: Response) => { + const text = await response.text() + let result: Record | null = null + if (text) { + try { + result = JSON.parse(text) + } catch { + result = null + } + } + + logger.info('Successfully truncated Tinybird Data Source') + + return { + success: true, + output: { + truncated: true, + result, + }, + } + }, + + outputs: { + truncated: { + type: 'boolean', + description: 'Whether the Data Source was truncated successfully', + }, + result: { + type: 'json', + description: 'Raw response body from the truncate endpoint, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tinybird/types.ts b/apps/sim/tools/tinybird/types.ts index 0c1d613339e..07699f8ccfd 100644 --- a/apps/sim/tools/tinybird/types.ts +++ b/apps/sim/tools/tinybird/types.ts @@ -44,16 +44,119 @@ export interface TinybirdQueryParams extends TinybirdBaseParams { export interface TinybirdQueryResponse extends ToolResponse { output: { data: unknown[] | string + meta?: Array<{ name: string; type: string }> rows?: number - statistics?: { - elapsed: number - rows_read: number - bytes_read: number - } + rows_before_limit_at_least?: number + statistics?: TinybirdQueryStatistics + } +} + +/** + * Query execution statistics returned by the Query API and Pipe endpoints + */ +interface TinybirdQueryStatistics { + elapsed: number + rows_read: number + bytes_read: number +} + +/** + * Parameters for calling a published Pipe API Endpoint by name + */ +export interface TinybirdQueryPipeParams extends TinybirdBaseParams { + base_url: string + pipe: string + parameters?: Record | string + q?: string +} + +/** + * Response from calling a published Pipe API Endpoint (`.json` format) + */ +export interface TinybirdQueryPipeResponse extends ToolResponse { + output: { + data: unknown[] + meta?: Array<{ name: string; type: string }> + rows?: number + rows_before_limit_at_least?: number + statistics?: TinybirdQueryStatistics + } +} + +/** + * Parameters for appending data to a Data Source from a URL + */ +export interface TinybirdAppendDatasourceParams extends TinybirdBaseParams { + base_url: string + datasource: string + url: string + format?: 'csv' | 'ndjson' | 'parquet' +} + +/** + * Response from an append-from-URL import job + */ +export interface TinybirdAppendDatasourceResponse extends ToolResponse { + output: { + id: string | null + import_id: string | null + job_id: string | null + job_url: string | null + status: string | null + job: Record | null + datasource: Record | null + } +} + +/** + * Parameters for truncating (deleting all rows from) a Data Source + */ +export interface TinybirdTruncateDatasourceParams extends TinybirdBaseParams { + base_url: string + datasource: string +} + +/** + * Response from truncating a Data Source + */ +export interface TinybirdTruncateDatasourceResponse extends ToolResponse { + output: { + truncated: boolean + result: Record | null + } +} + +/** + * Parameters for deleting rows from a Data Source by condition + */ +export interface TinybirdDeleteDatasourceRowsParams extends TinybirdBaseParams { + base_url: string + datasource: string + delete_condition: string + dry_run?: boolean +} + +/** + * Response from a delete-by-condition job + */ +export interface TinybirdDeleteDatasourceRowsResponse extends ToolResponse { + output: { + id: string | null + job_id: string | null + delete_id: string | null + job_url: string | null + status: string | null + job: Record | null } } /** * Union type for all possible Tinybird responses */ -export type TinybirdResponse = TinybirdEventsResponse | TinybirdQueryResponse +export type TinybirdResponse = + | TinybirdEventsResponse + | TinybirdQueryResponse + | TinybirdQueryPipeResponse + | TinybirdAppendDatasourceResponse + | TinybirdTruncateDatasourceResponse + | TinybirdDeleteDatasourceRowsResponse diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index f84af741a7f..497a352bd4e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 765, - zodRoutes: 765, + totalRoutes: 791, + zodRoutes: 791, nonZodRoutes: 0, } as const