feat(snowflake): credential-based auth, object pickers, and 9 new operations (#6474)

* feat(snowflake): credential-based auth, object pickers, and 9 new operations

Replace the per-block host + PAT fields with a Snowflake service-account
credential, move the credential picker to the top of the block, back the
object fields with metadata-only pickers, and add nine operations.

- credential: snowflake-service-account token service account (account host +
  programmatic access token), verified against the SQL API with the same
  headers the tools use
- selectors: database, schema, table, warehouse, execution role, file format
  and procedure pickers behind one /api/tools/snowflake/objects route
- new operations: unload_data, list_databases, list_schemas, list_tables,
  alter_warehouse, resume_task, suspend_task, list_query_history,
  list_copy_history

* fix(snowflake): migrate renamed subblock IDs and authenticate before parsing

- add SUBBLOCK_ID_MIGRATIONS entries so the renamed object fields map onto
  their pickers and the removed host/apiKey values are parked
- authenticate the caller before contract validation in the selector route,
  per the API route convention

* fix(snowflake): close unload-query breakouts, drop parked secrets, correct docs

- assertBalancedQuery now skips // line comments, $$ dollar quoting and rejects
  ambiguous nested block comments; each hid a paren that let an injected
  OVERWRITE = TRUE escape the derived table
- always emit OVERWRITE so an injected duplicate is rejected by Snowflake
  rather than silently replacing staged files
- _removed_ migration targets now drop the stored value instead of parking it
  under a dead key, where export scrubbing (which walks the block config) would
  never clear it
- 403 falls back to the shared invalid-credentials message, which names the
  network policy and SQL API causes Snowflake does not distinguish in the body
- correct the network-policy-by-user-type claim: only SERVICE_AGENT is exempt
- correct MAX_FILE_SIZE and errorOnly tool descriptions to match the fixed code

* fix(snowflake): stop untouched switches emitting clauses; retarget migration

- an untouched switch serializes as null, and advanced mode emits every
  advanced subblock, so alter_warehouse silently sent AUTO_RESUME = FALSE and
  permanently disabled auto-resume on the warehouse; normalize optional
  booleans to undefined in tools.config.params
- point the subblock migration at the advanced text members: a migrated block
  has no credential, so a picker cannot hydrate a stored name, and legacy
  fileFormat values were qualified while the picker lists bare names
- add the missing json-object wand type and scope the SQL wand prompt, which
  promised bindings that unload_data does not accept

* fix(migrations): sweep already-parked subblock values; align picker 403

- an earlier version of this migration renamed retired fields into _removed_*
  keys instead of deleting them, so deployed workflows still hold those values;
  they match no oldId, so a dedicated sweep clears them for every block type
- the picker now treats a Snowflake 403 like a 401: it means a network policy
  or a disabled SQL API, which the credential validator already reports as a
  credential problem rather than a bad request

* fix(wand): add json-array generation type for array-contract fields

The json-object reinforcement tells the model the response must start with {
and end with }, which fights any field whose contract is an array. Snowflake's
rows, matchColumns and procedureArguments all ask for arrays, so they were
being steered toward an object that the JSON parse would then reject.

Adds a sibling json-array type that strips fences the same way but reinforces
brackets, and points the three array fields at it. bindings and filters are
genuine objects and stay on json-object.

* fix(snowflake): unload a table, not an inline query

The COPY INTO grammar places the source immediately before its copy options, so
an inlined query sits one parenthesis from being able to rewrite them. Guarding
that means matching Snowflake's tokenizer exactly, and three successive versions
of the guard were each defeated: // line comments, $$ dollar quoting, and a bare
carriage return, which the scanner did not treat as a line terminator but
Snowflake does. Each fix was a guess at a lexer the public docs do not specify.

Removes the inline-query source instead of guessing a fourth time. A table name
goes through qualifiedIdentifier, which is provably safe. Exporting a query
result now means materializing it first — a view, or CREATE TABLE AS SELECT via
Execute SQL — which the tool description, the block skill and the docs all say.

Also from the final audit:
- optionalBoolean accepts the string forms a direct tool call delivers, matching
  the other boolean readers on this block, and its TSDoc no longer states the
  serializer rule backwards
- the five JSON editors declare language: 'json', so invalid JSON is caught
  inline instead of at execution
- bound the RESULT_SCAN read in SQL, not only by rows_per_resultset
- pin every migration target to a live subblock id, for all blocks
This commit is contained in:
Waleed
2026-08-09 00:20:09 -07:00
committed by GitHub
parent 29cfb8586e
commit 303986f45f
68 changed files with 3671 additions and 488 deletions
@@ -224,6 +224,7 @@
"smartlead",
"smtp",
"snowflake",
"snowflake-service-account",
"sportmonks",
"sqs",
"square",
@@ -0,0 +1,120 @@
---
title: Snowflake Programmatic Access Tokens
description: Create a Snowflake programmatic access token and connect it to Sim so workflows can query your account
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { FAQ } from '@/components/ui/faq'
A Snowflake programmatic access token (PAT) lets a workflow authenticate to your account over the Snowflake SQL API without a password or a key pair. The token belongs to one Snowflake user. Left unrestricted it can act as any role that user holds; with `ROLE_RESTRICTION` set it is pinned to exactly one.
Sim stores the token alongside your account host as one credential. Once it is added, every Snowflake block picks it from a dropdown — and the block's database, schema, table, warehouse, role, file-format, and procedure fields become pickers that list what the token can actually see.
## Prerequisites
- A Snowflake user you can generate a token for. Generating a token for another user requires the ability to run `ALTER USER` on them.
- Your account host — the `<account_identifier>.snowflakecomputing.com` hostname, for example `myorg-myaccount.snowflakecomputing.com`. Snowsight shows it under **Account details**.
- A network policy covering the user, or an authentication policy that waives the requirement (see below).
<Callout type="warn">
Snowflake's **network policy** requirement varies by user type, and getting it wrong is the most common reason a token is rejected:
- `TYPE = PERSON` — you can generate a token without a network policy, but the user **must** be covered by one to authenticate with it.
- `TYPE = SERVICE` and `TYPE = LEGACY_SERVICE` — a network policy is required to generate **and** to use a token.
- `TYPE = SERVICE_AGENT` — exempt; generate and use freely.
If your account has no network policy, either create one (allowing Sim's egress) or set `NETWORK_POLICY_EVALUATION = ENFORCED_NOT_REQUIRED` on an authentication policy applied to the user.
</Callout>
## Creating the Token
### Option 1 — Snowsight
<Steps>
<Step>
Open **Governance & security** → **Users & roles** and select the user the workflow should run as
</Step>
<Step>
Under **Programmatic access tokens**, click **Generate new token**
</Step>
<Step>
Give it a name, optionally restrict it to a single role, and set the expiry in days
</Step>
<Step>
Copy the token secret. Snowflake shows it **once**, at creation
</Step>
</Steps>
### Option 2 — SQL
```sql
ALTER USER my_service_user ADD PROGRAMMATIC ACCESS TOKEN sim_workflows
ROLE_RESTRICTION = 'SIM_WORKFLOW_ROLE'
DAYS_TO_EXPIRY = 90;
```
`DAYS_TO_EXPIRY` defaults to 15 days and cannot exceed 365 — an authentication policy can lower that ceiling further via `PROGRAMMATIC_ACCESS_TOKEN_MAX_EXPIRY_IN_DAYS`. **A token can never be non-expiring**, and the value cannot be changed after creation — to extend it, generate a new token and swap the credential in Sim. Plan the rotation when you create it.
Service users (`TYPE = SERVICE`, `LEGACY_SERVICE`, or `SERVICE_AGENT`) **must** set `ROLE_RESTRICTION`, unless an authentication policy exempts them. For person users it is optional but recommended: a restricted token can only ever act as that one role.
<Callout type="info">
If an authentication policy applies to the user, `'PROGRAMMATIC_ACCESS_TOKEN'` must appear in its `AUTHENTICATION_METHODS` list, otherwise the token is refused.
</Callout>
## Adding the Credential to Sim
<Steps>
<Step>
Add a **Snowflake** block to a workflow, open the credential dropdown, and choose to add a programmatic access token
</Step>
<Step>
Enter the **account host** (`myorg-myaccount.snowflakecomputing.com`) and paste the **token**
</Step>
<Step>
Save. Sim verifies the credential by running `SELECT CURRENT_USER(), CURRENT_ACCOUNT(), CURRENT_ROLE()` over the SQL API — a metadata-only statement that needs no warehouse and consumes no credits. A rejected token, an unreachable host, or a blocking network policy each produce a specific error rather than a generic failure.
</Step>
</Steps>
The host and the token are encrypted before being stored, and the token is never returned to the browser — the block sends a credential id and Sim resolves it server-side.
## Using the Credential in Workflows
Select the credential on any Snowflake block. You never enter the host again: every tool derives its endpoint from the host stored on the credential.
With a credential selected, these fields become pickers backed by metadata-only statements:
| Field | Lists | Needs |
| --- | --- | --- |
| Database | `SHOW DATABASES` | credential |
| Schema | `SHOW SCHEMAS IN DATABASE` | database |
| Table | `SHOW TABLES IN SCHEMA` | database, schema |
| Warehouse | `SHOW WAREHOUSES` | credential |
| Execution role | `CURRENT_AVAILABLE_ROLES()` | credential |
| Named file format | `SHOW FILE FORMATS IN SCHEMA` | database, schema |
| Procedure | `SHOW PROCEDURES IN SCHEMA` | database, schema |
Each picker runs as the token's user under its **default** role — not the execution role set on the block — so an empty list is usually a privilege gap rather than an empty account. Switch any field to advanced mode to type a name directly or reference an upstream block's output instead.
<Callout type="info">
**Unload Data exports a table, not a query.** The COPY INTO grammar places the
source immediately before its options, so an inline query would sit one
parenthesis away from being able to rewrite them. To export a query result,
materialize it first — a view, or `CREATE TABLE AS SELECT` via Execute SQL —
then unload that object.
</Callout>
## Rotating and Revoking
A token's expiry is fixed at creation. To rotate, generate a new token on the same user and update the credential in Sim — the old one stays valid until you remove it. `ALTER USER ... REMOVE PROGRAMMATIC ACCESS TOKEN <name>` revokes immediately and cannot be undone.
<FAQ items={[
{ question: "Why a programmatic access token instead of a password?", answer: "The token is scoped to one user, can be restricted to a single role, expires on a schedule you choose, and can be revoked on its own without changing anyone's password or breaking other integrations." },
{ question: "Does the token expire?", answer: "Yes. DAYS_TO_EXPIRY defaults to 15 days and can be set up to 365 at creation. It cannot be changed afterwards, so pick the value you want up front and plan a rotation." },
{ question: "I lost the token — can I see it again?", answer: "No. Snowflake shows the secret only at creation. Generate a new token and update the credential in Sim." },
{ question: "Why does adding the credential fail with an authentication error?", answer: "The three common causes are a token that has expired or been revoked, a user with no network policy (required to authenticate for every type except SERVICE_AGENT, unless an authentication policy waives it), and an authentication policy that omits PROGRAMMATIC_ACCESS_TOKEN from its AUTHENTICATION_METHODS. A wrong account host is reported separately — Snowflake resolves any *.snowflakecomputing.com name, so Sim identifies a mistyped host by the 404 it answers with." },
{ question: "Why is a picker empty?", answer: "The pickers run SHOW statements as the token's user under its default role — the block's execution role is not applied to them. If the objects you expect are visible only to another role, grant the default role usage on them, restrict the token to the role that has access, or type the name in advanced mode." },
{ question: "Does listing objects cost credits?", answer: "No. Every picker and the credential check run metadata-only statements, which Snowflake serves without a running warehouse." },
{ question: "Can one credential reach two Snowflake accounts?", answer: "No. A token is bound to the user in one account, and the credential stores that account's host. Add one credential per account." },
{ question: "How many tokens can a user have?", answer: "Snowflake allows up to 15 active programmatic access tokens per user." },
]} />
@@ -12,7 +12,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
## Usage Instructions
Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.
Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load and unload staged data, browse databases and schemas, size and control warehouses, run and schedule tasks, review query and load history, inspect schemas, and call stored procedures.
@@ -26,8 +26,6 @@ Execute one parameterized SQL statement through the Snowflake SQL API.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -74,8 +72,6 @@ Check a running or completed statement and retrieve exactly one result partition
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `statementHandle` | string | Yes | Statement handle returned by Snowflake |
| `partition` | number | No | Zero-based result partition to retrieve; defaults to 0 |
| `partitionCount` | number | No | Total number of result partitions, taken from the partitionCount of the first partition. Snowflake omits metadata from every later partition response, so supply this when fetching partition 1 or higher to keep truncated and nextPartition accurate |
@@ -116,8 +112,6 @@ Cancel a running Snowflake SQL API statement.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `statementHandle` | string | Yes | Statement handle returned by Snowflake |
#### Output
@@ -156,8 +150,6 @@ Insert structured JSON rows using bound values.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -202,8 +194,6 @@ Update matching rows with a bound MERGE statement without inserting new rows.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -249,8 +239,6 @@ Update matching rows and insert unmatched rows with a bound MERGE statement.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -296,8 +284,6 @@ Delete rows matching a required set of bound column filters.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -342,8 +328,6 @@ Load files from an existing Snowflake stage with COPY INTO.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -387,6 +371,182 @@ Load files from an existing Snowflake stage with COPY INTO.
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake Unload Data
Export a Snowflake table to files in a stage with COPY INTO.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
| `database` | string | Yes | Database name |
| `schema` | string | Yes | Schema name |
| `stagePath` | string | Yes | Destination stage reference, for example @EXPORTS/daily |
| `table` | string | Yes | Source table to unload. To export a query result, materialize it first as a view or with CREATE TABLE AS SELECT, then unload that |
| `fileFormat` | string | No | Named file format applied to the unloaded files |
| `header` | boolean | No | Whether to write column headings into the unloaded files; supported for CSV and Parquet only |
| `overwrite` | boolean | No | Whether to replace existing files with matching names in the stage |
| `singleFile` | boolean | No | Whether to write one file instead of splitting the output across files |
| `maxFileSizeBytes` | number | No | Upper size limit per unloaded file in bytes; Snowflake defaults to 16777216 \(16 MB\) and allows up to 5368709120 \(5 GB\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Databases
List the databases the credential can access.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `nameLike` | string | No | Optional SQL LIKE pattern for object names |
| `limit` | number | No | Maximum rows, from 1 to 10000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Schemas
List the schemas in a Snowflake database.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
| `nameLike` | string | No | Optional SQL LIKE pattern for object names |
| `limit` | number | No | Maximum rows, from 1 to 10000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Tables
List the tables in a Snowflake schema.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
| `schema` | string | Yes | Schema name |
| `nameLike` | string | No | Optional SQL LIKE pattern for object names |
| `limit` | number | No | Maximum rows, from 1 to 10000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Warehouses
List warehouses visible to the active Snowflake role.
@@ -395,8 +555,6 @@ List warehouses visible to the active Snowflake role.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `maxRows` | number | No | Maximum result rows; defaults to 1000 with a Sim safety limit of 10000 |
@@ -438,8 +596,6 @@ Get the full details for a Snowflake virtual warehouse.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouseName` | string | Yes | Warehouse name |
@@ -480,8 +636,6 @@ Resume a Snowflake virtual warehouse if it is suspended.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouseName` | string | Yes | Warehouse name |
@@ -522,8 +676,6 @@ Suspend a Snowflake virtual warehouse.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouseName` | string | Yes | Warehouse name |
@@ -556,6 +708,49 @@ Suspend a Snowflake virtual warehouse.
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake Alter Warehouse
Resize a Snowflake warehouse or change its auto-suspend and auto-resume settings.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouseName` | string | Yes | Warehouse name |
| `warehouseSize` | string | No | New warehouse size, one of $\{SNOWFLAKE_WAREHOUSE_SIZES.join\(', '\)\} |
| `autoSuspendSeconds` | number | No | Seconds of inactivity before the warehouse suspends. Snowflake polls every 30 seconds, so values under 30 or not a multiple of 30 may not behave as expected. 0 means the warehouse never suspends and keeps consuming credits |
| `autoResume` | boolean | No | Whether the warehouse resumes automatically when a statement is submitted |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Tasks
List tasks in a Snowflake schema.
@@ -564,8 +759,6 @@ List tasks in a Snowflake schema.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
@@ -609,8 +802,6 @@ Describe a Snowflake task.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
@@ -653,8 +844,6 @@ Run a Snowflake task immediately, optionally retrying its last failed graph.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
@@ -690,6 +879,90 @@ Run a Snowflake task immediately, optionally retrying its last failed graph.
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake Resume Task
Resume a suspended Snowflake task so its schedule runs again.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
| `schema` | string | Yes | Schema name |
| `taskName` | string | Yes | Task name without a database or schema prefix |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake Suspend Task
Suspend a Snowflake task so its schedule stops triggering runs.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `database` | string | Yes | Database name |
| `schema` | string | Yes | Schema name |
| `taskName` | string | Yes | Task name without a database or schema prefix |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Task Runs
Query up to seven days of Snowflake task history, capped at 10000 rows.
@@ -698,8 +971,6 @@ Query up to seven days of Snowflake task history, capped at 10000 rows.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -745,8 +1016,6 @@ Find one task history record by query ID within Snowflake’s seven-day window a
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -791,8 +1060,6 @@ Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs al
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -834,8 +1101,6 @@ Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -870,6 +1135,98 @@ Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Query History
List queries that completed in the last seven days, optionally filtered.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
| `userName` | string | No | Only queries run by this user; cannot be combined with warehouseName |
| `warehouseName` | string | No | Only queries run on this warehouse; cannot be combined with userName |
| `startTime` | string | No | ISO-8601 start of the query completion window, within the last seven days |
| `endTime` | string | No | ISO-8601 end of the query completion window, within the last seven days |
| `errorOnly` | boolean | No | Whether to return only queries that failed \(execution status FAILED_WITH_ERROR or FAILED_WITH_INCIDENT\). Snowflake applies the limit before this filter, so it selects the failures among the most recent queries rather than the most recent failures |
| `limit` | number | No | Maximum query rows, from 1 to 10000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake List Copy History
List staged-file load results for a table over the last fourteen days.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
| `database` | string | Yes | Database name |
| `schema` | string | Yes | Schema name |
| `table` | string | Yes | Table whose load history to return |
| `startTime` | string | Yes | ISO-8601 start of the load window, within the last fourteen days |
| `endTime` | string | No | ISO-8601 end of the load window, within the last fourteen days; defaults to now |
| `limit` | number | No | Maximum load rows, from 1 to 10000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `statementHandle` | string | Snowflake statement handle |
| `status` | string | Statement status: SUCCEEDED, RUNNING, or CANCELED |
| `message` | string | Snowflake response message |
| `result` | object | Completed result partition, or null while running or when no result is available |
| ↳ `columns` | array | Documented Snowflake result column metadata, or null when Snowflake returned a metadata-less partition response |
| ↳ `name` | string | Column name |
| ↳ `type` | string | Snowflake data type |
| ↳ `length` | number | Column length |
| ↳ `precision` | number | Numeric precision |
| ↳ `scale` | number | Numeric scale |
| ↳ `nullable` | boolean | Whether the column is nullable |
| ↳ `rows` | array | One complete Snowflake result partition as string or null arrays |
| ↳ `totalRows` | number | Total result rows |
| ↳ `currentPartition` | number | Zero-based partition returned |
| ↳ `partitionCount` | number | Total partitions in the result set. Snowflake reports this only on the first partition, so pass it back to Get Statement when fetching later partitions |
| ↳ `nextPartition` | number | Next partition to request with Get Statement, if one exists |
| ↳ `truncated` | boolean | Whether more result partitions remain to fetch with Get Statement, or null when Snowflake returned a metadata-less partition response and partitionCount was not supplied. Snowflake does not report when the requested row limit capped the result set, so that cap is never reflected here |
| `dml` | object | Completed DML statistics, or null when the statement has no DML statistics |
| ↳ `rowsInserted` | number | Rows inserted by the statement |
| ↳ `rowsUpdated` | number | Rows updated by the statement |
| ↳ `rowsDeleted` | number | Rows deleted by the statement |
| ↳ `duplicateRowsUpdated` | number | Duplicate rows updated by the statement |
| ↳ `rowsAffected` | number | Total inserted, updated, and deleted rows |
### Snowflake Introspect Schema
Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views.
@@ -878,8 +1235,6 @@ Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -925,8 +1280,6 @@ Call a stored procedure with explicitly typed Snowflake bindings.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `host` | string | Yes | Snowflake account host, for example myorg-myaccount.snowflakecomputing.com |
| `apiKey` | string | Yes | Snowflake programmatic access token |
| `role` | string | No | Snowflake role to use for this statement |
| `statementTimeoutSeconds` | number | No | Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds |
| `warehouse` | string | No | Warehouse to use for this statement; defaults to the PAT user setting |
@@ -0,0 +1,193 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveCredentialAccessToken } from '@/app/api/auth/oauth/utils'
import { buildSelectorStatement } from '@/tools/snowflake/sql'
import {
buildSnowflakeAuthHeaders,
normalizeSnowflakeHost,
readSnowflakeResult,
} from '@/tools/snowflake/utils'
const logger = createLogger('SnowflakeObjectsAPI')
export const dynamic = 'force-dynamic'
/** Rows a single picker may pull back. Bounded in SQL, not after the fact. */
const SELECTOR_ROW_LIMIT = 1000
/** Seconds Snowflake may spend on a picker statement before giving up. */
const SELECTOR_TIMEOUT_SECONDS = 20
/**
* HTTP-level abort. `SELECTOR_TIMEOUT_SECONDS` only bounds Snowflake's own
* execution; without this a stalled socket would pin the request until the
* runtime's socket wall.
*/
const SELECTOR_FETCH_TIMEOUT_MS = (SELECTOR_TIMEOUT_SECONDS + 10) * 1000
interface SnowflakeObject {
name: string
detail: string | null
}
/**
* Parses the JSON array `CURRENT_AVAILABLE_ROLES()` returns — a single row
* holding a string like `["PUBLIC","ANALYST"]`.
*/
function parseAvailableRoles(cellValue: string | null | undefined): SnowflakeObject[] {
if (!cellValue) return []
let parsed: unknown
try {
parsed = JSON.parse(cellValue)
} catch {
logger.warn('CURRENT_AVAILABLE_ROLES returned a non-JSON payload')
return []
}
if (!Array.isArray(parsed)) return []
return parsed
.filter((role): role is string => typeof role === 'string' && role.length > 0)
.sort((a, b) => a.localeCompare(b))
.map((role) => ({ name: role, detail: null }))
}
/**
* POST /api/tools/snowflake/objects
*
* Enumerates Snowflake objects for the editor's pickers (databases, schemas,
* tables, warehouses, roles, file formats, procedures) using the selected
* programmatic-access-token credential. Every statement is
* metadata-only, so no warehouse is required and nothing is billed for
* compute. The token never reaches the browser — the credential id is
* resolved server-side on each call.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
// Authenticate the caller before touching the body: contract validation must
// never run for an unauthenticated request. This reads headers and the
// session only, so it is safe ahead of parsing.
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: true })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Authentication required' }, { status: 401 })
}
const parsed = await parseRequest(
snowflakeObjectsSelectorContract,
request,
{},
{
validationErrorResponse: (error) => {
const path = error.issues.at(0)?.path[0]
const message =
path === 'credential'
? 'Credential is required'
: getValidationErrorMessage(error, 'Invalid request')
logger.error(`Validation failed for Snowflake objects request: ${message}`)
return NextResponse.json({ error: message }, { status: 400 })
},
}
)
if (!parsed.success) return parsed.response
const { credential, workflowId, kind, database, schema } = parsed.data.body
const authz = await authorizeCredentialUse(request, {
credentialId: credential,
workflowId,
callerUserId: auth.userId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const token = await resolveCredentialAccessToken(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!token?.accessToken || !token.domain) {
logger.error('Failed to resolve Snowflake credential', { credentialId: credential, kind })
return NextResponse.json(
{ error: 'Could not resolve the Snowflake credential', authRequired: true },
{ status: 401 }
)
}
let baseUrl: string
let statement: string
try {
baseUrl = normalizeSnowflakeHost(token.domain)
statement = buildSelectorStatement(kind, { database, schema }, SELECTOR_ROW_LIMIT).statement
} catch (error) {
return NextResponse.json({ error: getErrorMessage(error, 'Invalid request') }, { status: 400 })
}
let upstreamStatus = 0
try {
const response = await fetch(`${baseUrl}/api/v2/statements`, {
method: 'POST',
headers: buildSnowflakeAuthHeaders(token.accessToken),
body: JSON.stringify({
statement,
timeout: SELECTOR_TIMEOUT_SECONDS,
parameters: { rows_per_resultset: SELECTOR_ROW_LIMIT },
}),
signal: AbortSignal.any([request.signal, AbortSignal.timeout(SELECTOR_FETCH_TIMEOUT_MS)]),
})
// A rejected credential must tell the picker to reconnect rather than read
// as an outage or a bad request. 403 belongs here alongside 401: Snowflake
// uses it for a network-policy rejection and for a disabled SQL API, which
// is why the credential validator also treats it as a credential problem.
if (response.status === 401 || response.status === 403) {
logger.warn('Snowflake rejected the stored credential', { credentialId: credential, kind })
return NextResponse.json(
{
error:
'Snowflake rejected this credential. Check that it has not expired and that a network policy allows Sim to reach the account, then reconnect it.',
authRequired: true,
},
{ status: 401 }
)
}
// A remaining 4xx names something wrong with the request itself (unknown
// object, malformed statement); only a 5xx or an unreadable body is a
// gateway failure.
upstreamStatus = response.status
const result = await readSnowflakeResult(response)
// A metadata-only statement completes synchronously; a 202 means Snowflake
// deferred it, and returning an empty list would read as "no objects".
if (!result.result) {
logger.warn('Snowflake deferred a picker statement', { kind, status: result.status })
return NextResponse.json(
{ error: 'Snowflake did not return the object list in time. Try again in a moment.' },
{ status: 502 }
)
}
const rows = result.result.rows
const objects: SnowflakeObject[] =
kind === 'roles'
? parseAvailableRoles(rows[0]?.[0])
: rows.flatMap((row) => {
const name = row[0]
if (typeof name !== 'string' || !name) return []
return [{ name, detail: typeof row[1] === 'string' ? row[1] : null }]
})
return NextResponse.json({ objects })
} catch (error) {
logger.error('Failed to list Snowflake objects', { kind, error })
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to list Snowflake objects') },
{ status: upstreamStatus >= 400 && upstreamStatus < 500 ? 400 : 502 }
)
}
})
+7
View File
@@ -339,6 +339,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'
}
// Separate from json-object: that reinforcement demands braces, which would
// fight a field whose contract is an array.
if (generationType === 'json-array') {
finalSystemPrompt +=
'\n\nIMPORTANT: Return ONLY the raw JSON array. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with [ and end with ].'
}
const messages: ChatMessage[] = [{ role: 'system', content: finalSystemPrompt }]
messages.push(...history.filter((msg) => msg.role !== 'system'))
@@ -55,11 +55,15 @@ function buildWandContextInfo({
case 'json-schema':
case 'json-object':
case 'json-array':
case 'table-schema':
try {
const parsed = JSON.parse(currentValue)
const keys = Object.keys(parsed)
contextInfo += `\n\nJSON analysis: Valid JSON with ${keys.length} top-level keys: ${keys.join(', ')}`
// Reporting "top-level keys" for an array would list numeric indices,
// which tells the model nothing about the shape it should produce.
contextInfo += Array.isArray(parsed)
? `\n\nJSON analysis: Valid JSON array with ${parsed.length} items`
: `\n\nJSON analysis: Valid JSON with ${Object.keys(parsed).length} top-level keys: ${Object.keys(parsed).join(', ')}`
} catch {
contextInfo += `\n\nJSON analysis: Invalid JSON - needs fixing`
}
+519 -102
View File
@@ -3,6 +3,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { parseOptionalJsonInput, parseOptionalNumberInput } from '@/blocks/utils'
import type { SnowflakeStatementResponse } from '@/tools/snowflake/types'
import { SNOWFLAKE_WAREHOUSE_SIZES } from '@/tools/snowflake/types'
const sqlSubmissionOperations = [
'execute_sql',
@@ -11,17 +12,26 @@ const sqlSubmissionOperations = [
'upsert_rows',
'delete_rows',
'load_data',
'unload_data',
'list_databases',
'list_schemas',
'list_tables',
'list_warehouses',
'get_warehouse',
'resume_warehouse',
'suspend_warehouse',
'alter_warehouse',
'list_tasks',
'get_task',
'run_task',
'resume_task',
'suspend_task',
'list_task_runs',
'get_task_run',
'cancel_task_run',
'get_task_run_output',
'list_query_history',
'list_copy_history',
'introspect_schema',
'call_procedure',
] as const
@@ -33,10 +43,13 @@ const computeOperations = [
'upsert_rows',
'delete_rows',
'load_data',
'unload_data',
'list_task_runs',
'get_task_run',
'cancel_task_run',
'get_task_run_output',
'list_query_history',
'list_copy_history',
'introspect_schema',
'call_procedure',
] as const
@@ -44,6 +57,7 @@ const computeOperations = [
const maxRowsOperations = [
'execute_sql',
'load_data',
'unload_data',
'list_warehouses',
'get_task_run_output',
'introspect_schema',
@@ -59,8 +73,142 @@ const dataOperations = [
] as const
const taskDefinitionOperations = ['list_tasks', 'get_task', 'run_task'] as const
/** Operations that name a single task inside a database and schema. */
const taskNameOperations = [
'get_task',
'run_task',
'resume_task',
'suspend_task',
'list_task_runs',
'get_task_run',
] as const
const taskNameRequiredOperations = ['get_task', 'run_task', 'resume_task', 'suspend_task'] as const
/** Operations that act on, or filter by, a named warehouse. */
const warehouseNameOperations = [
'get_warehouse',
'resume_warehouse',
'suspend_warehouse',
'alter_warehouse',
'list_query_history',
] as const
const warehouseNameRequiredOperations = [
'get_warehouse',
'resume_warehouse',
'suspend_warehouse',
'alter_warehouse',
] as const
/** Operations scoped to a database, and the subset that cannot run without one. */
const databaseOperations = [
'execute_sql',
...dataOperations,
'unload_data',
'list_schemas',
'list_tables',
...taskDefinitionOperations,
'resume_task',
'suspend_task',
'list_copy_history',
'introspect_schema',
'call_procedure',
] as const
const databaseRequiredOperations = [
...dataOperations,
'unload_data',
'list_schemas',
'list_tables',
...taskDefinitionOperations,
'resume_task',
'suspend_task',
'list_copy_history',
'introspect_schema',
'call_procedure',
] as const
/** Operations scoped to a schema, and the subset that cannot run without one. */
const schemaOperations = [
'execute_sql',
...dataOperations,
'unload_data',
'list_tables',
...taskDefinitionOperations,
'resume_task',
'suspend_task',
'list_copy_history',
'introspect_schema',
'call_procedure',
] as const
const schemaRequiredOperations = [
...dataOperations,
'unload_data',
'list_tables',
...taskDefinitionOperations,
'resume_task',
'suspend_task',
'list_copy_history',
'call_procedure',
] as const
/** Operations that name a table, and the subset that cannot run without one. */
const tableOperations = [
...dataOperations,
'unload_data',
'list_copy_history',
'introspect_schema',
] as const
const tableRequiredOperations = [...dataOperations, 'unload_data', 'list_copy_history'] as const
/** Operations that bound their result with a `limit` parameter. */
const limitOperations = [
'list_databases',
'list_schemas',
'list_tables',
'list_tasks',
'list_task_runs',
'list_query_history',
'list_copy_history',
] as const
/** Operations that filter object names with a SQL LIKE pattern. */
const nameLikeOperations = [
'list_databases',
'list_schemas',
'list_tables',
'list_warehouses',
'list_tasks',
] as const
/** Operations that take an ISO-8601 time window. */
const timeRangeOperations = [
'list_task_runs',
'get_task_run',
'list_query_history',
'list_copy_history',
] as const
const sqlSubmissionOperationSet: ReadonlySet<string> = new Set(sqlSubmissionOperations)
const maxRowsOperationSet: ReadonlySet<string> = new Set(maxRowsOperations)
const limitOperationSet: ReadonlySet<string> = new Set(limitOperations)
/**
* Normalizes an optional switch to a real boolean or `undefined`.
*
* With advanced mode on, the serializer evaluates each advanced sub-block's
* condition and emits an untouched switch as `null`. Builders test these with
* `!== undefined`, so that `null` would otherwise emit a clause the user never
* asked for — `AUTO_RESUME = FALSE`, which permanently disables auto-resume on
* the warehouse, being the damaging one.
*
* The string forms are accepted because a direct tool call delivers booleans
* that way, matching the other boolean readers on this block.
*/
function optionalBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value
if (value === 'true') return true
if (value === 'false') return false
return undefined
}
function resolveCopyOnError(value: unknown, threshold: unknown): string | undefined {
if (value === undefined || value === null || value === '') return undefined
@@ -85,13 +233,33 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
description: 'Query data and manage warehouses and tasks in Snowflake',
authMode: AuthMode.ApiKey,
longDescription:
'Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.',
'Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load and unload staged data, browse databases and schemas, size and control warehouses, run and schedule tasks, review query and load history, inspect schemas, and call stored procedures.',
docsLink: 'https://docs.sim.ai/integrations/snowflake',
category: 'tools',
integrationType: IntegrationType.Databases,
bgColor: '#FFFFFF',
icon: SnowflakeIcon,
subBlocks: [
{
id: 'credential',
title: 'Snowflake Account',
type: 'oauth-input',
serviceId: 'snowflake',
credentialKind: 'service-account',
canonicalParamId: 'oauthCredential',
mode: 'basic',
placeholder: 'Select Snowflake credential',
required: true,
},
{
id: 'manualCredential',
title: 'Snowflake Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'operation',
title: 'Operation',
@@ -105,17 +273,26 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
{ label: 'Upsert Rows', id: 'upsert_rows' },
{ label: 'Delete Rows', id: 'delete_rows' },
{ label: 'Load Data', id: 'load_data' },
{ label: 'Unload Data', id: 'unload_data' },
{ label: 'List Databases', id: 'list_databases' },
{ label: 'List Schemas', id: 'list_schemas' },
{ label: 'List Tables', id: 'list_tables' },
{ label: 'List Warehouses', id: 'list_warehouses' },
{ label: 'Get Warehouse', id: 'get_warehouse' },
{ label: 'Resume Warehouse', id: 'resume_warehouse' },
{ label: 'Suspend Warehouse', id: 'suspend_warehouse' },
{ label: 'Alter Warehouse', id: 'alter_warehouse' },
{ label: 'List Tasks', id: 'list_tasks' },
{ label: 'Get Task', id: 'get_task' },
{ label: 'Run Task', id: 'run_task' },
{ label: 'Resume Task', id: 'resume_task' },
{ label: 'Suspend Task', id: 'suspend_task' },
{ label: 'List Task Runs', id: 'list_task_runs' },
{ label: 'Get Task Run', id: 'get_task_run' },
{ label: 'Cancel Task Query', id: 'cancel_task_run' },
{ label: 'Get Task Run Output', id: 'get_task_run_output' },
{ label: 'List Query History', id: 'list_query_history' },
{ label: 'List Copy History', id: 'list_copy_history' },
{ label: 'Introspect Schema', id: 'introspect_schema' },
{ label: 'Call Procedure', id: 'call_procedure' },
],
@@ -128,11 +305,19 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
placeholder: 'SELECT * FROM ANALYTICS.PUBLIC.EVENTS LIMIT 100',
condition: { field: 'operation', value: 'execute_sql' },
required: { field: 'operation', value: 'execute_sql' },
wandConfig: {
enabled: true,
prompt:
'Generate one Snowflake SQL statement for the described request. Use positional ? placeholders for any values that will be bound. Return ONLY the SQL statement - no explanations, no extra text.',
placeholder: 'Describe the query to run...',
generationType: 'sql-query',
},
},
{
id: 'bindings',
title: 'Bindings',
type: 'code',
language: 'json',
placeholder: '{"1":{"type":"TEXT","value":"active"}}',
condition: { field: 'operation', value: 'execute_sql' },
mode: 'advanced',
@@ -141,6 +326,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
prompt:
'Generate a JSON object keyed by 1-based binding position. Each value must contain a Snowflake binding type and a string value, for example {"1":{"type":"TEXT","value":"active"}}. Return ONLY the JSON object - no explanations, no extra text.',
placeholder: 'Describe the values and Snowflake types to bind...',
generationType: 'json-object',
},
},
{
@@ -175,62 +361,79 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
mode: 'advanced',
},
{
id: 'database',
id: 'databaseSelector',
title: 'Database',
type: 'project-selector',
canonicalParamId: 'database',
serviceId: 'snowflake',
selectorKey: 'snowflake.databases',
placeholder: 'Select database',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: [...databaseOperations] },
required: { field: 'operation', value: [...databaseRequiredOperations] },
},
{
id: 'databaseName',
title: 'Database',
type: 'short-input',
canonicalParamId: 'database',
placeholder: 'ANALYTICS',
condition: {
field: 'operation',
value: [
'execute_sql',
...dataOperations,
...taskDefinitionOperations,
'introspect_schema',
'call_procedure',
],
},
required: {
field: 'operation',
value: [
...dataOperations,
...taskDefinitionOperations,
'introspect_schema',
'call_procedure',
],
},
mode: 'advanced',
condition: { field: 'operation', value: [...databaseOperations] },
required: { field: 'operation', value: [...databaseRequiredOperations] },
},
{
id: 'schema',
id: 'schemaSelector',
title: 'Schema',
type: 'project-selector',
canonicalParamId: 'schema',
serviceId: 'snowflake',
selectorKey: 'snowflake.schemas',
placeholder: 'Select schema',
dependsOn: ['credential', 'databaseSelector'],
mode: 'basic',
condition: { field: 'operation', value: [...schemaOperations] },
required: { field: 'operation', value: [...schemaRequiredOperations] },
},
{
id: 'schemaName',
title: 'Schema',
type: 'short-input',
canonicalParamId: 'schema',
placeholder: 'PUBLIC',
condition: {
field: 'operation',
value: [
'execute_sql',
...dataOperations,
...taskDefinitionOperations,
'introspect_schema',
'call_procedure',
],
},
required: {
field: 'operation',
value: [...dataOperations, ...taskDefinitionOperations, 'call_procedure'],
},
mode: 'advanced',
condition: { field: 'operation', value: [...schemaOperations] },
required: { field: 'operation', value: [...schemaRequiredOperations] },
},
{
id: 'table',
id: 'tableSelector',
title: 'Table',
type: 'project-selector',
canonicalParamId: 'table',
serviceId: 'snowflake',
selectorKey: 'snowflake.tables',
placeholder: 'Select table',
dependsOn: ['credential', 'databaseSelector', 'schemaSelector'],
mode: 'basic',
condition: { field: 'operation', value: [...tableOperations] },
required: { field: 'operation', value: [...tableRequiredOperations] },
},
{
id: 'tableName',
title: 'Table',
type: 'short-input',
canonicalParamId: 'table',
placeholder: 'EVENTS',
condition: { field: 'operation', value: [...dataOperations, 'introspect_schema'] },
required: { field: 'operation', value: [...dataOperations] },
mode: 'advanced',
condition: { field: 'operation', value: [...tableOperations] },
required: { field: 'operation', value: [...tableRequiredOperations] },
},
{
id: 'rows',
title: 'Rows',
type: 'code',
language: 'json',
placeholder: '[{"id":1,"status":"active"}]',
condition: { field: 'operation', value: ['insert_rows', 'update_rows', 'upsert_rows'] },
required: { field: 'operation', value: ['insert_rows', 'update_rows', 'upsert_rows'] },
@@ -239,12 +442,14 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
prompt:
'Generate a non-empty JSON array of flat row objects. Every row must have the same keys. Use Load Data instead for bulk ingestion from staged files. Return ONLY the JSON array - no explanations, no extra text.',
placeholder: 'Describe the records to write...',
generationType: 'json-array',
},
},
{
id: 'matchColumns',
title: 'Match Columns',
type: 'code',
language: 'json',
placeholder: '["id"]',
condition: { field: 'operation', value: ['update_rows', 'upsert_rows'] },
required: { field: 'operation', value: ['update_rows', 'upsert_rows'] },
@@ -253,12 +458,14 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
prompt:
'Generate a JSON array containing the row column names that uniquely match target records, for example ["tenant_id","id"]. Return ONLY the JSON array - no explanations, no extra text.',
placeholder: 'Describe the columns that identify a row...',
generationType: 'json-array',
},
},
{
id: 'filters',
title: 'Match Filters',
type: 'code',
language: 'json',
placeholder: '{"status":"expired","tenant_id":42,"archived_at":null}',
condition: { field: 'operation', value: 'delete_rows' },
required: { field: 'operation', value: 'delete_rows' },
@@ -267,6 +474,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
prompt:
'Generate a non-empty JSON object of column filters. All filters are combined with AND. A null value matches rows where that column IS NULL; every other value is matched for equality. Return ONLY the JSON object - no explanations, no extra text.',
placeholder: 'Describe the exact rows to delete...',
generationType: 'json-object',
},
},
{
@@ -274,15 +482,28 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
title: 'Stage Path',
type: 'short-input',
placeholder: '@RAW_STAGE/2026/08',
condition: { field: 'operation', value: 'load_data' },
required: { field: 'operation', value: 'load_data' },
condition: { field: 'operation', value: ['load_data', 'unload_data'] },
required: { field: 'operation', value: ['load_data', 'unload_data'] },
},
{
id: 'fileFormat',
id: 'fileFormatSelector',
title: 'Named File Format',
type: 'project-selector',
canonicalParamId: 'fileFormat',
serviceId: 'snowflake',
selectorKey: 'snowflake.fileFormats',
placeholder: 'Select file format',
dependsOn: ['credential', 'databaseSelector', 'schemaSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['load_data', 'unload_data'] },
},
{
id: 'fileFormatName',
title: 'Named File Format',
type: 'short-input',
canonicalParamId: 'fileFormat',
placeholder: 'ANALYTICS.PUBLIC.CSV_FORMAT',
condition: { field: 'operation', value: 'load_data' },
condition: { field: 'operation', value: ['load_data', 'unload_data'] },
mode: 'advanced',
},
{
@@ -329,7 +550,6 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
value: 'load_data',
and: { field: 'onError', value: ['SKIP_FILE_NUMBER', 'SKIP_FILE_PERCENT'] },
},
mode: 'advanced',
},
{
id: 'purge',
@@ -359,25 +579,34 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
mode: 'advanced',
},
{
id: 'warehouseName',
id: 'warehouseNameSelector',
title: 'Warehouse Name',
type: 'project-selector',
canonicalParamId: 'warehouseName',
serviceId: 'snowflake',
selectorKey: 'snowflake.warehouses',
placeholder: 'Select warehouse',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: [...warehouseNameOperations] },
required: { field: 'operation', value: [...warehouseNameRequiredOperations] },
},
{
id: 'warehouseNameManual',
title: 'Warehouse Name',
type: 'short-input',
canonicalParamId: 'warehouseName',
placeholder: 'COMPUTE_WH',
condition: {
field: 'operation',
value: ['get_warehouse', 'resume_warehouse', 'suspend_warehouse'],
},
required: {
field: 'operation',
value: ['get_warehouse', 'resume_warehouse', 'suspend_warehouse'],
},
mode: 'advanced',
condition: { field: 'operation', value: [...warehouseNameOperations] },
required: { field: 'operation', value: [...warehouseNameRequiredOperations] },
},
{
id: 'nameLike',
title: 'Name Pattern',
type: 'short-input',
placeholder: 'ETL%',
condition: { field: 'operation', value: ['list_warehouses', 'list_tasks'] },
condition: { field: 'operation', value: [...nameLikeOperations] },
mode: 'advanced',
wandConfig: {
enabled: true,
@@ -391,11 +620,8 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
title: 'Task Name',
type: 'short-input',
placeholder: 'DAILY_LOAD (name only, not DB.SCHEMA.TASK)',
condition: {
field: 'operation',
value: ['get_task', 'run_task', 'list_task_runs', 'get_task_run'],
},
required: { field: 'operation', value: ['get_task', 'run_task'] },
condition: { field: 'operation', value: [...taskNameOperations] },
required: { field: 'operation', value: [...taskNameRequiredOperations] },
},
{
id: 'retryLast',
@@ -409,44 +635,44 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
title: 'Limit',
type: 'short-input',
placeholder: '100',
condition: { field: 'operation', value: ['list_tasks', 'list_task_runs'] },
condition: { field: 'operation', value: [...limitOperations] },
mode: 'advanced',
},
{
id: 'startTime',
title: 'Scheduled Time From',
title: 'Time From',
type: 'short-input',
placeholder: '2026-08-01T00:00:00Z',
condition: { field: 'operation', value: ['list_task_runs', 'get_task_run'] },
mode: 'advanced',
condition: { field: 'operation', value: [...timeRangeOperations] },
required: { field: 'operation', value: 'list_copy_history' },
wandConfig: {
enabled: true,
prompt:
'Convert the requested start date and time to an ISO 8601 timestamp within the last seven days. Return ONLY the timestamp - no explanations, no extra text.',
placeholder: 'Describe the beginning of the task history window...',
'Convert the requested start date and time to an ISO 8601 timestamp. Task and query history reach back seven days; copy history reaches back fourteen. Return ONLY the timestamp - no explanations, no extra text.',
placeholder: 'Describe the beginning of the history window...',
generationType: 'timestamp',
},
},
{
id: 'endTime',
title: 'Scheduled Time To',
title: 'Time To',
type: 'short-input',
placeholder: '2026-08-07T00:00:00Z',
condition: { field: 'operation', value: ['list_task_runs', 'get_task_run'] },
condition: { field: 'operation', value: [...timeRangeOperations] },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt:
'Convert the requested end date and time to an ISO 8601 timestamp. Return ONLY the timestamp - no explanations, no extra text.',
placeholder: 'Describe the end of the task history window...',
placeholder: 'Describe the end of the history window...',
generationType: 'timestamp',
},
},
{
id: 'errorOnly',
title: 'Failed and Cancelled Only',
title: 'Failures Only',
type: 'switch',
condition: { field: 'operation', value: 'list_task_runs' },
condition: { field: 'operation', value: ['list_task_runs', 'list_query_history'] },
mode: 'advanced',
},
{
@@ -463,6 +689,69 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
value: ['get_task_run', 'cancel_task_run', 'get_task_run_output'],
},
},
{
id: 'warehouseSize',
title: 'Warehouse Size',
type: 'dropdown',
options: [
{ label: 'Keep current size', id: '' },
...SNOWFLAKE_WAREHOUSE_SIZES.map((size) => ({ label: size, id: size })),
],
value: () => '',
condition: { field: 'operation', value: 'alter_warehouse' },
},
{
id: 'autoSuspendSeconds',
title: 'Auto-Suspend (seconds)',
type: 'short-input',
placeholder: '600',
condition: { field: 'operation', value: 'alter_warehouse' },
mode: 'advanced',
},
{
id: 'autoResume',
title: 'Auto-Resume',
type: 'switch',
condition: { field: 'operation', value: 'alter_warehouse' },
mode: 'advanced',
},
{
id: 'header',
title: 'Write Column Headings',
type: 'switch',
condition: { field: 'operation', value: 'unload_data' },
mode: 'advanced',
},
{
id: 'overwrite',
title: 'Overwrite Existing Files',
type: 'switch',
condition: { field: 'operation', value: 'unload_data' },
mode: 'advanced',
},
{
id: 'singleFile',
title: 'Write a Single File',
type: 'switch',
condition: { field: 'operation', value: 'unload_data' },
mode: 'advanced',
},
{
id: 'maxFileSizeBytes',
title: 'Maximum File Size (bytes)',
type: 'short-input',
placeholder: '16000000',
condition: { field: 'operation', value: 'unload_data' },
mode: 'advanced',
},
{
id: 'userName',
title: 'User Name',
type: 'short-input',
placeholder: 'ANALYST_SVC',
condition: { field: 'operation', value: 'list_query_history' },
mode: 'advanced',
},
{
id: 'includeViews',
title: 'Include Views',
@@ -471,10 +760,25 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
mode: 'advanced',
},
{
id: 'procedureName',
id: 'procedureSelector',
title: 'Procedure Name',
type: 'project-selector',
canonicalParamId: 'procedureName',
serviceId: 'snowflake',
selectorKey: 'snowflake.procedures',
placeholder: 'Select procedure',
dependsOn: ['credential', 'databaseSelector', 'schemaSelector'],
mode: 'basic',
condition: { field: 'operation', value: 'call_procedure' },
required: { field: 'operation', value: 'call_procedure' },
},
{
id: 'procedureNameManual',
title: 'Procedure Name',
type: 'short-input',
canonicalParamId: 'procedureName',
placeholder: 'REFRESH_MODEL',
mode: 'advanced',
condition: { field: 'operation', value: 'call_procedure' },
required: { field: 'operation', value: 'call_procedure' },
},
@@ -482,6 +786,7 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
id: 'procedureArguments',
title: 'Procedure Arguments',
type: 'code',
language: 'json',
placeholder: '[{"type":"TEXT","value":"daily"}]',
condition: { field: 'operation', value: 'call_procedure' },
mode: 'advanced',
@@ -490,20 +795,47 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
prompt:
'Generate an ordered JSON array of Snowflake procedure bindings. Each item must contain a supported Snowflake binding type and a string value, for example {"type":"TEXT","value":"daily"}. Return ONLY the JSON array - no explanations, no extra text.',
placeholder: 'Describe the procedure arguments in order...',
generationType: 'json-array',
},
},
{
id: 'warehouse',
id: 'warehouseSelector',
title: 'Execution Warehouse',
type: 'project-selector',
canonicalParamId: 'warehouse',
serviceId: 'snowflake',
selectorKey: 'snowflake.warehouses',
placeholder: 'Select warehouse',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: [...computeOperations] },
},
{
id: 'warehouseManual',
title: 'Execution Warehouse',
type: 'short-input',
canonicalParamId: 'warehouse',
placeholder: 'COMPUTE_WH',
condition: { field: 'operation', value: [...computeOperations] },
mode: 'advanced',
},
{
id: 'role',
id: 'roleSelector',
title: 'Execution Role',
type: 'project-selector',
canonicalParamId: 'role',
serviceId: 'snowflake',
selectorKey: 'snowflake.roles',
placeholder: 'Select role',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: [...sqlSubmissionOperations] },
},
{
id: 'roleManual',
title: 'Execution Role',
type: 'short-input',
canonicalParamId: 'role',
placeholder: 'ANALYST',
condition: { field: 'operation', value: [...sqlSubmissionOperations] },
mode: 'advanced',
@@ -527,21 +859,6 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
},
mode: 'advanced',
},
{
id: 'host',
title: 'Account Host',
type: 'short-input',
placeholder: 'myorg-myaccount.snowflakecomputing.com',
required: true,
},
{
id: 'apiKey',
title: 'Programmatic Access Token',
type: 'short-input',
placeholder: 'Enter your Snowflake PAT',
password: true,
required: true,
},
],
tools: {
access: [
@@ -553,17 +870,26 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
'snowflake_upsert_rows',
'snowflake_delete_rows',
'snowflake_load_data',
'snowflake_unload_data',
'snowflake_list_databases',
'snowflake_list_schemas',
'snowflake_list_tables',
'snowflake_list_warehouses',
'snowflake_get_warehouse',
'snowflake_resume_warehouse',
'snowflake_suspend_warehouse',
'snowflake_alter_warehouse',
'snowflake_list_tasks',
'snowflake_get_task',
'snowflake_run_task',
'snowflake_resume_task',
'snowflake_suspend_task',
'snowflake_list_task_runs',
'snowflake_get_task_run',
'snowflake_cancel_task_run',
'snowflake_get_task_run_output',
'snowflake_list_query_history',
'snowflake_list_copy_history',
'snowflake_introspect_schema',
'snowflake_call_procedure',
],
@@ -582,6 +908,9 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
if (maxRowsOperationSet.has(operation)) {
result.maxRows = parseOptionalNumberInput(params.maxRows, 'Maximum result rows')
}
if (limitOperationSet.has(operation)) {
result.limit = parseOptionalNumberInput(params.limit, 'Limit')
}
switch (operation) {
case 'execute_sql':
@@ -608,12 +937,24 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
case 'load_data':
result.onError = resolveCopyOnError(params.onError, params.onErrorThreshold)
result.onErrorThreshold = undefined
result.purge = optionalBoolean(params.purge)
result.force = optionalBoolean(params.force)
break
case 'list_tasks':
result.limit = parseOptionalNumberInput(params.limit, 'Task limit')
case 'unload_data':
result.maxFileSizeBytes = parseOptionalNumberInput(
params.maxFileSizeBytes,
'Maximum file size'
)
result.header = optionalBoolean(params.header)
result.overwrite = optionalBoolean(params.overwrite)
result.singleFile = optionalBoolean(params.singleFile)
break
case 'list_task_runs':
result.limit = parseOptionalNumberInput(params.limit, 'Task run limit')
case 'alter_warehouse':
result.autoSuspendSeconds = parseOptionalNumberInput(
params.autoSuspendSeconds,
'Auto-suspend seconds'
)
result.autoResume = optionalBoolean(params.autoResume)
break
case 'call_procedure':
result.procedureArguments = parseOptionalJsonInput(
@@ -628,8 +969,10 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
host: { type: 'string', description: 'Snowflake account hostname' },
apiKey: { type: 'string', description: 'Snowflake programmatic access token' },
oauthCredential: {
type: 'string',
description: 'Snowflake credential (account host and programmatic access token)',
},
statement: { type: 'string', description: 'Single SQL statement' },
bindings: {
type: 'string',
@@ -669,7 +1012,10 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
purge: { type: 'boolean', description: 'Purge successfully loaded staged files' },
force: { type: 'boolean', description: 'Force staged files to load again' },
matchByColumnName: { type: 'string', description: 'COPY column-name matching policy' },
warehouseName: { type: 'string', description: 'Warehouse to retrieve or change' },
warehouseName: {
type: 'string',
description: 'Warehouse to retrieve or change, or to filter query history by',
},
nameLike: { type: 'string', description: 'SQL LIKE pattern for object names' },
taskName: {
type: 'string',
@@ -678,12 +1024,26 @@ export const SnowflakeBlock: BlockConfig<SnowflakeStatementResponse> = {
},
retryLast: { type: 'boolean', description: 'Retry the last failed task graph' },
limit: { type: 'number', description: 'Maximum list results' },
startTime: { type: 'string', description: 'Task history scheduled-time range start' },
endTime: { type: 'string', description: 'Task history scheduled-time range end' },
startTime: { type: 'string', description: 'History window start as an ISO-8601 timestamp' },
endTime: { type: 'string', description: 'History window end as an ISO-8601 timestamp' },
errorOnly: {
type: 'boolean',
description: 'Only return task runs that failed or were cancelled',
description: 'Only return task runs or queries that failed',
},
warehouseSize: { type: 'string', description: 'New warehouse size' },
autoSuspendSeconds: {
type: 'number',
description: 'Warehouse idle seconds before auto-suspend; 0 disables it',
},
autoResume: {
type: 'boolean',
description: 'Whether the warehouse resumes automatically on the next statement',
},
header: { type: 'boolean', description: 'Write column headings into unloaded files' },
overwrite: { type: 'boolean', description: 'Replace stage files with matching names' },
singleFile: { type: 'boolean', description: 'Unload to one file instead of several' },
maxFileSizeBytes: { type: 'number', description: 'Per-file byte ceiling for unloaded files' },
userName: { type: 'string', description: 'Query history user filter' },
queryId: {
type: 'string',
description: 'Task run query UUID. Cancel Task Query cancels only this single query',
@@ -771,6 +1131,44 @@ export const SnowflakeBlockMeta = {
tags: ['finance', 'devops'],
alsoIntegrations: ['slack'],
},
{
icon: SnowflakeIcon,
title: 'Snowflake result export',
prompt:
'Build a scheduled workflow that unloads a Snowflake query result to a stage as a single CSV file with headers, then posts the file count and row total to Slack.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['data', 'automation'],
alsoIntegrations: ['slack'],
},
{
icon: SnowflakeIcon,
title: 'Snowflake ingestion audit',
prompt:
'Create a workflow that reviews Snowflake copy history for a landing table, identifies files that failed to load, and opens a ticket with the first error on each one.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'engineering',
tags: ['monitoring', 'data'],
},
{
icon: SnowflakeIcon,
title: 'Snowflake slow-query review',
prompt:
'Build a weekly workflow that lists the longest-running Snowflake queries on a warehouse, summarizes the patterns behind them, and reports the top offenders with their elapsed times.',
modules: ['scheduled', 'agent', 'workflows'],
category: 'operations',
tags: ['analysis', 'monitoring'],
},
{
icon: SnowflakeIcon,
title: 'Snowflake task pause and resume',
prompt:
'Create a workflow that suspends a Snowflake task when its recent runs keep failing, notifies the data platform channel, and resumes it once someone confirms the fix.',
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['devops', 'automation'],
alsoIntegrations: ['slack'],
},
{
icon: SnowflakeIcon,
title: 'Snowflake schema-drift audit',
@@ -811,15 +1209,34 @@ export const SnowflakeBlockMeta = {
},
{
name: 'monitor-snowflake-tasks',
description: 'Inspect Snowflake task history and diagnose failed runs.',
description:
'Inspect Snowflake task history, diagnose failed runs, and pause or resume a task.',
content:
'# Monitor Snowflake Tasks\n\n## Steps\n1. Query the relevant time window within seven days.\n2. Filter to failures when appropriate.\n3. Get the selected run by query ID.\n4. Retrieve output only when it remains available and access permits it.\n\n## Output\nReturn run state, timing, query ID, and a failure summary.',
'# Monitor Snowflake Tasks\n\n## Steps\n1. Query the relevant time window within seven days.\n2. Filter to failures when appropriate.\n3. Get the selected run by query ID.\n4. Retrieve output only when it remains available and access permits it.\n5. Suspend a task to stop its schedule, and resume it once the cause is fixed. Resume every child task before the root task of a task graph.\n\n## Output\nReturn run state, timing, query ID, a failure summary, and any schedule change made.',
},
{
name: 'control-snowflake-warehouse',
description: 'Inspect and deliberately resume or suspend a Snowflake warehouse.',
description: 'Inspect, resume, suspend, or resize a Snowflake warehouse.',
content:
'# Control a Snowflake Warehouse\n\n## Steps\n1. List or describe the target warehouse.\n2. Confirm its name and current state.\n3. Resume or suspend only when requested.\n4. Verify the statement completed.\n\n## Output\nReturn the warehouse name, prior state, requested action, and result.',
'# Control a Snowflake Warehouse\n\n## Steps\n1. List or describe the target warehouse.\n2. Confirm its name and current state.\n3. Resume, suspend, or alter its size, auto-suspend, and auto-resume only when requested.\n4. Verify the statement completed.\n\n## Output\nReturn the warehouse name, prior state, requested action, and result.',
},
{
name: 'export-snowflake-results',
description: 'Unload a table or query result to files in a Snowflake stage.',
content:
'# Export Snowflake Results\n\n## Steps\n1. Confirm the destination table and the stage path, and whether existing files may be overwritten.\n2. To export a query result rather than a whole table, materialize it first — create a view or use CREATE TABLE AS SELECT via Execute SQL — then unload that object.\n3. Pick a named file format, and decide on column headings and whether one file or several.\n4. Run the unload and review the reported file count and row totals.\n\n## Output\nReturn the stage path, files written, and rows unloaded.',
},
{
name: 'browse-snowflake-objects',
description: 'Discover the databases, schemas, and tables a Snowflake credential can reach.',
content:
'# Browse Snowflake Objects\n\n## Steps\n1. List databases to find the one in scope.\n2. List schemas in that database, then tables in the chosen schema.\n3. Narrow long lists with a SQL LIKE pattern and a row limit.\n4. Introspect the chosen table when column types matter.\n\n## Output\nReturn the object names found and the qualified name of the one selected.',
},
{
name: 'review-snowflake-history',
description: 'Review recent query executions and staged-file loads to explain a failure.',
content:
'# Review Snowflake History\n\n## Steps\n1. For query problems, list query history over the window in question, optionally filtered to one user or warehouse, or to failures only. It reaches back seven days.\n2. For ingestion problems, list copy history for the target table. It requires a start time and reaches back fourteen days.\n3. Read the error message and file status on the offending rows.\n\n## Output\nReturn the failing statement or file, its error, and the likely cause.',
},
{
name: 'audit-snowflake-schema',
+1
View File
@@ -181,6 +181,7 @@ export type GenerationType =
| 'typescript-function-body'
| 'json-schema'
| 'json-object'
| 'json-array'
| 'table-schema'
| 'system-prompt'
| 'custom-tool-schema'
@@ -0,0 +1,104 @@
import { requestJson } from '@/lib/api/client/request'
import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake'
import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type {
SelectorDefinition,
SelectorKey,
SelectorOption,
SelectorQueryArgs,
} from '@/hooks/selectors/types'
import type { SnowflakeSelectorKind } from '@/tools/snowflake/selector-kinds'
type SnowflakeSelectorKey = Extract<SelectorKey, `snowflake.${string}`>
/** What each picker needs in scope before it can list anything. */
type SnowflakeSelectorScopeLevel = 'account' | 'database' | 'schema'
interface SnowflakeSelectorSpec {
kind: SnowflakeSelectorKind
scope: SnowflakeSelectorScopeLevel
}
const SNOWFLAKE_SELECTOR_SPECS: Record<SnowflakeSelectorKey, SnowflakeSelectorSpec> = {
'snowflake.databases': { kind: 'databases', scope: 'account' },
'snowflake.warehouses': { kind: 'warehouses', scope: 'account' },
'snowflake.roles': { kind: 'roles', scope: 'account' },
'snowflake.schemas': { kind: 'schemas', scope: 'database' },
'snowflake.tables': { kind: 'tables', scope: 'schema' },
'snowflake.fileFormats': { kind: 'file_formats', scope: 'schema' },
'snowflake.procedures': { kind: 'procedures', scope: 'schema' },
}
/**
* Snowflake object names are the values the tools send back as identifiers, so
* the option id IS the name. The detail column is shown only when it adds
* something the name doesn't already say.
*/
function toOption(object: { name: string; detail: string | null }): SelectorOption {
return {
id: object.name,
label: object.detail ? `${object.name} — ${object.detail}` : object.name,
meta: { name: object.name, ...(object.detail ? { detail: object.detail } : {}) },
}
}
function scopeSatisfied(spec: SnowflakeSelectorSpec, args: SelectorQueryArgs): boolean {
const { context } = args
if (!context.oauthCredential) return false
if (spec.scope === 'account') return true
if (!context.database) return false
return spec.scope === 'database' || Boolean(context.schema)
}
function buildSelector(key: SnowflakeSelectorKey): SelectorDefinition {
const spec = SNOWFLAKE_SELECTOR_SPECS[key]
const fetchObjects = async ({ context, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, key)
const data = await requestJson(snowflakeObjectsSelectorContract, {
body: {
credential: credentialId,
workflowId: context.workflowId,
kind: spec.kind,
database: spec.scope === 'account' ? undefined : context.database,
schema: spec.scope === 'schema' ? context.schema : undefined,
},
signal,
})
return data.objects
}
return {
key,
contracts: [snowflakeObjectsSelectorContract],
staleTime: SELECTOR_STALE,
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
key,
context.oauthCredential ?? 'none',
context.database ?? 'none',
context.schema ?? 'none',
],
enabled: (args: SelectorQueryArgs) => scopeSatisfied(spec, args),
fetchList: async (args: SelectorQueryArgs) => (await fetchObjects(args)).map(toOption),
fetchById: async (args: SelectorQueryArgs) => {
if (!args.detailId || !scopeSatisfied(spec, args)) return null
const match = (await fetchObjects(args)).find((object) => object.name === args.detailId)
return match ? toOption(match) : null
},
// Snowflake exposes no lookup-by-name endpoint; `fetchById` filters the
// same listing, so an id that does not exist resolves to null rather than
// erroring.
resolvesUnknownIds: true,
}
}
export const snowflakeSelectors = {
'snowflake.databases': buildSelector('snowflake.databases'),
'snowflake.schemas': buildSelector('snowflake.schemas'),
'snowflake.tables': buildSelector('snowflake.tables'),
'snowflake.warehouses': buildSelector('snowflake.warehouses'),
'snowflake.roles': buildSelector('snowflake.roles'),
'snowflake.fileFormats': buildSelector('snowflake.fileFormats'),
'snowflake.procedures': buildSelector('snowflake.procedures'),
} satisfies Record<SnowflakeSelectorKey, SelectorDefinition>
+2
View File
@@ -18,6 +18,7 @@ import { pipedriveSelectors } from '@/hooks/selectors/providers/pipedrive/select
import { sharepointSelectors } from '@/hooks/selectors/providers/sharepoint/selectors'
import { simSelectors } from '@/hooks/selectors/providers/sim/selectors'
import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors'
import { snowflakeSelectors } from '@/hooks/selectors/providers/snowflake/selectors'
import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors'
import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors'
import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors'
@@ -56,6 +57,7 @@ export const selectorRegistry = {
...clickupSelectors,
...cloudwatchSelectors,
...simSelectors,
...snowflakeSelectors,
} satisfies Record<SelectorKey, SelectorDefinition>
export function getSelectorDefinition(key: SelectorKey): SelectorDefinition {
+11
View File
@@ -31,6 +31,13 @@ export type SelectorKey =
| 'zoho_desk.agents'
| 'zoom.meetings'
| 'slack.channels'
| 'snowflake.databases'
| 'snowflake.schemas'
| 'snowflake.tables'
| 'snowflake.warehouses'
| 'snowflake.roles'
| 'snowflake.fileFormats'
| 'snowflake.procedures'
| 'slack.users'
| 'gmail.labels'
| 'outlook.folders'
@@ -104,6 +111,10 @@ export interface SelectorContext {
logGroupName?: string
mcpServerId?: string
tableId?: string
/** Snowflake database holding the objects a picker enumerates. */
database?: string
/** Snowflake schema holding the objects a picker enumerates. */
schema?: string
/** Zoho Desk organization (portal) id — the `orgId` header every Desk call but `/organizations` requires. */
orgId?: string
}
@@ -95,6 +95,7 @@ import {
slackUserSelectorContract,
slackUsersSelectorContract,
} from '@/lib/api/contracts/selectors/slack'
import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake'
import { trelloBoardsSelectorContract } from '@/lib/api/contracts/selectors/trello'
import {
wealthboxItemContract,
@@ -135,6 +136,7 @@ export * from '@/lib/api/contracts/selectors/oauth'
export * from '@/lib/api/contracts/selectors/pipedrive'
export * from '@/lib/api/contracts/selectors/sharepoint'
export * from '@/lib/api/contracts/selectors/slack'
export * from '@/lib/api/contracts/selectors/snowflake'
export * from '@/lib/api/contracts/selectors/trello'
export * from '@/lib/api/contracts/selectors/wealthbox'
export * from '@/lib/api/contracts/selectors/webflow'
@@ -173,6 +175,7 @@ export const selectorContractsByPath = {
'/api/tools/zoho_desk/agents': zohoDeskAgentsSelectorContract,
'/api/tools/zoom/meetings': zoomMeetingsSelectorContract,
'/api/tools/slack/channels': slackChannelsSelectorContract,
'/api/tools/snowflake/objects': snowflakeObjectsSelectorContract,
'/api/tools/slack/users': slackUsersSelectorContract,
'/api/tools/slack/users:detail': slackUserSelectorContract,
'/api/tools/gmail/labels': gmailLabelsSelectorContract,
@@ -0,0 +1,59 @@
import { z } from 'zod'
import {
credentialWorkflowBodySchema,
definePostSelector,
} from '@/lib/api/contracts/selectors/shared'
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
import { SNOWFLAKE_SELECTOR_KINDS } from '@/tools/snowflake/selector-kinds'
/**
* One route backs every Snowflake picker: each `kind` maps to a metadata-only
* `SHOW` statement over the same SQL API endpoint and the same credential, so
* splitting them into nine routes would duplicate the credential resolution
* and statement transport nine times.
*/
export const snowflakeObjectsBodySchema = credentialWorkflowBodySchema
.extend({
kind: z.enum(SNOWFLAKE_SELECTOR_KINDS),
database: z.string().min(1, 'database cannot be empty').max(255).optional(),
schema: z.string().min(1, 'schema cannot be empty').max(255).optional(),
})
.superRefine((value, ctx) => {
const needsDatabase =
value.kind !== 'databases' && value.kind !== 'warehouses' && value.kind !== 'roles'
const needsSchema = needsDatabase && value.kind !== 'schemas'
if (needsDatabase && !value.database?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['database'],
message: `database is required to list ${value.kind}`,
})
}
if (needsSchema && !value.schema?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['schema'],
message: `schema is required to list ${value.kind}`,
})
}
})
const snowflakeObjectSchema = z.object({
/** Snowflake object name, exactly as Snowflake stores it. */
name: z.string().min(1),
/** Kind-specific secondary column (state, type, comment, or signature). */
detail: z.string().nullable(),
})
export const snowflakeObjectsSelectorContract = definePostSelector(
'/api/tools/snowflake/objects',
snowflakeObjectsBodySchema,
z.object({ objects: z.array(snowflakeObjectSchema) })
)
export type SnowflakeObjectsSelectorBody = ContractBodyInput<
typeof snowflakeObjectsSelectorContract
>
export type SnowflakeObjectsSelectorResponse = ContractJsonResponse<
typeof snowflakeObjectsSelectorContract
>
@@ -79,9 +79,17 @@ export const WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID = 'wealthbox-service-account'
export const PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'pipedrive-service-account' as const
export const CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID =
'claude-platform-service-account' as const
export const SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID = 'snowflake-service-account' as const
const SHOPIFY_DOMAIN_HINT_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i
/**
* Account hostnames carry a variable number of labels — `myorg-myaccount`,
* but also the legacy `xy12345.us-east-1` locator form — so dots are allowed
* before the `snowflakecomputing` suffix. China accounts use `.cn`.
*/
const SNOWFLAKE_HOST_HINT_REGEX = /^[a-z0-9][a-z0-9.-]*\.snowflakecomputing\.(com|cn)$/i
export type TokenServiceAccountProviderId =
| typeof HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID
| typeof AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID
@@ -98,6 +106,7 @@ export type TokenServiceAccountProviderId =
| typeof WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID
| typeof PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID
export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
TokenServiceAccountProviderId,
@@ -381,6 +390,33 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
],
docsUrl: 'https://docs.sim.ai/integrations/managed-agent',
},
[SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
serviceLabel: 'Snowflake',
tokenNoun: 'programmatic access token',
connectNoun: 'programmatic access token',
fields: [
{
id: 'domain',
label: 'Account host',
placeholder: 'myorg-myaccount.snowflakecomputing.com',
secret: false,
hintPattern: SNOWFLAKE_HOST_HINT_REGEX,
hintMessage: 'Snowflake account hosts end in .snowflakecomputing.com.',
},
{
id: 'apiToken',
label: 'Programmatic access token',
placeholder: 'Paste a programmatic access token',
secret: true,
},
],
docsUrl: 'https://docs.sim.ai/integrations/snowflake-service-account',
helpText:
'A programmatic access token acts as the Snowflake user that owns it, or as the single role it was restricted to. It always expires — 15 days by default, 365 at most — and every user type except SERVICE_AGENT must be covered by a network policy to authenticate with it, unless an authentication policy waives that.',
invalidCredentialsHelp:
'Snowflake rejected this token. Check that it belongs to a user on this exact account host, that it has not expired or been revoked, that a network policy allows Sim to reach the account, and that the SQL API is enabled for the account.',
},
}
/**
@@ -13,6 +13,7 @@ import {
NOTION_SERVICE_ACCOUNT_PROVIDER_ID,
PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID,
SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID,
SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID,
TOKEN_SERVICE_ACCOUNT_SECRET_TYPE,
type TokenServiceAccountProviderId,
TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -31,6 +32,7 @@ import { validateMondayServiceAccount } from '@/lib/credentials/token-service-ac
import { validateNotionServiceAccount } from '@/lib/credentials/token-service-accounts/validators/notion'
import { validatePipedriveServiceAccount } from '@/lib/credentials/token-service-accounts/validators/pipedrive'
import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify'
import { validateSnowflakeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/snowflake'
import { validateTrelloServiceAccount } from '@/lib/credentials/token-service-accounts/validators/trello'
import { validateWealthboxServiceAccount } from '@/lib/credentials/token-service-accounts/validators/wealthbox'
import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow'
@@ -94,6 +96,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record<
[WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: validateWealthboxServiceAccount,
[PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePipedriveServiceAccount,
[CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID]: validateClaudePlatformServiceAccount,
[SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSnowflakeServiceAccount,
}
export function getTokenServiceAccountValidator(
@@ -0,0 +1,111 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
import { validateSnowflakeServiceAccount } from '@/lib/credentials/token-service-accounts/validators/snowflake'
const mockFetch = vi.fn()
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const fields = { apiToken: 'pat-secret', domain: 'MyOrg-MyAccount.snowflakecomputing.com' }
async function expectCode(promise: Promise<unknown>, code: string, status?: number) {
await expect(promise).rejects.toBeInstanceOf(TokenServiceAccountValidationError)
await promise.catch((error: TokenServiceAccountValidationError) => {
expect(error.code).toBe(code)
if (status !== undefined) expect(error.status).toBe(status)
})
}
describe('validateSnowflakeServiceAccount', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
vi.unstubAllGlobals()
// resetAllMocks, not clearAllMocks: the latter leaves queued
// mockResolvedValueOnce values behind to leak into the next test.
vi.resetAllMocks()
})
it('verifies through the SQL API with the PAT headers the tools use', async () => {
mockFetch.mockResolvedValueOnce(
jsonResponse(200, { data: [['SVC_USER', 'MYORG-MYACCOUNT', 'ANALYST']] })
)
const result = await validateSnowflakeServiceAccount(fields)
const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe('https://myorg-myaccount.snowflakecomputing.com/api/v2/statements')
expect(init.headers.Authorization).toBe('Bearer pat-secret')
expect(init.headers['X-Snowflake-Authorization-Token-Type']).toBe('PROGRAMMATIC_ACCESS_TOKEN')
expect(result).toEqual({
displayName: 'SVC_USER (MYORG-MYACCOUNT)',
principal: { kind: 'user', id: 'SVC_USER' },
auditMetadata: { account: 'MYORG-MYACCOUNT', role: 'ANALYST' },
storedMetadata: { account: 'MYORG-MYACCOUNT', role: 'ANALYST' },
normalizedDomain: 'myorg-myaccount.snowflakecomputing.com',
})
})
it('rejects a host that is not a Snowflake account hostname before any request', async () => {
await expectCode(
validateSnowflakeServiceAccount({ ...fields, domain: 'evil.com' }),
'site_not_found'
)
expect(mockFetch).not.toHaveBeenCalled()
})
/**
* Snowflake wildcard-resolves `*.snowflakecomputing.com`, so a mistyped
* account answers 404 instead of failing DNS. Without this mapping the user
* is told the provider is down for a host that will never work.
*/
it('maps a 404 to a bad account host, not a provider outage', async () => {
mockFetch.mockResolvedValueOnce(new Response('File not Found', { status: 404 }))
await expectCode(validateSnowflakeServiceAccount(fields), 'site_not_found')
})
/**
* Snowflake answers 403 both for a disabled SQL API and for a network-policy
* rejection, so both must reach the provider's invalid-credentials help,
* which names every cause — not a "provider is down" message.
*/
it('maps 401 and 403 to a rejected credential', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(401, { message: 'invalid token' }))
await expectCode(validateSnowflakeServiceAccount(fields), 'invalid_credentials', 401)
mockFetch.mockResolvedValueOnce(
jsonResponse(403, { message: 'not allowed to access Snowflake' })
)
await expectCode(validateSnowflakeServiceAccount(fields), 'invalid_credentials', 403)
})
it('treats a deferred statement and a metadata-less success as distinct provider problems', async () => {
// The status is what separates these two: without the 202 branch the
// deferred response would fall through to the metadata-less path and throw
// 502, so asserting only the code cannot tell them apart.
mockFetch.mockResolvedValueOnce(jsonResponse(202, { statementHandle: 'abc' }))
await expectCode(validateSnowflakeServiceAccount(fields), 'provider_unavailable', 202)
mockFetch.mockResolvedValueOnce(jsonResponse(200, { data: [] }))
await expectCode(validateSnowflakeServiceAccount(fields), 'provider_unavailable', 502)
})
it('falls back to the account when the token reports no user', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse(200, { data: [[null, 'MYORG-MYACCOUNT', null]] }))
const result = await validateSnowflakeServiceAccount(fields)
expect(result.displayName).toBe('MYORG-MYACCOUNT')
expect(result.principal).toEqual({ kind: 'tenant', id: 'MYORG-MYACCOUNT' })
expect(result.auditMetadata).toEqual({ account: 'MYORG-MYACCOUNT' })
})
})
@@ -0,0 +1,137 @@
import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal'
import {
fetchProvider,
parseProviderJson,
readProviderErrorSnippet,
TokenServiceAccountValidationError,
throwForProviderResponse,
} from '@/lib/credentials/token-service-accounts/errors'
import type {
TokenServiceAccountFields,
TokenServiceAccountValidationResult,
} from '@/lib/credentials/token-service-accounts/server'
import { buildSnowflakeAuthHeaders, normalizeSnowflakeHost } from '@/tools/snowflake/utils'
/**
* Context functions only — they resolve from the session the token opens and
* need no warehouse, so a token whose user has no default warehouse still
* verifies. Column order is the tuple order Snowflake returns in `data`.
*/
const IDENTITY_STATEMENT = 'SELECT CURRENT_USER(), CURRENT_ACCOUNT(), CURRENT_ROLE()'
/**
* Seconds Snowflake may spend on the verification statement. Kept below
* `fetchProvider`'s 10s HTTP abort so Snowflake's own timeout always wins and
* the user sees a statement error rather than "network error reaching provider".
*/
const IDENTITY_STATEMENT_TIMEOUT_SECONDS = 5
interface SnowflakeStatementApiResponse {
message?: string
data?: Array<Array<string | null>>
}
/** Trimmed cell value, or undefined when Snowflake returned null/blank. */
function cell(row: Array<string | null> | undefined, index: number): string | undefined {
const value = row?.[index]
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
/**
* Validates a Snowflake programmatic access token by running a context-only
* statement against the account's SQL API — the same endpoint, headers, and
* host normalization every Sim Snowflake tool uses, so a passing validation
* proves the credential works with the tools as written.
*
* `normalizeSnowflakeHost` is the SSRF guard: it rejects anything that is not
* a bare `*.snowflakecomputing.com`/`.cn` hostname before any outbound fetch.
*
* Snowflake wildcard-resolves `*.snowflakecomputing.com`, so an account that
* does not exist still resolves and answers **404** rather than failing DNS —
* that 404 is what identifies a mistyped host, and without the branch below it
* would be blamed on the provider forever. The DNS mapping is kept only as a
* defensive fallback for hosts outside the wildcard.
*/
export async function validateSnowflakeServiceAccount(
fields: TokenServiceAccountFields
): Promise<TokenServiceAccountValidationResult> {
let baseUrl: string
try {
baseUrl = normalizeSnowflakeHost(fields.domain ?? '')
} catch {
throw new TokenServiceAccountValidationError('site_not_found', 400, {
step: 'host_validation',
domain: fields.domain,
reason: 'host is not a Snowflake account hostname',
})
}
const domain = new URL(baseUrl).hostname
const res = await fetchProvider(
`${baseUrl}/api/v2/statements`,
{
method: 'POST',
headers: buildSnowflakeAuthHeaders(fields.apiToken),
body: JSON.stringify({
statement: IDENTITY_STATEMENT,
timeout: IDENTITY_STATEMENT_TIMEOUT_SECONDS,
}),
},
'identity_statement',
{ dnsFailureCode: 'site_not_found', dnsFailureReason: 'account host does not resolve' }
)
// A wrong account host resolves through Snowflake's wildcard DNS and answers
// 404, so it must be reported as a bad host rather than a provider outage.
if (res.status === 404) {
throw new TokenServiceAccountValidationError('site_not_found', 404, {
step: 'identity_statement',
domain,
body: await readProviderErrorSnippet(res),
})
}
// 403 is left to the shared 401/403 branch on purpose. Snowflake returns it
// both when the SQL API is disabled and when a network policy rejects the
// caller's IP, and the response wording for neither is documented — so rather
// than guess from the body, both surface the provider's `invalidCredentialsHelp`,
// which names every cause.
await throwForProviderResponse(res, 'identity_statement', { domain })
// 202 means the statement is still executing. A context-only SELECT that
// Snowflake could not finish synchronously says nothing about the token, so
// it is an outage rather than a rejection.
if (res.status === 202) {
throw new TokenServiceAccountValidationError('provider_unavailable', 202, {
step: 'identity_statement',
domain,
reason: 'verification statement did not complete synchronously',
})
}
const payload = await parseProviderJson<SnowflakeStatementApiResponse>(res, 'identity_statement')
const row = payload.data?.[0]
const user = cell(row, 0)
const account = cell(row, 1)
const role = cell(row, 2)
if (!account) {
throw new TokenServiceAccountValidationError('provider_unavailable', 502, {
step: 'identity_statement',
domain,
reason: 'statement succeeded without an account identity',
})
}
const metadata = { account, ...(role ? { role } : {}) }
return {
displayName: user ? `${user} (${account})` : account,
// A PAT belongs to one Snowflake user, so the user is the finest identity
// available; accounts that mask it fall back to the account itself. The
// account is carried in metadata rather than the principal's label slot,
// which names the human actor.
principal: user ? userPrincipal(user) : tenantPrincipal(account),
auditMetadata: metadata,
storedMetadata: metadata,
normalizedDomain: domain,
}
}
@@ -67,6 +67,9 @@ const EXPECTED_COVERAGE: Record<string, string[]> = {
'salesforce-service-account': ['salesforce'],
'shopify-service-account': ['shopify'],
'slack-custom-bot': ['slack'],
// Snowflake's catalog entry is api-key (there is no Snowflake OAuth client),
// so its credential is offered on the block rather than an integration page.
'snowflake-service-account': [],
'trello-service-account': ['trello'],
'wealthbox-service-account': ['wealthbox'],
'webflow-service-account': ['webflow'],
+39 -3
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-08-08",
"updatedAt": "2026-08-09",
"integrations": [
{
"type": "onepassword",
@@ -18247,7 +18247,7 @@
"slug": "snowflake",
"name": "Snowflake",
"description": "Query data and manage warehouses and tasks in Snowflake",
"longDescription": "Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load staged data, manage warehouses and tasks, inspect schemas, and call stored procedures.",
"longDescription": "Connect with a Snowflake programmatic access token to execute SQL, synchronize structured rows, load and unload staged data, browse databases and schemas, size and control warehouses, run and schedule tasks, review query and load history, inspect schemas, and call stored procedures.",
"bgColor": "#FFFFFF",
"iconName": "SnowflakeIcon",
"docsUrl": "https://docs.sim.ai/integrations/snowflake",
@@ -18284,6 +18284,22 @@
"name": "Load Data",
"description": "Load files from an existing Snowflake stage with COPY INTO."
},
{
"name": "Unload Data",
"description": "Export a Snowflake table to files in a stage with COPY INTO."
},
{
"name": "List Databases",
"description": "List the databases the credential can access."
},
{
"name": "List Schemas",
"description": "List the schemas in a Snowflake database."
},
{
"name": "List Tables",
"description": "List the tables in a Snowflake schema."
},
{
"name": "List Warehouses",
"description": "List warehouses visible to the active Snowflake role."
@@ -18300,6 +18316,10 @@
"name": "Suspend Warehouse",
"description": "Suspend a Snowflake virtual warehouse."
},
{
"name": "Alter Warehouse",
"description": "Resize a Snowflake warehouse or change its auto-suspend and auto-resume settings."
},
{
"name": "List Tasks",
"description": "List tasks in a Snowflake schema."
@@ -18312,6 +18332,14 @@
"name": "Run Task",
"description": "Run a Snowflake task immediately, optionally retrying its last failed graph."
},
{
"name": "Resume Task",
"description": "Resume a suspended Snowflake task so its schedule runs again."
},
{
"name": "Suspend Task",
"description": "Suspend a Snowflake task so its schedule stops triggering runs."
},
{
"name": "List Task Runs",
"description": "Query up to seven days of Snowflake task history, capped at 10000 rows."
@@ -18328,6 +18356,14 @@
"name": "Get Task Run Output",
"description": "Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention window using the task owner role."
},
{
"name": "List Query History",
"description": "List queries that completed in the last seven days, optionally filtered."
},
{
"name": "List Copy History",
"description": "List staged-file load results for a table over the last fourteen days."
},
{
"name": "Introspect Schema",
"description": "Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views."
@@ -18337,7 +18373,7 @@
"description": "Call a stored procedure with explicitly typed Snowflake bindings."
}
],
"operationCount": 21,
"operationCount": 30,
"triggers": [],
"triggerCount": 0,
"authType": "api-key",
+18
View File
@@ -47,6 +47,7 @@ import {
SalesforceIcon,
ShopifyIcon,
SlackIcon,
SnowflakeIcon,
SpotifyIcon,
TikTokIcon,
TrelloIcon,
@@ -838,6 +839,23 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'slack',
},
snowflake: {
name: 'Snowflake',
icon: SnowflakeIcon,
services: {
snowflake: {
name: 'Snowflake',
description: 'Query data and manage warehouses and tasks in Snowflake.',
providerId: 'snowflake',
serviceAccountProviderId: 'snowflake-service-account',
icon: SnowflakeIcon,
baseProviderIcon: SnowflakeIcon,
scopes: [],
authType: 'service_account',
},
},
defaultService: 'snowflake',
},
reddit: {
name: 'Reddit',
icon: RedditIcon,
@@ -92,6 +92,7 @@ describe('shouldStripCodeFences', () => {
expect(shouldStripCodeFences('javascript-function-body')).toBe(true)
expect(shouldStripCodeFences('custom-tool-schema')).toBe(true)
expect(shouldStripCodeFences('json-object')).toBe(true)
expect(shouldStripCodeFences('json-array')).toBe(true)
expect(shouldStripCodeFences('cron-expression')).toBe(true)
})
+1
View File
@@ -19,6 +19,7 @@ const STRIPS_CODE_FENCES: Record<GenerationType, boolean> = {
'typescript-function-body': true,
'json-schema': true,
'json-object': true,
'json-array': true,
'table-schema': true,
'system-prompt': false,
'custom-tool-schema': true,
@@ -2,13 +2,18 @@
* @vitest-environment node
*/
import { afterAll, describe, expect, it, vi } from 'vitest'
import { getAllBlocks } from '@/blocks/registry'
import type { BlockState } from '@/stores/workflows/workflow/types'
vi.unmock('@/blocks/registry')
import * as blocksBarrel from '@/blocks'
import { getBlock as getRealBlock } from '@/blocks/registry'
import { backfillCanonicalModes, migrateSubblockIds } from './subblock-migrations'
import {
backfillCanonicalModes,
migrateSubblockIds,
SUBBLOCK_ID_MIGRATIONS,
} from './subblock-migrations'
/**
* Under `isolate: false` the module under test may already be cached from an
@@ -35,6 +40,47 @@ function makeBlock(overrides: Partial<BlockState> & { type: string }): BlockStat
} as BlockState
}
/**
* `dropParkedSubblocks` deletes any subblock whose id starts with `_removed_`,
* on the assumption that no live block declares one. Nothing enforces that
* naming rule at the block level, so pin it here — a block that adopted the
* prefix for a real field would have its value silently deleted on every load.
*/
describe('_removed_ prefix invariant', () => {
it('is never used as a live subblock id', () => {
const offenders = getAllBlocks().flatMap((block) =>
(block.subBlocks ?? [])
.filter((subBlock) => subBlock.id.startsWith('_removed_'))
.map((subBlock) => `${block.type}.${subBlock.id}`)
)
expect(offenders).toEqual([])
})
})
/**
* A migration target that names no live subblock silently drops the value: the
* rename writes a key nothing reads, and the sweep or the serializer discards
* it. Nothing else checks the right-hand side of the map.
*/
describe('migration targets', () => {
it('every rename points at a subblock that still exists', () => {
const offenders: string[] = []
for (const [blockType, renames] of Object.entries(SUBBLOCK_ID_MIGRATIONS)) {
const config = getAllBlocks().find((block) => block.type === blockType)
if (!config) {
offenders.push(`${blockType} (block not registered)`)
continue
}
const liveIds = new Set((config.subBlocks ?? []).map((subBlock) => subBlock.id))
for (const [legacyId, currentId] of Object.entries(renames)) {
if (currentId.startsWith('_removed_')) continue
if (!liveIds.has(currentId)) offenders.push(`${blockType}.${legacyId} -> ${currentId}`)
}
}
expect(offenders).toEqual([])
})
})
describe('migrateSubblockIds', () => {
it('should preserve Instagram insight metrics after the subblock rename', () => {
const input: Record<string, BlockState> = {
@@ -61,6 +107,106 @@ describe('migrateSubblockIds', () => {
expect(blocks.b1.subBlocks.metrics).toBeUndefined()
})
describe('snowflake block', () => {
it('renames the object fields onto their advanced text inputs', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'snowflake',
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'insert_rows' },
// Every legacy id in the map, so a rename added later without a
// matching assertion still fails here.
...Object.fromEntries(
Object.keys(SUBBLOCK_ID_MIGRATIONS.snowflake).map((legacyId) => [
legacyId,
{ id: legacyId, type: 'short-input', value: `value-${legacyId}` },
])
),
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
// The advanced text members, not the pickers: a migrated block has no
// credential yet, so a picker could not hydrate the stored name.
for (const [legacyId, currentId] of Object.entries(SUBBLOCK_ID_MIGRATIONS.snowflake)) {
if (currentId.startsWith('_removed_')) continue
expect(blocks.b1.subBlocks[currentId]?.value, `${legacyId} -> ${currentId}`).toBe(
`value-${legacyId}`
)
expect(blocks.b1.subBlocks[legacyId], legacyId).toBeUndefined()
}
})
/**
* Secret scrubbing for exports walks the block config, so a value parked
* under a key the config no longer declares would never be cleared. A
* `_removed_` target must drop the value, not carry it forward.
*/
it('discards the retired host and programmatic access token', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'snowflake',
subBlocks: {
host: { id: 'host', type: 'short-input', value: 'acme.snowflakecomputing.com' },
apiKey: { id: 'apiKey', type: 'short-input', value: 'super-secret-pat' },
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
expect(blocks.b1.subBlocks.apiKey).toBeUndefined()
expect(blocks.b1.subBlocks.host).toBeUndefined()
expect(blocks.b1.subBlocks._removed_apiKey).toBeUndefined()
expect(blocks.b1.subBlocks._removed_host).toBeUndefined()
expect(JSON.stringify(blocks.b1)).not.toContain('super-secret-pat')
})
})
/**
* An earlier version of this migration renamed retired fields into a
* `_removed_*` key instead of deleting them, so deployed workflows still hold
* those values. They match no `oldId`, so only a dedicated sweep clears them.
*/
it('drops values parked by an earlier run of the migration', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'rippling',
subBlocks: {
_removed_email: { id: '_removed_email', type: 'short-input', value: 'ada@example.com' },
_removed_firstName: { id: '_removed_firstName', type: 'short-input', value: 'Ada' },
// Not in rippling's rename map, so it must survive the sweep untouched.
credential: { id: 'credential', type: 'oauth-input', value: 'cred-1' },
},
}),
// A block type with no rename map at all must still be swept.
b2: makeBlock({
type: 'snowflake',
subBlocks: {
_removed_apiKey: {
id: '_removed_apiKey',
type: 'short-input',
value: 'super-secret-pat',
},
},
}),
}
const { blocks, migrated } = migrateSubblockIds(input)
expect(migrated).toBe(true)
expect(blocks.b1.subBlocks._removed_email).toBeUndefined()
expect(blocks.b1.subBlocks._removed_firstName).toBeUndefined()
expect(blocks.b1.subBlocks.credential?.value).toBe('cred-1')
expect(blocks.b2.subBlocks._removed_apiKey).toBeUndefined()
expect(JSON.stringify(blocks)).not.toContain('super-secret-pat')
expect(JSON.stringify(blocks)).not.toContain('ada@example.com')
})
describe('knowledge block', () => {
it('should rename knowledgeBaseId to knowledgeBaseSelector', () => {
const input: Record<string, BlockState> = {
@@ -13,6 +13,12 @@ import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('SubblockMigrations')
/**
* Marks a migration target as "this field is gone", rather than a rename. The
* old value is discarded instead of being carried into workflow state.
*/
const REMOVED_SUBBLOCK_ID_PREFIX = '_removed_'
/**
* Maps old subblock IDs to their current equivalents per block type.
*
@@ -21,6 +27,10 @@ const logger = createLogger('SubblockMigrations')
* serializer silently drops the value, breaking execution.
*
* Format: { blockType: { oldSubblockId: newSubblockId } }
*
* A target prefixed with `_removed_` means the field was deleted outright; the
* stored value is dropped. Use it for fields with no replacement — never map a
* secret onto a live subblock.
*/
export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
instagram: {
@@ -80,6 +90,30 @@ export const SUBBLOCK_ID_MIGRATIONS: Record<string, Record<string, string>> = {
useAutoprompt: '_removed_useAutoprompt',
livecrawl: '_removed_livecrawl',
},
/**
* The Snowflake block moved from per-block `host` + `apiKey` fields to a
* stored credential, and gave every object field a basic picker paired with
* an advanced text input.
*
* The old free-text values map onto the ADVANCED members, not the pickers: a
* migrated block has no credential yet, so a picker cannot hydrate a name and
* would render an empty control over a non-empty value. `fileFormat` is the
* clearest case — legacy values were fully qualified (`DB.SCHEMA.FORMAT`)
* while the picker lists bare names, so it could never resolve. The host and
* token have no in-block equivalent and are dropped.
*/
snowflake: {
database: 'databaseName',
schema: 'schemaName',
table: 'tableName',
fileFormat: 'fileFormatName',
warehouseName: 'warehouseNameManual',
procedureName: 'procedureNameManual',
warehouse: 'warehouseManual',
role: 'roleManual',
host: '_removed_host',
apiKey: '_removed_apiKey',
},
rippling: {
action: '_removed_action',
candidateDepartment: '_removed_candidateDepartment',
@@ -132,6 +166,16 @@ function migrateBlockSubblockIds(
for (const [oldId, newId] of Object.entries(renames)) {
if (!(oldId in result)) continue
// A `_removed_` target means the field no longer exists in the block. Drop
// the value rather than parking it under a dead key: nothing ever reads
// these keys, and secret scrubbing walks the block config, so a parked
// `password: true` value would never be cleared and would ride along in
// workflow exports and templates.
if (newId.startsWith(REMOVED_SUBBLOCK_ID_PREFIX)) {
delete result[oldId]
continue
}
if (newId in result) {
delete result[oldId]
continue
@@ -168,6 +212,29 @@ function migrateBlockSubblockIds(
return { subBlocks: result, migrated: true }
}
/**
* Drops any `_removed_*` subblock left behind by an earlier version of this
* migration, which renamed retired fields into a dead key instead of deleting
* them. Those keys are unreachable from the block config, so secret scrubbing —
* which walks the config — can never clear them, and a parked token or PII
* would otherwise survive in state, exports, and templates indefinitely.
*
* Runs for every block, not just those with a rename map: the parked keys no
* longer appear in any `SUBBLOCK_ID_MIGRATIONS` entry as an `oldId`, so nothing
* else would ever look at them.
*/
function dropParkedSubblocks(subBlocks: Record<string, BlockState['subBlocks'][string]>): {
subBlocks: Record<string, BlockState['subBlocks'][string]>
dropped: boolean
} {
const parked = Object.keys(subBlocks).filter((id) => id.startsWith(REMOVED_SUBBLOCK_ID_PREFIX))
if (parked.length === 0) return { subBlocks, dropped: false }
const result = { ...subBlocks }
for (const id of parked) delete result[id]
return { subBlocks: result, dropped: true }
}
/**
* Applies subblock-ID migrations to every block in a workflow.
* Returns a new blocks record with migrated subBlocks where needed.
@@ -189,11 +256,19 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
const renamed = renames
? migrateBlockSubblockIds(block.type, block.subBlocks, renames)
: { subBlocks: block.subBlocks, migrated: false }
const renamedBlock = renamed.migrated ? { ...block, subBlocks: renamed.subBlocks } : block
const purged = dropParkedSubblocks(renamed.subBlocks)
const changedSubBlocks = renamed.migrated || purged.dropped
const renamedBlock = changedSubBlocks ? { ...block, subBlocks: purged.subBlocks } : block
const sanitized = sanitizeMalformedSubBlocks(renamedBlock)
const blockMigrated = renamed.migrated || sanitized.changed
const blockMigrated = changedSubBlocks || sanitized.changed
if (blockMigrated) {
if (purged.dropped) {
logger.info('Dropped parked subblock values left by an earlier migration', {
blockId: block.id,
blockType: block.type,
})
}
if (renamed.migrated) {
logger.info('Migrated legacy subblock IDs', {
blockId: block.id,
@@ -38,6 +38,8 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'logGroupName',
'tableId',
'orgId',
'database',
'schema',
])
/**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
View File
@@ -3818,6 +3818,7 @@ import {
import { smsSendTool } from '@/tools/sms'
import { smtpSendMailTool } from '@/tools/smtp'
import {
snowflakeAlterWarehouseTool,
snowflakeCallProcedureTool,
snowflakeCancelStatementTool,
snowflakeCancelTaskRunTool,
@@ -3830,13 +3831,21 @@ import {
snowflakeGetWarehouseTool,
snowflakeInsertRowsTool,
snowflakeIntrospectSchemaTool,
snowflakeListCopyHistoryTool,
snowflakeListDatabasesTool,
snowflakeListQueryHistoryTool,
snowflakeListSchemasTool,
snowflakeListTablesTool,
snowflakeListTaskRunsTool,
snowflakeListTasksTool,
snowflakeListWarehousesTool,
snowflakeLoadDataTool,
snowflakeResumeTaskTool,
snowflakeResumeWarehouseTool,
snowflakeRunTaskTool,
snowflakeSuspendTaskTool,
snowflakeSuspendWarehouseTool,
snowflakeUnloadDataTool,
snowflakeUpdateRowsTool,
snowflakeUpsertRowsTool,
} from '@/tools/snowflake'
@@ -5409,6 +5418,7 @@ export const tools: Record<string, ToolConfig> = {
sendgrid_delete_template: sendGridDeleteTemplateTool,
sendgrid_create_template_version: sendGridCreateTemplateVersionTool,
smtp_send_mail: smtpSendMailTool,
snowflake_alter_warehouse: snowflakeAlterWarehouseTool,
snowflake_call_procedure: snowflakeCallProcedureTool,
snowflake_cancel_statement: snowflakeCancelStatementTool,
snowflake_cancel_task_run: snowflakeCancelTaskRunTool,
@@ -5421,13 +5431,21 @@ export const tools: Record<string, ToolConfig> = {
snowflake_get_warehouse: snowflakeGetWarehouseTool,
snowflake_insert_rows: snowflakeInsertRowsTool,
snowflake_introspect_schema: snowflakeIntrospectSchemaTool,
snowflake_list_copy_history: snowflakeListCopyHistoryTool,
snowflake_list_databases: snowflakeListDatabasesTool,
snowflake_list_query_history: snowflakeListQueryHistoryTool,
snowflake_list_schemas: snowflakeListSchemasTool,
snowflake_list_tables: snowflakeListTablesTool,
snowflake_list_task_runs: snowflakeListTaskRunsTool,
snowflake_list_tasks: snowflakeListTasksTool,
snowflake_list_warehouses: snowflakeListWarehousesTool,
snowflake_load_data: snowflakeLoadDataTool,
snowflake_resume_task: snowflakeResumeTaskTool,
snowflake_resume_warehouse: snowflakeResumeWarehouseTool,
snowflake_run_task: snowflakeRunTaskTool,
snowflake_suspend_task: snowflakeSuspendTaskTool,
snowflake_suspend_warehouse: snowflakeSuspendWarehouseTool,
snowflake_unload_data: snowflakeUnloadDataTool,
snowflake_update_rows: snowflakeUpdateRowsTool,
snowflake_upsert_rows: snowflakeUpsertRowsTool,
sportmonks_football_expected_by_player: sportmonksExpectedByPlayerTool,
@@ -0,0 +1,68 @@
import { buildAlterWarehouse } from '@/tools/snowflake/sql'
import type {
SnowflakeAlterWarehouseParams,
SnowflakeStatementResponse,
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS, SNOWFLAKE_WAREHOUSE_SIZES } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const alterWarehouseTool: ToolConfig<
SnowflakeAlterWarehouseParams,
SnowflakeStatementResponse
> = {
id: 'snowflake_alter_warehouse',
version: '1.0.0',
name: 'Snowflake Alter Warehouse',
description: 'Resize a Snowflake warehouse or change its auto-suspend and auto-resume settings.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
warehouseName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Warehouse name',
},
warehouseSize: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: `New warehouse size, one of ${SNOWFLAKE_WAREHOUSE_SIZES.join(', ')}`,
},
autoSuspendSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Seconds of inactivity before the warehouse suspends. Snowflake polls every 30 seconds, so values under 30 or not a multiple of 30 may not behave as expected. 0 means the warehouse never suspends and keeps consuming credits',
},
autoResume: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether the warehouse resumes automatically when a statement is submitted',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildAlterWarehouse(params))
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+2 -12
View File
@@ -7,6 +7,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -36,18 +37,7 @@ export const callProcedureTool: ToolConfig<
name: 'Snowflake Call Procedure',
description: 'Call a stored procedure with explicitly typed Snowflake bindings.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+4 -14
View File
@@ -4,8 +4,9 @@ import type {
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
getSnowflakeBaseUrl,
getSnowflakeHeaders,
normalizeSnowflakeHost,
snowflakeAuthParamFields,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
@@ -19,18 +20,7 @@ export const cancelStatementTool: ToolConfig<
description: 'Cancel a running Snowflake SQL API statement.',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
statementHandle: {
type: 'string',
required: true,
@@ -40,7 +30,7 @@ export const cancelStatementTool: ToolConfig<
},
request: {
url: (params) =>
`${normalizeSnowflakeHost(params.host)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}/cancel`,
`${getSnowflakeBaseUrl(params)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}/cancel`,
method: 'POST',
headers: getSnowflakeHeaders,
},
+2 -12
View File
@@ -6,6 +6,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -21,18 +22,7 @@ export const cancelTaskRunTool: ToolConfig<
description:
'Cancel one running task query by query ID with SYSTEM$CANCEL_QUERY. Task runs already in flight are unaffected and must be cancelled individually; a cancelled child marks the task graph run failed, so downstream tasks are skipped.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeDeleteRowsParams, SnowflakeStatementResponse } from '@/to
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -27,18 +28,7 @@ export const deleteRowsTool: ToolConfig<SnowflakeDeleteRowsParams, SnowflakeStat
name: 'Snowflake Delete Rows',
description: 'Delete rows matching a required set of bound column filters.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -7,6 +7,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -38,18 +39,7 @@ export const executeSqlTool: ToolConfig<SnowflakeExecuteSqlParams, SnowflakeStat
name: 'Snowflake Execute SQL',
description: 'Execute one parameterized SQL statement through the Snowflake SQL API.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+4 -14
View File
@@ -4,8 +4,9 @@ import type {
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
getSnowflakeBaseUrl,
getSnowflakeHeaders,
normalizeSnowflakeHost,
snowflakeAuthParamFields,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
@@ -39,18 +40,7 @@ export const getStatementTool: ToolConfig<SnowflakeGetStatementParams, Snowflake
'Check a running or completed statement and retrieve exactly one result partition. Canceled or failed statements are returned as errors.',
version: '1.0.0',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
statementHandle: {
type: 'string',
required: true,
@@ -74,7 +64,7 @@ export const getStatementTool: ToolConfig<SnowflakeGetStatementParams, Snowflake
request: {
url: (params) => {
const partition = partitionNumber(params.partition)
return `${normalizeSnowflakeHost(params.host)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}?partition=${partition}`
return `${getSnowflakeBaseUrl(params)}/api/v2/statements/${encodeURIComponent(params.statementHandle.trim())}?partition=${partition}`
},
method: 'GET',
headers: getSnowflakeHeaders,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeTaskParams } from '@/tools/sn
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -14,18 +15,7 @@ export const getTaskTool: ToolConfig<SnowflakeTaskParams, SnowflakeStatementResp
name: 'Snowflake Get Task',
description: 'Describe a Snowflake task.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeGetTaskRunParams, SnowflakeStatementResponse } from '@/to
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -15,18 +16,7 @@ export const getTaskRunTool: ToolConfig<SnowflakeGetTaskRunParams, SnowflakeStat
description:
'Find one task history record by query ID within Snowflake’s seven-day window and 10000 most recent records after optional filters.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
@@ -6,6 +6,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -21,18 +22,7 @@ export const getTaskRunOutputTool: ToolConfig<
description:
'Read a task query result with RESULT_SCAN during Snowflake’s 24-hour retention window using the task owner role.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/too
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -14,18 +15,7 @@ export const getWarehouseTool: ToolConfig<SnowflakeWarehouseParams, SnowflakeSta
name: 'Snowflake Get Warehouse',
description: 'Get the full details for a Snowflake virtual warehouse.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+18
View File
@@ -1,3 +1,4 @@
import { alterWarehouseTool } from '@/tools/snowflake/alter_warehouse'
import { callProcedureTool } from '@/tools/snowflake/call_procedure'
import { cancelStatementTool } from '@/tools/snowflake/cancel_statement'
import { cancelTaskRunTool } from '@/tools/snowflake/cancel_task_run'
@@ -10,13 +11,21 @@ import { getTaskRunOutputTool } from '@/tools/snowflake/get_task_run_output'
import { getWarehouseTool } from '@/tools/snowflake/get_warehouse'
import { insertRowsTool } from '@/tools/snowflake/insert_rows'
import { introspectSchemaTool } from '@/tools/snowflake/introspect_schema'
import { listCopyHistoryTool } from '@/tools/snowflake/list_copy_history'
import { listDatabasesTool } from '@/tools/snowflake/list_databases'
import { listQueryHistoryTool } from '@/tools/snowflake/list_query_history'
import { listSchemasTool } from '@/tools/snowflake/list_schemas'
import { listTablesTool } from '@/tools/snowflake/list_tables'
import { listTaskRunsTool } from '@/tools/snowflake/list_task_runs'
import { listTasksTool } from '@/tools/snowflake/list_tasks'
import { listWarehousesTool } from '@/tools/snowflake/list_warehouses'
import { loadDataTool } from '@/tools/snowflake/load_data'
import { resumeTaskTool } from '@/tools/snowflake/resume_task'
import { resumeWarehouseTool } from '@/tools/snowflake/resume_warehouse'
import { runTaskTool } from '@/tools/snowflake/run_task'
import { suspendTaskTool } from '@/tools/snowflake/suspend_task'
import { suspendWarehouseTool } from '@/tools/snowflake/suspend_warehouse'
import { unloadDataTool } from '@/tools/snowflake/unload_data'
import { updateRowsTool } from '@/tools/snowflake/update_rows'
import { upsertRowsTool } from '@/tools/snowflake/upsert_rows'
@@ -43,3 +52,12 @@ export const snowflakeRunTaskTool = runTaskTool
export const snowflakeSuspendWarehouseTool = suspendWarehouseTool
export const snowflakeUpdateRowsTool = updateRowsTool
export const snowflakeUpsertRowsTool = upsertRowsTool
export const snowflakeAlterWarehouseTool = alterWarehouseTool
export const snowflakeListCopyHistoryTool = listCopyHistoryTool
export const snowflakeListDatabasesTool = listDatabasesTool
export const snowflakeListQueryHistoryTool = listQueryHistoryTool
export const snowflakeListSchemasTool = listSchemasTool
export const snowflakeListTablesTool = listTablesTool
export const snowflakeResumeTaskTool = resumeTaskTool
export const snowflakeSuspendTaskTool = suspendTaskTool
export const snowflakeUnloadDataTool = unloadDataTool
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeInsertRowsParams, SnowflakeStatementResponse } from '@/to
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -28,18 +29,7 @@ export const insertRowsTool: ToolConfig<SnowflakeInsertRowsParams, SnowflakeStat
name: 'Snowflake Insert Rows',
description: 'Insert structured JSON rows using bound values.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -6,6 +6,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -28,18 +29,7 @@ export const introspectSchemaTool: ToolConfig<
name: 'Snowflake Introspect Schema',
description: 'Inspect table and column metadata through Snowflake INFORMATION_SCHEMA views.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
@@ -0,0 +1,89 @@
import { buildListCopyHistory } from '@/tools/snowflake/sql'
import type {
SnowflakeListCopyHistoryParams,
SnowflakeStatementResponse,
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const listCopyHistoryTool: ToolConfig<
SnowflakeListCopyHistoryParams,
SnowflakeStatementResponse
> = {
id: 'snowflake_list_copy_history',
version: '1.0.0',
name: 'Snowflake List Copy History',
description: 'List staged-file load results for a table over the last fourteen days.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
warehouse: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
schema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Schema name',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Table whose load history to return',
},
startTime: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'ISO-8601 start of the load window, within the last fourteen days',
},
endTime: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'ISO-8601 end of the load window, within the last fourteen days; defaults to now',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum load rows, from 1 to 10000',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildListCopyHistory(params), {
warehouse: params.warehouse,
maxRows: params.limit,
})
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
@@ -0,0 +1,55 @@
import { buildListDatabases } from '@/tools/snowflake/sql'
import type {
SnowflakeListDatabasesParams,
SnowflakeStatementResponse,
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const listDatabasesTool: ToolConfig<
SnowflakeListDatabasesParams,
SnowflakeStatementResponse
> = {
id: 'snowflake_list_databases',
version: '1.0.0',
name: 'Snowflake List Databases',
description: 'List the databases the credential can access.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
nameLike: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional SQL LIKE pattern for object names',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum rows, from 1 to 10000',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildListDatabases(params), { maxRows: params.limit })
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
@@ -0,0 +1,89 @@
import { buildListQueryHistory } from '@/tools/snowflake/sql'
import type {
SnowflakeListQueryHistoryParams,
SnowflakeStatementResponse,
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const listQueryHistoryTool: ToolConfig<
SnowflakeListQueryHistoryParams,
SnowflakeStatementResponse
> = {
id: 'snowflake_list_query_history',
version: '1.0.0',
name: 'Snowflake List Query History',
description: 'List queries that completed in the last seven days, optionally filtered.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
warehouse: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
},
userName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Only queries run by this user; cannot be combined with warehouseName',
},
warehouseName: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Only queries run on this warehouse; cannot be combined with userName',
},
startTime: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'ISO-8601 start of the query completion window, within the last seven days',
},
endTime: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'ISO-8601 end of the query completion window, within the last seven days',
},
errorOnly: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Whether to return only queries that failed (execution status FAILED_WITH_ERROR or FAILED_WITH_INCIDENT). Snowflake applies the limit before this filter, so it selects the failures among the most recent queries rather than the most recent failures',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum query rows, from 1 to 10000',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildListQueryHistory(params), {
warehouse: params.warehouse,
maxRows: params.limit,
})
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+58
View File
@@ -0,0 +1,58 @@
import { buildListSchemas } from '@/tools/snowflake/sql'
import type {
SnowflakeListSchemasParams,
SnowflakeStatementResponse,
} from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const listSchemasTool: ToolConfig<SnowflakeListSchemasParams, SnowflakeStatementResponse> = {
id: 'snowflake_list_schemas',
version: '1.0.0',
name: 'Snowflake List Schemas',
description: 'List the schemas in a Snowflake database.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
nameLike: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional SQL LIKE pattern for object names',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum rows, from 1 to 10000',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildListSchemas(params), { maxRows: params.limit })
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+61
View File
@@ -0,0 +1,61 @@
import { buildListTables } from '@/tools/snowflake/sql'
import type { SnowflakeListTablesParams, SnowflakeStatementResponse } from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const listTablesTool: ToolConfig<SnowflakeListTablesParams, SnowflakeStatementResponse> = {
id: 'snowflake_list_tables',
version: '1.0.0',
name: 'Snowflake List Tables',
description: 'List the tables in a Snowflake schema.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
schema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Schema name',
},
nameLike: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional SQL LIKE pattern for object names',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum rows, from 1 to 10000',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildListTables(params), { maxRows: params.limit })
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+2 -12
View File
@@ -6,6 +6,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -26,18 +27,7 @@ export const listTaskRunsTool: ToolConfig<SnowflakeListTaskRunsParams, Snowflake
name: 'Snowflake List Task Runs',
description: 'Query up to seven days of Snowflake task history, capped at 10000 rows.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeListTasksParams, SnowflakeStatementResponse } from '@/too
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -14,18 +15,7 @@ export const listTasksTool: ToolConfig<SnowflakeListTasksParams, SnowflakeStatem
name: 'Snowflake List Tasks',
description: 'List tasks in a Snowflake schema.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -6,6 +6,7 @@ import type {
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -20,18 +21,7 @@ export const listWarehousesTool: ToolConfig<
name: 'Snowflake List Warehouses',
description: 'List warehouses visible to the active Snowflake role.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeLoadDataParams, SnowflakeStatementResponse } from '@/tool
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -25,18 +26,7 @@ export const loadDataTool: ToolConfig<SnowflakeLoadDataParams, SnowflakeStatemen
name: 'Snowflake Load Data',
description: 'Load files from an existing Snowflake stage with COPY INTO.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+55
View File
@@ -0,0 +1,55 @@
import { buildResumeTask } from '@/tools/snowflake/sql'
import type { SnowflakeStatementResponse, SnowflakeTaskParams } from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const resumeTaskTool: ToolConfig<SnowflakeTaskParams, SnowflakeStatementResponse> = {
id: 'snowflake_resume_task',
version: '1.0.0',
name: 'Snowflake Resume Task',
description: 'Resume a suspended Snowflake task so its schedule runs again.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
schema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Schema name',
},
taskName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Task name without a database or schema prefix',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildResumeTask(params))
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/too
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -15,18 +16,7 @@ export const resumeWarehouseTool: ToolConfig<SnowflakeWarehouseParams, Snowflake
name: 'Snowflake Resume Warehouse',
description: 'Resume a Snowflake virtual warehouse if it is suspended.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeRunTaskParams, SnowflakeStatementResponse } from '@/tools
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -22,18 +23,7 @@ export const runTaskTool: ToolConfig<SnowflakeRunTaskParams, SnowflakeStatementR
name: 'Snowflake Run Task',
description: 'Run a Snowflake task immediately, optionally retrying its last failed graph.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
@@ -0,0 +1,25 @@
/**
* Object kinds the editor's Snowflake pickers can enumerate.
*
* A leaf module on purpose: the selector contract in
* `@/lib/api/contracts/selectors/snowflake` is imported by client code, and
* reading these from `@/tools/snowflake/sql` would pull the whole statement
* builder — and its transport dependencies — into the browser bundle for the
* sake of a seven-element array.
*/
export const SNOWFLAKE_SELECTOR_KINDS = [
'databases',
'schemas',
'tables',
'warehouses',
'roles',
'file_formats',
'procedures',
] as const
export type SnowflakeSelectorKind = (typeof SNOWFLAKE_SELECTOR_KINDS)[number]
export interface SnowflakeSelectorScope {
database?: string
schema?: string
}
+233 -7
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { SnowflakeBlock } from '@/blocks/blocks/snowflake'
import {
buildAlterWarehouse,
buildCallProcedure,
buildCancelTaskRun,
buildDeleteRows,
@@ -10,13 +11,21 @@ import {
buildGetWarehouse,
buildInsertRows,
buildIntrospectSchema,
buildListCopyHistory,
buildListDatabases,
buildListQueryHistory,
buildListSchemas,
buildListTables,
buildListTaskRuns,
buildListTasks,
buildListWarehouses,
buildLoadData,
buildResumeTask,
buildResumeWarehouse,
buildRunTask,
buildSuspendTask,
buildSuspendWarehouse,
buildUnloadData,
buildUpdateRows,
buildUpsertRows,
identifier,
@@ -24,10 +33,15 @@ import {
qualifiedIdentifier,
} from '@/tools/snowflake/sql'
const context = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
const context = { oauthCredential: 'cred-1' }
const table = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', table: 'EVENTS' }
const queryId = '01b71944-0301-b428-0000-69f706bf0001'
/** ISO instants inside the history retention windows, so assertions never age out. */
const historyStart = new Date(Date.now() - 2 * 86_400_000).toISOString()
const historyEnd = new Date(Date.now() - 86_400_000).toISOString()
const copyStart = new Date(Date.now() - 3 * 86_400_000).toISOString()
/** The separator the row-shape and match-key encodings join on; legal inside a quoted identifier. */
const SEPARATOR = String.fromCharCode(0)
@@ -363,6 +377,218 @@ describe('Snowflake SQL builders', () => {
)
})
it('bounds every object listing and scopes it to the requested container', () => {
expect(buildListDatabases({ ...context, limit: 25 }).statement).toBe('SHOW DATABASES LIMIT 25')
expect(buildListDatabases({ ...context, nameLike: "AN'X%" }).statement).toBe(
"SHOW DATABASES LIKE 'AN''X%' LIMIT 1000"
)
expect(buildListSchemas({ ...context, database: 'ANALYTICS', limit: 5 }).statement).toBe(
'SHOW SCHEMAS IN DATABASE ANALYTICS LIMIT 5'
)
expect(
buildListTables({ ...context, database: 'ANALYTICS', schema: 'PUBLIC', nameLike: 'E%' })
.statement
).toBe("SHOW TABLES LIKE 'E%' IN SCHEMA ANALYTICS.PUBLIC LIMIT 1000")
expect(() => buildListTables({ ...context, database: 'A; DROP', schema: 'PUBLIC' })).toThrow(
/Invalid Snowflake identifier/
)
})
it('builds task state changes and rejects unqualifiable names', () => {
const task = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', taskName: 'DAILY_LOAD' }
expect(buildResumeTask(task).statement).toBe('ALTER TASK ANALYTICS.PUBLIC.DAILY_LOAD RESUME')
expect(buildSuspendTask(task).statement).toBe('ALTER TASK ANALYTICS.PUBLIC.DAILY_LOAD SUSPEND')
expect(() => buildResumeTask({ ...task, taskName: 'A RESUME; DROP' })).toThrow(
/Invalid Snowflake identifier/
)
})
/**
* An untouched switch serializes as `null`, and in advanced mode the
* serializer emits every advanced sub-block. The generic handler merges
* `{...inputs, ...mapParams(inputs)}`, so a null survives unless the params
* function explicitly overwrites it with undefined — and the builders'
* `!== undefined` tests would then fire, turning a resize into a permanent
* `AUTO_RESUME = FALSE` on the warehouse.
*/
it('drops untouched switches instead of emitting their clauses', () => {
const mapParams = SnowflakeBlock.tools.config.params
if (!mapParams) throw new Error('Snowflake block must map tool parameters')
const merged = (params: Record<string, unknown>) => ({ ...params, ...mapParams(params) })
const altered = merged({
operation: 'alter_warehouse',
warehouseSize: '',
autoSuspendSeconds: null,
autoResume: null,
})
expect(altered.autoResume).toBeUndefined()
expect(() =>
buildAlterWarehouse({ ...context, warehouseName: 'COMPUTE_WH', ...altered })
).toThrow(/at least one of/)
const unloaded = merged({
operation: 'unload_data',
header: null,
overwrite: null,
singleFile: null,
})
expect(unloaded.header).toBeUndefined()
expect(unloaded.singleFile).toBeUndefined()
expect(
buildUnloadData({
...context,
database: 'ANALYTICS',
schema: 'PUBLIC',
stagePath: '@EXPORTS/daily',
table: 'EVENTS',
...unloaded,
}).statement
).toBe('COPY INTO @EXPORTS/daily FROM ANALYTICS.PUBLIC.EVENTS OVERWRITE = FALSE')
})
it('builds warehouse alterations and rejects unsupported settings', () => {
expect(
buildAlterWarehouse({
...context,
warehouseName: 'ETL_WH',
warehouseSize: 'xlarge',
autoSuspendSeconds: 0,
autoResume: false,
}).statement
).toBe(
'ALTER WAREHOUSE ETL_WH SET WAREHOUSE_SIZE = XLARGE AUTO_SUSPEND = 0 AUTO_RESUME = FALSE'
)
// Every documented spelling of a size normalizes to the bare keyword.
for (const spelling of ['2X-LARGE', "'2X-LARGE'", 'X2LARGE', 'xxlarge']) {
expect(
buildAlterWarehouse({ ...context, warehouseName: 'ETL_WH', warehouseSize: spelling })
.statement
).toBe('ALTER WAREHOUSE ETL_WH SET WAREHOUSE_SIZE = XXLARGE')
}
expect(() =>
buildAlterWarehouse({ ...context, warehouseName: 'ETL_WH', warehouseSize: 'HUGE' })
).toThrow(/warehouseSize must be one of/)
expect(() =>
buildAlterWarehouse({ ...context, warehouseName: 'ETL_WH', autoSuspendSeconds: -1 })
).toThrow(/autoSuspendSeconds/)
// Snowflake documents no upper bound, so a long idle window must be allowed.
expect(
buildAlterWarehouse({ ...context, warehouseName: 'ETL_WH', autoSuspendSeconds: 900_000 })
.statement
).toBe('ALTER WAREHOUSE ETL_WH SET AUTO_SUSPEND = 900000')
expect(() => buildAlterWarehouse({ ...context, warehouseName: 'ETL_WH' })).toThrow(
/at least one of/
)
})
/**
* The source is a table name, never an inline query. An inlined query sits
* directly before the copy-option slot, so anything escaping its parentheses
* becomes a copy clause — a guard for that has to match Snowflake's tokenizer
* exactly, and three versions of one were each defeated. `qualifiedIdentifier`
* removes the class instead of re-guarding it.
*/
it('unloads a table and orders the COPY INTO clauses', () => {
const base = {
...context,
database: 'ANALYTICS',
schema: 'PUBLIC',
stagePath: '@EXPORTS/daily',
}
expect(
buildUnloadData({
...base,
table: 'EVENTS',
fileFormat: 'ANALYTICS.PUBLIC.CSV_FORMAT',
header: true,
overwrite: false,
singleFile: true,
maxFileSizeBytes: 16_777_216,
}).statement
).toBe(
// OVERWRITE/SINGLE/MAX_FILE_SIZE are all copyOptions members, so their
// order among themselves is free; HEADER must stay last.
"COPY INTO @EXPORTS/daily FROM ANALYTICS.PUBLIC.EVENTS FILE_FORMAT = (FORMAT_NAME = 'ANALYTICS.PUBLIC.CSV_FORMAT') OVERWRITE = FALSE SINGLE = TRUE MAX_FILE_SIZE = 16777216 HEADER = TRUE"
)
// OVERWRITE is always emitted so the option can never be left to a default.
expect(buildUnloadData({ ...base, table: 'EVENTS' }).statement).toBe(
'COPY INTO @EXPORTS/daily FROM ANALYTICS.PUBLIC.EVENTS OVERWRITE = FALSE'
)
expect(() => buildUnloadData({ ...base, table: '' })).toThrow(/table is required/)
// No SQL text can reach the statement, so no breakout is expressible.
expect(() =>
buildUnloadData({ ...base, table: 'EVENTS) OVERWRITE = TRUE FILE_FORMAT = (TYPE = CSV' })
).toThrow(/Invalid Snowflake identifier/)
expect(() =>
buildUnloadData({ ...base, table: 'EVENTS', maxFileSizeBytes: 5_368_709_121 })
).toThrow(/maxFileSizeBytes/)
})
it('emits history filters as literals so none can be silently dropped', () => {
expect(
buildListQueryHistory({
...context,
limit: 50,
userName: 'analyst_svc',
startTime: historyStart,
endTime: historyEnd,
errorOnly: true,
})
).toEqual({
statement: `SELECT * FROM TABLE(SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY_BY_USER(RESULT_LIMIT => 50, USER_NAME => 'ANALYST_SVC', END_TIME_RANGE_START => TO_TIMESTAMP_LTZ('${historyStart}'), END_TIME_RANGE_END => TO_TIMESTAMP_LTZ('${historyEnd}'))) WHERE UPPER(EXECUTION_STATUS) IN ('FAILED_WITH_ERROR', 'FAILED_WITH_INCIDENT') ORDER BY END_TIME DESC`,
})
expect(buildListQueryHistory({ ...context, warehouseName: 'ETL_WH' }).statement).toContain(
"SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY_BY_WAREHOUSE(RESULT_LIMIT => 1000, WAREHOUSE_NAME => 'ETL_WH')"
)
expect(buildListQueryHistory({ ...context }).statement).toContain(
'SNOWFLAKE.INFORMATION_SCHEMA.QUERY_HISTORY(RESULT_LIMIT => 1000)'
)
expect(() => buildListQueryHistory({ ...context, userName: 'A', warehouseName: 'B' })).toThrow(
/by user or by warehouse/
)
expect(() => buildListQueryHistory({ ...context, startTime: 'yesterday' })).toThrow(
/ISO-8601 timestamp within the last 7 days/
)
// The retention window is enforced, not just advertised in the message.
expect(() => buildListQueryHistory({ ...context, startTime: '2020-01-01T00:00:00Z' })).toThrow(
/within the last 7 days/
)
})
it('requires a start time for copy history and bounds it outside the table function', () => {
expect(
buildListCopyHistory({
...context,
database: 'ANALYTICS',
schema: 'PUBLIC',
table: 'EVENTS',
startTime: copyStart,
limit: 20,
}).statement
).toBe(
`SELECT * FROM TABLE(ANALYTICS.INFORMATION_SCHEMA.COPY_HISTORY(TABLE_NAME => 'ANALYTICS.PUBLIC.EVENTS', START_TIME => TO_TIMESTAMP_LTZ('${copyStart}'))) ORDER BY LAST_LOAD_TIME DESC LIMIT 20`
)
expect(() =>
buildListCopyHistory({
...context,
database: 'ANALYTICS',
schema: 'PUBLIC',
table: 'EVENTS',
startTime: 'last tuesday',
})
).toThrow(/ISO-8601 timestamp within the last 14 days/)
expect(() =>
buildListCopyHistory({
...context,
database: 'ANALYTICS',
schema: 'PUBLIC',
table: 'EVENTS',
startTime: '2020-01-01T00:00:00Z',
})
).toThrow(/within the last 14 days/)
})
it('builds task definition and execution statements', () => {
const task = { ...context, database: 'ANALYTICS', schema: 'PUBLIC', taskName: 'DAILY_LOAD' }
expect(buildListTasks({ ...task, limit: 25, nameLike: 'DAILY%' }).statement).toBe(
@@ -378,7 +604,7 @@ describe('Snowflake SQL builders', () => {
const history = buildListTaskRuns({
...context,
taskName: 'DAILY_LOAD',
startTime: '2026-08-01T00:00:00Z',
startTime: historyStart,
errorOnly: true,
limit: 50,
})
@@ -388,7 +614,7 @@ describe('Snowflake SQL builders', () => {
...context,
queryId,
taskName: 'DAILY_LOAD',
startTime: '2026-08-01T00:00:00Z',
startTime: historyStart,
})
expect(run.statement).toContain('TASK_NAME => ?')
expect(run.statement).toContain('WHERE QUERY_ID = ?')
@@ -406,14 +632,14 @@ describe('Snowflake SQL builders', () => {
*/
const window = buildListTaskRuns({
...context,
startTime: '2026-08-01T00:00:00Z',
endTime: '2026-08-02T00:00:00Z',
startTime: historyStart,
endTime: historyEnd,
})
expect(window.statement).toContain(
"SCHEDULED_TIME_RANGE_START => TO_TIMESTAMP_LTZ('2026-08-01T00:00:00Z')"
`SCHEDULED_TIME_RANGE_START => TO_TIMESTAMP_LTZ('${historyStart}')`
)
expect(window.statement).toContain(
"SCHEDULED_TIME_RANGE_END => TO_TIMESTAMP_LTZ('2026-08-02T00:00:00Z')"
`SCHEDULED_TIME_RANGE_END => TO_TIMESTAMP_LTZ('${historyEnd}')`
)
expect(window.statement).not.toContain('TO_TIMESTAMP_LTZ(?)')
expect(window.bindings).toEqual({})
+331 -4
View File
@@ -1,7 +1,13 @@
import { isValidUuid } from '@sim/utils/id'
import { isPlainRecord } from '@sim/utils/object'
import type {
SnowflakeSelectorKind,
SnowflakeSelectorScope,
} from '@/tools/snowflake/selector-kinds'
import {
SNOWFLAKE_BINDING_TYPES,
SNOWFLAKE_WAREHOUSE_SIZES,
type SnowflakeAlterWarehouseParams,
type SnowflakeBinding,
type SnowflakeCallProcedureParams,
type SnowflakeCancelTaskRunParams,
@@ -10,11 +16,17 @@ import {
type SnowflakeGetTaskRunParams,
type SnowflakeInsertRowsParams,
type SnowflakeIntrospectSchemaParams,
type SnowflakeListCopyHistoryParams,
type SnowflakeListDatabasesParams,
type SnowflakeListQueryHistoryParams,
type SnowflakeListSchemasParams,
type SnowflakeListTablesParams,
type SnowflakeListTaskRunsParams,
type SnowflakeListTasksParams,
type SnowflakeLoadDataParams,
type SnowflakeRunTaskParams,
type SnowflakeTaskParams,
type SnowflakeUnloadDataParams,
type SnowflakeUpdateRowsParams,
type SnowflakeWarehouseParams,
} from '@/tools/snowflake/types'
@@ -502,14 +514,24 @@ export function buildRunTask(params: SnowflakeRunTaskParams): SnowflakeStatement
* is silently dropped rather than rejected, which would turn the requested window into a
* no-op, so the timestamp is emitted as a literal instead.
*/
function taskHistoryTimestamp(value: string, field: string): string {
function historyTimestamp(value: string, field: string, retentionDays: number): string {
const trimmed = value.trim()
if (Number.isNaN(Date.parse(trimmed))) {
throw new Error(`${field} must be an ISO-8601 timestamp within the last seven days`)
const parsed = Date.parse(trimmed)
if (Number.isNaN(parsed)) {
throw new Error(`${field} must be an ISO-8601 timestamp within the last ${retentionDays} days`)
}
// Snowflake rejects a window outside the function's retention, so the bound is
// checked here rather than advertised in the message and left unenforced.
if (Date.now() - parsed > retentionDays * 24 * 60 * 60 * 1000) {
throw new Error(`${field} must be within the last ${retentionDays} days`)
}
return `TO_TIMESTAMP_LTZ(${stringLiteral(trimmed)})`
}
function taskHistoryTimestamp(value: string, field: string): string {
return historyTimestamp(value, field, 7)
}
function unqualifiedTaskName(value: string): string {
const trimmed = value.trim()
if (splitQualifiedIdentifier(trimmed).length > 1) {
@@ -572,7 +594,9 @@ export function buildGetTaskRunOutput(
params: SnowflakeGetTaskRunOutputParams
): SnowflakeStatementSpec {
return {
statement: `SELECT * FROM TABLE(RESULT_SCAN(${stringLiteral(requireQueryId(params.queryId))}))`,
// Bounded in SQL as well as by `rows_per_resultset`: this reads back a
// result set Sim never submitted, so its size is not otherwise known here.
statement: `SELECT * FROM TABLE(RESULT_SCAN(${stringLiteral(requireQueryId(params.queryId))})) LIMIT ${normalizeMaxRows(params.maxRows)}`,
}
}
@@ -616,3 +640,306 @@ export function buildCallProcedure(params: SnowflakeCallProcedureParams): Snowfl
bindings,
}
}
/**
* `SHOW` scoping and filter clauses shared by the list operations. `LIKE` must
* precede `IN`, and `LIMIT` must follow both — the grammar is positional.
*
* @see https://docs.snowflake.com/en/sql-reference/sql/show-tables
*/
function showClauses(
nameLike: string | undefined,
scope: string,
limit: number | undefined
): string {
const like = nameLike?.trim() ? ` LIKE ${stringLiteral(nameLike.trim())}` : ''
return `${like}${scope} LIMIT ${normalizeMaxRows(limit)}`
}
export function buildListDatabases(params: SnowflakeListDatabasesParams): SnowflakeStatementSpec {
return { statement: `SHOW DATABASES${showClauses(params.nameLike, '', params.limit)}` }
}
export function buildListSchemas(params: SnowflakeListSchemasParams): SnowflakeStatementSpec {
return {
statement: `SHOW SCHEMAS${showClauses(params.nameLike, ` IN DATABASE ${identifier(params.database)}`, params.limit)}`,
}
}
export function buildListTables(params: SnowflakeListTablesParams): SnowflakeStatementSpec {
return {
statement: `SHOW TABLES${showClauses(params.nameLike, ` IN SCHEMA ${qualifiedIdentifier(params.database, params.schema)}`, params.limit)}`,
}
}
export function buildResumeTask(params: SnowflakeTaskParams): SnowflakeStatementSpec {
return {
statement: `ALTER TASK ${qualifiedIdentifier(params.database, params.schema, params.taskName)} RESUME`,
}
}
export function buildSuspendTask(params: SnowflakeTaskParams): SnowflakeStatementSpec {
return {
statement: `ALTER TASK ${qualifiedIdentifier(params.database, params.schema, params.taskName)} SUSPEND`,
}
}
/**
* Documented spellings that name a size already in {@link SNOWFLAKE_WAREHOUSE_SIZES}.
* Snowflake accepts `X2LARGE`/`X3LARGE` as bare keywords and the hyphenated
* forms (`'X-SMALL'`, `'2X-LARGE'`, …) when quoted, so a value copied straight
* out of the vendor docs normalizes instead of being rejected.
*/
const WAREHOUSE_SIZE_ALIASES: Record<string, string> = {
XSMALL: 'XSMALL',
SMALL: 'SMALL',
MEDIUM: 'MEDIUM',
LARGE: 'LARGE',
XLARGE: 'XLARGE',
X2LARGE: 'XXLARGE',
XXLARGE: 'XXLARGE',
X3LARGE: 'XXXLARGE',
XXXLARGE: 'XXXLARGE',
X4LARGE: 'X4LARGE',
X5LARGE: 'X5LARGE',
X6LARGE: 'X6LARGE',
// Dehyphenated forms of the quoted spellings ('2X-LARGE' → 2XLARGE).
'2XLARGE': 'XXLARGE',
'3XLARGE': 'XXXLARGE',
'4XLARGE': 'X4LARGE',
'5XLARGE': 'X5LARGE',
'6XLARGE': 'X6LARGE',
}
/** Folds quoting, hyphens, and case so every documented spelling resolves. */
function normalizeWarehouseSize(value: string): string | undefined {
const bare = value
.trim()
.replace(/^'(.*)'$/, '$1')
.replace(/-/g, '')
.toUpperCase()
return Object.hasOwn(WAREHOUSE_SIZE_ALIASES, bare) ? WAREHOUSE_SIZE_ALIASES[bare] : undefined
}
export function buildAlterWarehouse(params: SnowflakeAlterWarehouseParams): SnowflakeStatementSpec {
const clauses: string[] = []
if (params.warehouseSize?.trim()) {
const size = normalizeWarehouseSize(params.warehouseSize)
if (!size) {
throw new Error(`warehouseSize must be one of ${SNOWFLAKE_WAREHOUSE_SIZES.join(', ')}`)
}
clauses.push(`WAREHOUSE_SIZE = ${size}`)
}
if (params.autoSuspendSeconds !== undefined) {
// Snowflake documents "any integer 0 or greater, or NULL" — there is no
// upper bound to enforce here.
const seconds = params.autoSuspendSeconds
if (!Number.isInteger(seconds) || seconds < 0) {
throw new Error(
'autoSuspendSeconds must be an integer of 0 or greater; 0 disables automatic suspension'
)
}
clauses.push(`AUTO_SUSPEND = ${seconds}`)
}
if (params.autoResume !== undefined) {
clauses.push(`AUTO_RESUME = ${params.autoResume ? 'TRUE' : 'FALSE'}`)
}
if (clauses.length === 0) {
throw new Error('Set at least one of warehouseSize, autoSuspendSeconds, or autoResume')
}
return {
statement: `ALTER WAREHOUSE ${identifier(params.warehouseName)} SET ${clauses.join(' ')}`,
}
}
/** Documented ceiling for a single unloaded file: 5368709120 bytes (5 GB). */
const MAX_UNLOAD_FILE_SIZE_BYTES = 5_368_709_120
/**
* `COPY INTO <location>` grammar is positional: FROM, then PARTITION BY, then
* FILE_FORMAT, then the copy options.
*
* @see https://docs.snowflake.com/en/sql-reference/sql/copy-into-location
*/
export function buildUnloadData(params: SnowflakeUnloadDataParams): SnowflakeStatementSpec {
if (!params.table?.trim()) {
throw new Error('table is required to unload data')
}
const source = qualifiedIdentifier(params.database, params.schema, params.table)
const clauses = [`COPY INTO ${stagePath(params.stagePath)}`, `FROM ${source}`]
if (params.fileFormat?.trim()) {
clauses.push(
`FILE_FORMAT = (FORMAT_NAME = ${stringLiteral(qualifiedIdentifierValue(params.fileFormat.trim()))})`
)
}
// Always emitted, never conditional: an injected `OVERWRITE = TRUE` that
// somehow escaped the derived table would duplicate this option and be
// rejected by Snowflake instead of silently replacing staged files.
clauses.push(`OVERWRITE = ${params.overwrite ? 'TRUE' : 'FALSE'}`)
if (params.singleFile !== undefined) {
clauses.push(`SINGLE = ${params.singleFile ? 'TRUE' : 'FALSE'}`)
}
if (params.maxFileSizeBytes !== undefined) {
// Snowflake documents 16777216 (16 MB) as the default and 5368709120 (5 GB)
// as the maximum — both binary, not decimal, multiples.
if (
!Number.isInteger(params.maxFileSizeBytes) ||
params.maxFileSizeBytes < 1 ||
params.maxFileSizeBytes > MAX_UNLOAD_FILE_SIZE_BYTES
) {
throw new Error(
`maxFileSizeBytes must be an integer between 1 and ${MAX_UNLOAD_FILE_SIZE_BYTES}`
)
}
clauses.push(`MAX_FILE_SIZE = ${params.maxFileSizeBytes}`)
}
// HEADER is not a copy option: the grammar places it after copyOptions and
// VALIDATION_MODE, so it must be emitted last.
if (params.header !== undefined) clauses.push(`HEADER = ${params.header ? 'TRUE' : 'FALSE'}`)
return { statement: clauses.join(' ') }
}
/**
* QUERY_HISTORY covers the last seven days. BCR-1410 allowlists binds for the
* base function's arguments — but not for every variant, and not for
* TASK_HISTORY's time bounds, where a bind is silently dropped rather than
* rejected. Everything here is emitted as a literal so no argument's
* bind-ability has to be tracked per variant.
*
* @see https://docs.snowflake.com/en/sql-reference/functions/query_history
*/
export function buildListQueryHistory(
params: SnowflakeListQueryHistoryParams
): SnowflakeStatementSpec {
const userName = params.userName?.trim()
const warehouseName = params.warehouseName?.trim()
if (userName && warehouseName) {
throw new Error('Filter query history by user or by warehouse, not both')
}
const args = [`RESULT_LIMIT => ${normalizeMaxRows(params.limit)}`]
if (userName) args.push(`USER_NAME => ${stringLiteral(resolvedIdentifierName(userName))}`)
if (warehouseName) {
args.push(`WAREHOUSE_NAME => ${stringLiteral(resolvedIdentifierName(warehouseName))}`)
}
if (params.startTime?.trim()) {
args.push(`END_TIME_RANGE_START => ${historyTimestamp(params.startTime, 'startTime', 7)}`)
}
if (params.endTime?.trim()) {
args.push(`END_TIME_RANGE_END => ${historyTimestamp(params.endTime, 'endTime', 7)}`)
}
const fn = userName
? 'QUERY_HISTORY_BY_USER'
: warehouseName
? 'QUERY_HISTORY_BY_WAREHOUSE'
: 'QUERY_HISTORY'
// INFORMATION_SCHEMA.QUERY_HISTORY reports failures as `failed_with_error` or
// `failed_with_incident`. `FAIL` is the ACCOUNT_USAGE spelling and matches
// nothing here, which would make errorOnly a silent no-op. Casing is not
// documented as normalized and Snowflake compares strings case-sensitively,
// so the filter folds case rather than guessing.
const where = params.errorOnly
? " WHERE UPPER(EXECUTION_STATUS) IN ('FAILED_WITH_ERROR', 'FAILED_WITH_INCIDENT')"
: ''
// Qualification is required: a bare INFORMATION_SCHEMA resolves against the
// session's current database and this statement never sets one. SNOWFLAKE is
// used because it always exists and QUERY_HISTORY is account-scoped, so the
// answer is identical from any database. Note the vendor docs show this
// qualification only for TASK_HISTORY — it is inferred here, not documented.
return {
statement: `SELECT * FROM TABLE(SNOWFLAKE.INFORMATION_SCHEMA.${fn}(${args.join(', ')}))${where} ORDER BY END_TIME DESC`,
}
}
/**
* COPY_HISTORY covers the last 14 days and requires START_TIME, which is
* enforced here rather than left to a server-side error. It has no
* RESULT_LIMIT argument, so the row bound is applied by the wrapping SELECT, and
* it is database-scoped, so it is read from the target database's
* INFORMATION_SCHEMA rather than an unset session database.
*
* @see https://docs.snowflake.com/en/sql-reference/functions/copy_history
*/
export function buildListCopyHistory(
params: SnowflakeListCopyHistoryParams
): SnowflakeStatementSpec {
if (!params.startTime?.trim()) {
throw new Error('startTime is required to list copy history')
}
const table = qualifiedIdentifier(params.database, params.schema, params.table)
const args = [
`TABLE_NAME => ${stringLiteral(table)}`,
`START_TIME => ${historyTimestamp(params.startTime, 'startTime', 14)}`,
]
if (params.endTime?.trim()) {
args.push(`END_TIME => ${historyTimestamp(params.endTime, 'endTime', 14)}`)
}
return {
statement: `SELECT * FROM TABLE(${identifier(params.database)}.INFORMATION_SCHEMA.COPY_HISTORY(${args.join(', ')})) ORDER BY LAST_LOAD_TIME DESC LIMIT ${normalizeMaxRows(params.limit)}`,
}
}
/** `SHOW` command and its per-kind detail column, keyed by picker kind. */
const SELECTOR_SHOW_COMMANDS: Record<
Exclude<SnowflakeSelectorKind, 'roles'>,
{ command: string; scope: 'account' | 'database' | 'schema'; detail: string }
> = {
databases: { command: 'SHOW DATABASES', scope: 'account', detail: '"comment"' },
warehouses: { command: 'SHOW WAREHOUSES', scope: 'account', detail: '"state"' },
schemas: { command: 'SHOW SCHEMAS', scope: 'database', detail: '"comment"' },
tables: { command: 'SHOW TABLES', scope: 'schema', detail: '"comment"' },
file_formats: { command: 'SHOW FILE FORMATS', scope: 'schema', detail: '"type"' },
// SHOW PROCEDURES has no `comment` column; the argument signature is what
// disambiguates overloads, so that is the detail worth showing.
procedures: { command: 'SHOW PROCEDURES', scope: 'schema', detail: '"arguments"' },
}
function requireScope(
value: string | undefined,
kind: SnowflakeSelectorKind,
field: string
): string {
if (!value?.trim()) throw new Error(`Snowflake ${field} is required to list ${kind}`)
return value
}
/**
* Statement backing one editor picker.
*
* Every `SHOW` is post-processed with the flow operator (`->>`) rather than
* read positionally: `SHOW` output columns differ per command and Snowflake
* adds new ones between releases, and several of these commands
* (`SHOW WAREHOUSES`, `SHOW FILE FORMATS`, `SHOW PROCEDURES`) accept no
* `LIMIT` clause of their own. Projecting `name` and one detail column through
* a piped `SELECT` gives every kind the same two-column shape, a stable order,
* and a row bound.
*/
export function buildSelectorStatement(
kind: SnowflakeSelectorKind,
scope: SnowflakeSelectorScope,
limit: number
): SnowflakeStatementSpec {
if (kind === 'roles') {
// Returns one row holding a JSON array of the roles available to the
// token's user. SHOW ROLES would need account-level privileges a
// programmatic access token often lacks.
return { statement: 'SELECT CURRENT_AVAILABLE_ROLES()' }
}
const { command, scope: requiredScope, detail } = SELECTOR_SHOW_COMMANDS[kind]
const target =
requiredScope === 'account'
? ''
: requiredScope === 'database'
? ` IN DATABASE ${identifier(requireScope(scope.database, kind, 'database'))}`
: ` IN SCHEMA ${qualifiedIdentifier(
requireScope(scope.database, kind, 'database'),
requireScope(scope.schema, kind, 'schema')
)}`
return {
statement: `${command}${target} ->> SELECT "name", ${detail} AS "detail" FROM $1 ORDER BY 1 LIMIT ${normalizeMaxRows(limit)}`,
}
}
+55
View File
@@ -0,0 +1,55 @@
import { buildSuspendTask } from '@/tools/snowflake/sql'
import type { SnowflakeStatementResponse, SnowflakeTaskParams } from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const suspendTaskTool: ToolConfig<SnowflakeTaskParams, SnowflakeStatementResponse> = {
id: 'snowflake_suspend_task',
version: '1.0.0',
name: 'Snowflake Suspend Task',
description: 'Suspend a Snowflake task so its schedule stops triggering runs.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
schema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Schema name',
},
taskName: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Task name without a database or schema prefix',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildSuspendTask(params))
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeWarehouseParams } from '@/too
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -17,18 +18,7 @@ export const suspendWarehouseTool: ToolConfig<
name: 'Snowflake Suspend Warehouse',
description: 'Suspend a Snowflake virtual warehouse.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+77 -2
View File
@@ -14,6 +14,27 @@ export const SNOWFLAKE_BINDING_TYPES = [
'TIMESTAMP_NTZ',
] as const
/**
* Warehouse sizes Snowflake accepts as bare keywords. The hyphenated aliases
* (`'X-SMALL'`, `'2X-LARGE'`, …) name the same sizes but must be quoted, so
* only the keyword spellings are offered — one accepted form, no quoting rule
* for the caller to get wrong.
*
* @see https://docs.snowflake.com/en/sql-reference/sql/alter-warehouse
*/
export const SNOWFLAKE_WAREHOUSE_SIZES = [
'XSMALL',
'SMALL',
'MEDIUM',
'LARGE',
'XLARGE',
'XXLARGE',
'XXXLARGE',
'X4LARGE',
'X5LARGE',
'X6LARGE',
] as const
export type SnowflakeBindingType = (typeof SNOWFLAKE_BINDING_TYPES)[number]
export interface SnowflakeBinding {
@@ -22,8 +43,12 @@ export interface SnowflakeBinding {
}
export interface SnowflakeBaseParams {
host: string
apiKey: string
/** Id of the selected Snowflake programmatic-access-token credential. */
oauthCredential: string
/** Programmatic access token, injected by the executor from the credential. */
accessToken?: string
/** Account host, injected by the executor from the credential. */
domain?: string
}
export interface SnowflakeStatementParams extends SnowflakeBaseParams {
@@ -93,6 +118,50 @@ export interface SnowflakeLoadDataParams extends SnowflakeTableParams {
matchByColumnName?: 'CASE_SENSITIVE' | 'CASE_INSENSITIVE' | 'NONE'
}
export interface SnowflakeUnloadDataParams extends SnowflakeResultParams {
database: string
schema: string
stagePath: string
/** Source table. An inline query is deliberately not supported — see buildUnloadData. */
table: string
fileFormat?: string
header?: boolean
overwrite?: boolean
singleFile?: boolean
maxFileSizeBytes?: number
}
export interface SnowflakeListDatabasesParams extends SnowflakeStatementParams {
nameLike?: string
limit?: number
}
export interface SnowflakeListSchemasParams extends SnowflakeListDatabasesParams {
database: string
}
export interface SnowflakeListTablesParams extends SnowflakeListSchemasParams {
schema: string
}
export interface SnowflakeListQueryHistoryParams extends SnowflakeComputeParams {
userName?: string
warehouseName?: string
startTime?: string
endTime?: string
errorOnly?: boolean
limit?: number
}
export interface SnowflakeListCopyHistoryParams extends SnowflakeComputeParams {
database: string
schema: string
table: string
startTime: string
endTime?: string
limit?: number
}
export interface SnowflakeListWarehousesParams extends SnowflakeStatementParams {
maxRows?: number
nameLike?: string
@@ -102,6 +171,12 @@ export interface SnowflakeWarehouseParams extends SnowflakeStatementParams {
warehouseName: string
}
export interface SnowflakeAlterWarehouseParams extends SnowflakeWarehouseParams {
warehouseSize?: string
autoSuspendSeconds?: number
autoResume?: boolean
}
export interface SnowflakeListTasksParams extends SnowflakeStatementParams {
database: string
schema: string
+110
View File
@@ -0,0 +1,110 @@
import { buildUnloadData } from '@/tools/snowflake/sql'
import type { SnowflakeStatementResponse, SnowflakeUnloadDataParams } from '@/tools/snowflake/types'
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
export const unloadDataTool: ToolConfig<SnowflakeUnloadDataParams, SnowflakeStatementResponse> = {
id: 'snowflake_unload_data',
version: '1.0.0',
name: 'Snowflake Unload Data',
description: 'Export a Snowflake table to files in a stage with COPY INTO.',
params: {
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Snowflake role to use for this statement',
},
statementTimeoutSeconds: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Statement timeout in seconds; 0 uses Snowflake maximum of 604800 seconds',
},
warehouse: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Warehouse to use for this statement; defaults to the PAT user setting',
},
maxRows: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum result rows; defaults to 1000 with a Sim safety limit of 10000',
},
database: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Database name',
},
schema: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Schema name',
},
stagePath: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Destination stage reference, for example @EXPORTS/daily',
},
table: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Source table to unload. To export a query result, materialize it first as a view or with CREATE TABLE AS SELECT, then unload that',
},
fileFormat: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Named file format applied to the unloaded files',
},
header: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Whether to write column headings into the unloaded files; supported for CSV and Parquet only',
},
overwrite: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to replace existing files with matching names in the stage',
},
singleFile: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to write one file instead of splitting the output across files',
},
maxFileSizeBytes: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description:
'Upper size limit per unloaded file in bytes; Snowflake defaults to 16777216 (16 MB) and allows up to 5368709120 (5 GB)',
},
},
request: snowflakeStatementRequest((params) =>
buildSnowflakeStatementBody(params, buildUnloadData(params), {
context: { database: params.database, schema: params.schema },
warehouse: params.warehouse,
maxRows: params.maxRows,
})
),
transformResponse: transformSnowflakeResult(),
outputs: SNOWFLAKE_STATEMENT_OUTPUTS,
}
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeUpdateRowsParams } from '@/to
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -38,18 +39,7 @@ export const updateRowsTool: ToolConfig<SnowflakeUpdateRowsParams, SnowflakeStat
name: 'Snowflake Update Rows',
description: 'Update matching rows with a bound MERGE statement without inserting new rows.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+2 -12
View File
@@ -3,6 +3,7 @@ import type { SnowflakeStatementResponse, SnowflakeUpsertRowsParams } from '@/to
import { SNOWFLAKE_STATEMENT_OUTPUTS } from '@/tools/snowflake/types'
import {
buildSnowflakeStatementBody,
snowflakeAuthParamFields,
snowflakeStatementRequest,
transformSnowflakeResult,
} from '@/tools/snowflake/utils'
@@ -38,18 +39,7 @@ export const upsertRowsTool: ToolConfig<SnowflakeUpsertRowsParams, SnowflakeStat
name: 'Snowflake Upsert Rows',
description: 'Update matching rows and insert unmatched rows with a bound MERGE statement.',
params: {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
+222 -56
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
import { SnowflakeBlock } from '@/blocks/blocks/snowflake'
import type { SubBlockConfig } from '@/blocks/types'
import { prepareToolRequest } from '@/tools/request-transport'
import * as snowflakeTools from '@/tools/snowflake'
import { cancelStatementTool } from '@/tools/snowflake/cancel_statement'
@@ -15,6 +17,7 @@ import {
normalizeSnowflakeHost,
readSnowflakeResult,
SNOWFLAKE_MAX_RESPONSE_BYTES,
snowflakeAuthParamFields,
} from '@/tools/snowflake/utils'
import type { ToolConfig } from '@/tools/types'
@@ -38,14 +41,40 @@ function mergedBlockInputs(inputs: Record<string, unknown>): Record<string, unkn
return { ...inputs, ...mapParams(inputs) }
}
const snowflakeToolById = new Map(registeredSnowflakeTools().map((tool) => [tool.id, tool]))
const snowflakeOperationIds = (
SnowflakeBlock.subBlocks.find((block) => block.id === 'operation')?.options ?? []
).map((option) => String(option.id))
/** The tool param a sub-block ultimately publishes under. */
const paramIdOf = (subBlock: SubBlockConfig) => subBlock.canonicalParamId ?? subBlock.id
/** Operations a sub-block is visible for, expanded from its condition. */
function conditionOperations(subBlock: SubBlockConfig): string[] {
const condition = subBlock.condition
if (!condition || typeof condition === 'function') return snowflakeOperationIds
const value = condition.value
const list = (Array.isArray(value) ? value : [value]).map(String)
return condition.not ? snowflakeOperationIds.filter((op) => !list.includes(op)) : list
}
/** Operations a sub-block is required for. */
function requiredOperations(subBlock: SubBlockConfig): string[] {
const required = subBlock.required
if (required === true) return snowflakeOperationIds
if (!required || typeof required !== 'object') return []
const value = (required as { value: unknown }).value
return (Array.isArray(value) ? value : [value]).map(String)
}
describe('Snowflake integration contracts', () => {
it('keeps all 21 block operations aligned with registered tool IDs', () => {
it('keeps all 30 block operations aligned with registered tool IDs', () => {
const tools = registeredSnowflakeTools()
const operationBlock = SnowflakeBlock.subBlocks.find((block) => block.id === 'operation')
const operationIds = operationBlock?.options?.map((option) => String(option.id)) ?? []
const expectedToolIds = operationIds.map((operation) => `snowflake_${operation}`)
expect(operationIds).toHaveLength(21)
expect(operationIds).toHaveLength(30)
expect(SnowflakeBlock.tools.access).toEqual(expectedToolIds)
expect(tools.map((tool) => tool.id).sort()).toEqual([...expectedToolIds].sort())
for (const operation of operationIds) {
@@ -53,16 +82,156 @@ describe('Snowflake integration contracts', () => {
}
})
it('keeps every canonical selector group well formed', () => {
const ids = SnowflakeBlock.subBlocks.map((subBlock) => subBlock.id)
expect(new Set(ids).size, 'duplicate sub-block id').toBe(ids.length)
// A canonical id that also names a sub-block collides with its own group:
// the serializer deletes member ids and republishes under the canonical id,
// so the two would fight over the same key.
for (const subBlock of SnowflakeBlock.subBlocks) {
if (!subBlock.canonicalParamId) continue
expect(ids, `${subBlock.id} canonical id collides with a sub-block id`).not.toContain(
subBlock.canonicalParamId
)
}
const groups = buildCanonicalIndex(SnowflakeBlock.subBlocks).groupsById
// Each picker pairs one basic selector with one advanced text input, so a
// value can always be typed or referenced when the picker cannot list it.
// Both members must agree on condition and required, or the serializer's
// basic/advanced swap would change when the field shows or blocks a run.
for (const [canonicalId, group] of Object.entries(groups)) {
expect(group.basicId, `${canonicalId} has no basic member`).toBeTruthy()
expect(group.advancedIds, `${canonicalId} has no advanced member`).toHaveLength(1)
const members = SnowflakeBlock.subBlocks.filter(
(subBlock) => subBlock.canonicalParamId === canonicalId
)
expect(
new Set(members.map((member) => JSON.stringify(member.required ?? null))).size,
`${canonicalId} members disagree on required`
).toBe(1)
expect(
new Set(members.map((member) => JSON.stringify(member.condition ?? null))).size,
`${canonicalId} members disagree on condition`
).toBe(1)
}
expect(Object.keys(groups).sort()).toEqual([
'database',
'fileFormat',
'oauthCredential',
'procedureName',
'role',
'schema',
'table',
'warehouse',
'warehouseName',
])
})
it('every required tool param is required on the block for that operation', () => {
const problems: string[] = []
for (const op of snowflakeOperationIds) {
const tool = snowflakeToolById.get(`snowflake_${op}`)
for (const [name, cfg] of Object.entries<any>(tool.params ?? {})) {
if (cfg.visibility === 'hidden' || !cfg.required) continue
if (name === 'oauthCredential') continue
const members = SnowflakeBlock.subBlocks.filter((sb) => paramIdOf(sb) === name)
if (!members.length) {
problems.push(`${tool.id}.${name}: no sub-block`)
continue
}
const shown = members.filter((sb) => conditionOperations(sb).includes(op))
if (!shown.length) problems.push(`${tool.id}.${name}: not shown for ${op}`)
const required = shown.filter((sb) => requiredOperations(sb).includes(op))
if (shown.length && !required.length)
problems.push(`${tool.id}.${name}: not required for ${op}`)
}
}
expect(problems).toEqual([])
})
it('no sub-block is shown for an operation whose tool does not accept it', () => {
const skip = new Set(['operation', 'onErrorThreshold'])
const problems: string[] = []
for (const sb of SnowflakeBlock.subBlocks) {
if (skip.has(sb.id)) continue
const name = paramIdOf(sb)
for (const op of conditionOperations(sb)) {
const tool = snowflakeToolById.get(`snowflake_${op}`)
if (!tool) {
problems.push(`${sb.id}: unknown op ${op}`)
continue
}
if (!(name in (tool.params ?? {})))
problems.push(`${sb.id} shown for ${op} but ${tool.id} has no ${name}`)
}
}
expect(problems).toEqual([])
})
it('block inputs and dependsOn reference real things', () => {
const surfaces = new Set(SnowflakeBlock.subBlocks.map(paramIdOf))
const orphanInputs = Object.keys(SnowflakeBlock.inputs).filter((k) => !surfaces.has(k))
expect(orphanInputs).toEqual([])
const ids = new Set(SnowflakeBlock.subBlocks.map((sb) => sb.id))
const badDeps: string[] = []
for (const sb of SnowflakeBlock.subBlocks) {
for (const dep of (sb as any).dependsOn ?? []) {
if (!ids.has(dep)) badDeps.push(`${sb.id} -> ${dep}`)
}
}
expect(badDeps).toEqual([])
})
it('never hides a required field behind advanced mode', () => {
const problems: string[] = []
for (const subBlock of SnowflakeBlock.subBlocks) {
if (!requiredOperations(subBlock).length) continue
// A canonical pair always has a basic member, and a field gated behind an
// advanced-only parent (onErrorThreshold under onError) cannot appear in
// basic mode at all — neither is reachable-only-in-advanced.
if (subBlock.canonicalParamId || (subBlock.required as { and?: unknown })?.and) continue
if (subBlock.mode === 'advanced')
problems.push(`${subBlock.id} is required but advanced-only`)
}
expect(problems).toEqual([])
})
it('every tool param declares required, visibility and description', () => {
const problems: string[] = []
for (const tool of registeredSnowflakeTools()) {
for (const [name, cfg] of Object.entries<any>(tool.params ?? {})) {
if (typeof cfg.required !== 'boolean') problems.push(`${tool.id}.${name} required`)
if (!cfg.visibility) problems.push(`${tool.id}.${name} visibility`)
if (!cfg.description) problems.push(`${tool.id}.${name} description`)
}
if (tool.version !== '1.0.0') problems.push(`${tool.id} version`)
if (!tool.name?.startsWith('Snowflake ')) problems.push(`${tool.id} name`)
if (!tool.description?.endsWith('.')) problems.push(`${tool.id} description`)
if (!tool.outputs) problems.push(`${tool.id} outputs`)
}
expect(problems).toEqual([])
})
it('keeps tool parameters and outputs represented by the block contract', () => {
for (const tool of registeredSnowflakeTools()) {
expect(tool.params.host).toMatchObject({ required: true, visibility: 'user-only' })
expect(tool.params.apiKey).toMatchObject({ required: true, visibility: 'user-only' })
expect(tool.params.oauthCredential).toMatchObject({
required: true,
visibility: 'user-only',
})
expect(tool.params).not.toHaveProperty('timeout')
expect(tool.version).toBe('1.0.0')
for (const param of Object.keys(tool.params)) {
for (const [param, config] of Object.entries(tool.params)) {
// Hidden params (accessToken, domain) are injected by the executor
// from the selected credential, so they have no editor surface.
if (config.visibility === 'hidden') continue
expect(SnowflakeBlock.inputs, `${tool.id}.${param} block input`).toHaveProperty(param)
// A param is surfaced either by a sub-block of the same id or by a
// basic/advanced pair republishing under that canonical id.
expect(
SnowflakeBlock.subBlocks.some((subBlock) => subBlock.id === param),
SnowflakeBlock.subBlocks.some(
(subBlock) => subBlock.id === param || subBlock.canonicalParamId === param
),
`${tool.id}.${param} sub-block`
).toBe(true)
}
@@ -74,23 +243,13 @@ describe('Snowflake integration contracts', () => {
it('declares every shared connection and session param identically across tools', () => {
/**
* Each tool inlines these params rather than spreading a shared object, matching
* the convention used by every other integration. Duplication is only safe while
* the definitions stay byte-identical, so pin them here.
* The auth params come from a shared object; the session params are still
* inlined per tool, matching the convention used by every other
* integration. Duplication is only safe while the definitions stay
* byte-identical, so pin them all here.
*/
const shared = {
host: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake account host, for example myorg-myaccount.snowflakecomputing.com',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake programmatic access token',
},
...snowflakeAuthParamFields,
role: {
type: 'string',
required: false,
@@ -128,12 +287,13 @@ describe('Snowflake integration contracts', () => {
}
expect(seen).toEqual({
host: 21,
apiKey: 21,
role: 19,
statementTimeoutSeconds: 19,
warehouse: 12,
maxRows: 6,
oauthCredential: 30,
accessToken: 30,
domain: 30,
role: 28,
statementTimeoutSeconds: 28,
warehouse: 15,
maxRows: 7,
})
})
@@ -163,6 +323,12 @@ describe('Snowflake integration contracts', () => {
procedureArguments: '[{"type":"TEXT","value":"x"}]',
onError: 'CONTINUE',
onErrorThreshold: '1',
maxFileSizeBytes: '16000000',
autoSuspendSeconds: '600',
header: true,
overwrite: true,
singleFile: true,
autoResume: true,
}
let coveredCoercions = 0
@@ -188,7 +354,7 @@ describe('Snowflake integration contracts', () => {
}
}
expect(coveredCoercions, 'coercion fixture must exercise every coerced tool param').toBe(37)
expect(coveredCoercions, 'coercion fixture must exercise every coerced tool param').toBe(54)
expect(
mapParams({ operation: 'load_data', onError: 'SKIP_FILE_PERCENT', onErrorThreshold: '5' })
@@ -252,8 +418,8 @@ describe('Snowflake SQL API transport', () => {
)
const snowflakeHeaders = getSnowflakeHeaders({
host: 'acme.snowflakecomputing.com',
apiKey: ' secret ',
domain: 'acme.snowflakecomputing.com',
accessToken: ' secret ',
})
expect(snowflakeHeaders).toMatchObject({
Authorization: 'Bearer secret',
@@ -266,8 +432,8 @@ describe('Snowflake SQL API transport', () => {
it('keeps statement timeout in the Snowflake body, not the HTTP transport', () => {
const prepared = prepareToolRequest(executeSqlTool, {
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statement: 'SELECT 1',
statementTimeoutSeconds: 60,
})
@@ -284,8 +450,8 @@ describe('Snowflake SQL API transport', () => {
expect(
buildSnowflakeStatementBody(
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
role: '"Analyst ""Plus"""',
statementTimeoutSeconds: 30,
},
@@ -309,7 +475,7 @@ describe('Snowflake SQL API transport', () => {
})
it('uses list limits as the SQL API result bound', () => {
const auth = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
const auth = { domain: 'acme.snowflakecomputing.com', accessToken: 'secret' }
const listTasksBody = listTasksTool.request.body
const listRunsBody = listTaskRunsTool.request.body
if (typeof listTasksBody !== 'function' || typeof listRunsBody !== 'function') {
@@ -338,8 +504,8 @@ describe('Snowflake SQL API transport', () => {
const listTasks = listTasksBody(
mergedBlockInputs({
operation: 'list_tasks',
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
database: 'ANALYTICS',
schema: 'PUBLIC',
limit: '25',
@@ -353,8 +519,8 @@ describe('Snowflake SQL API transport', () => {
const insertRows = insertRowsBody(
mergedBlockInputs({
operation: 'insert_rows',
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
database: 'ANALYTICS',
schema: 'PUBLIC',
table: 'EVENTS',
@@ -367,8 +533,8 @@ describe('Snowflake SQL API transport', () => {
const execute = executeBody(
mergedBlockInputs({
operation: 'execute_sql',
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statement: 'SELECT 1',
warehouse: 'compute_wh',
maxRows: '25',
@@ -397,8 +563,8 @@ describe('Snowflake SQL API transport', () => {
}
)
const transformed = await getStatementTool.transformResponse?.(response, {
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statementHandle: 'handle',
partition: 0,
})
@@ -562,8 +728,8 @@ describe('Snowflake SQL API transport', () => {
const pending = await executeSqlTool.transformResponse?.(
jsonResponse({ statementHandle: 'async-handle', message: 'Running' }, 202),
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statement: 'SELECT 1',
async: true,
}
@@ -579,8 +745,8 @@ describe('Snowflake SQL API transport', () => {
const canceled = await cancelStatementTool.transformResponse?.(
jsonResponse({ statementHandle: 'cancel-handle', sqlState: '57014', message: 'Canceled' }),
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statementHandle: 'cancel-handle',
}
)
@@ -600,8 +766,8 @@ describe('Snowflake SQL API transport', () => {
message: 'Unexpected cancellation failure',
}),
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statementHandle: 'cancel-handle',
}
)
@@ -615,8 +781,8 @@ describe('Snowflake SQL API transport', () => {
message: 'Statement executed successfully',
}),
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statementHandle: 'cancel-handle',
}
)
@@ -655,8 +821,8 @@ describe('Snowflake SQL API transport', () => {
cancelStatementTool.transformResponse?.(
jsonResponse({ statementHandle: 'cancel-handle', sqlState: '57014' }, 408),
{
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statementHandle: 'cancel-handle',
}
)
@@ -708,7 +874,7 @@ describe('Snowflake SQL API transport', () => {
})
it('rejects session-context names that are not Snowflake identifiers', () => {
const auth = { host: 'acme.snowflakecomputing.com', apiKey: 'secret' }
const auth = { domain: 'acme.snowflakecomputing.com', accessToken: 'secret' }
const spec = { statement: 'SELECT 1' }
expect(() => buildSnowflakeStatementBody({ ...auth, role: 'ACCOUNTADMIN; --' }, spec)).toThrow(
'Snowflake role must be an unquoted identifier'
@@ -780,8 +946,8 @@ describe('Snowflake common result contract', () => {
},
}
const params = {
host: 'acme.snowflakecomputing.com',
apiKey: 'secret',
domain: 'acme.snowflakecomputing.com',
accessToken: 'secret',
statement: 'SELECT 1',
}
const submitted = await executeSqlTool.transformResponse?.(jsonResponse(body), params)
+58 -5
View File
@@ -131,11 +131,45 @@ export function normalizeSnowflakeHost(host: string): string {
return `https://${hostname}`
}
export function getSnowflakeHeaders(params: SnowflakeBaseParams): Record<string, string> {
const apiKey = params.apiKey.trim()
if (!apiKey) throw new Error('Snowflake programmatic access token is required')
/**
* Auth params every Snowflake tool declares. The account host and the
* programmatic access token both live on the selected credential, so the block
* collects neither: `accessToken` and `domain` are injected by the executor
* when it resolves `credential`.
*/
export const snowflakeAuthParamFields = {
oauthCredential: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Snowflake credential (account host and programmatic access token)',
},
accessToken: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Programmatic access token injected by the executor from the selected credential',
},
domain: {
type: 'string',
required: false,
visibility: 'hidden',
description: 'Snowflake account host injected by the executor from the selected credential',
},
} satisfies ToolConfig['params']
/**
* Header set every Snowflake SQL API request carries. Shared with the
* credential validator in
* `@/lib/credentials/token-service-accounts/validators/snowflake` so a token
* that verifies at connect time is proven against the exact header shape the
* tools use at run time.
*/
export function buildSnowflakeAuthHeaders(accessToken: string): Record<string, string> {
const token = accessToken.trim()
if (!token) throw new Error('Snowflake programmatic access token is required')
return {
Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Sim/1.0 (+https://sim.ai)',
@@ -143,13 +177,32 @@ export function getSnowflakeHeaders(params: SnowflakeBaseParams): Record<string,
}
}
export function getSnowflakeHeaders(params: SnowflakeBaseParams): Record<string, string> {
if (!params.accessToken) {
throw new Error('No Snowflake credential is selected, or it could not be resolved')
}
return buildSnowflakeAuthHeaders(params.accessToken)
}
/**
* Account host for the selected credential. The host is stored on the
* credential rather than entered per block, so a missing value means the
* credential failed to resolve — not that the user left a field blank.
*/
export function getSnowflakeBaseUrl(params: SnowflakeBaseParams): string {
if (!params.domain) {
throw new Error('No Snowflake credential is selected, or it could not be resolved')
}
return normalizeSnowflakeHost(params.domain)
}
export function snowflakeStatementRequest<P extends SnowflakeBaseParams>(
body: (params: P) => Record<string, unknown>,
asynchronous?: (params: P) => boolean
): ToolConfig<P>['request'] {
return {
url: (params) =>
`${normalizeSnowflakeHost(params.host)}/api/v2/statements${asynchronous?.(params) ? '?async=true' : ''}`,
`${getSnowflakeBaseUrl(params)}/api/v2/statements${asynchronous?.(params) ? '?async=true' : ''}`,
method: 'POST',
headers: getSnowflakeHeaders,
body,
+2 -2
View File
@@ -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: 1008,
zodRoutes: 1008,
totalRoutes: 1009,
zodRoutes: 1009,
nonZodRoutes: 0,
} as const
+1
View File
@@ -59,6 +59,7 @@ const HANDWRITTEN_INTEGRATION_DOCS = new Set([
'pipedrive-service-account',
'salesforce-service-account',
'shopify-service-account',
'snowflake-service-account',
'trello-service-account',
'wealthbox-service-account',
'webflow-service-account',