Dataverse Connector
The Dataverse connector (ctx.connectors.dataverse) reads and writes Dataverse (Dynamics 365) rows, relates rows, calls actions, and moves file and image columns. Locally it calls the Dataverse Web API and shapes the answers the way the Power Automate "Microsoft Dataverse" connector returns them.
entityName is the table's entity set name (the plural, such as accounts or contoso_projects), and recordId the row's GUID.
Authentication
The connector needs a token for your environment (https://contoso.crm.dynamics.com) with the delegated user_impersonation permission on Dynamics CRM (App ID 00000007-0000-0000-c000-000000000000).
With --auth (recommended)
Add the permission to your app registration (see App registration) and name the environment in flowforger.config.json:
{
"auth": {
"clientId": "<client-id>",
"tenantId": "<tenant-id>",
"resources": {
"dataverse": "https://contoso.crm.dynamics.com"
}
}
}
flowforger run flow.ff.ts --auth
--auth requests https://contoso.crm.dynamics.com/user_impersonation when the flow uses a Dataverse action, and uses auth.resources.dataverse as the environment URL. Without that entry the run fails with "auth.resources.dataverse is not configured". flowforger scopes flow.ff.ts shows what a given flow will request.
With an explicit token
az account get-access-token --resource "https://contoso.crm.dynamics.com" --query accessToken -o tsv
flowforger run flow.ff.ts --dv-url https://contoso.crm.dynamics.com --dv-token <token>
Both --dv-url and --dv-token are needed. An explicit --dv-token wins over --auth for Dataverse.
Environment variables
Whenever a Dataverse connection is available (either form above), flow parameters backed by environment variables resolve to the environment's current values (the value record first, then the definition's default) instead of the default saved in the flow. --param name=value overrides a single parameter. With --auth, Dataverse is signed in for this even when the flow has no Dataverse action, as long as auth.resources.dataverse is set. See the CLI reference.
Operations
Call ctx.connectors.dataverse.<Operation>(name, params, connectionReferenceName?). Use the cloud operation id (first column) in flows you deploy. The local aliases run locally but are written into the compiled flow unchanged, so the deployed flow would name an operation the cloud connector does not have.
| Cloud operation id | Designer name | Local aliases | Local behaviour |
|---|---|---|---|
ListRecords | List rows | ListRows, listRows | See List rows. |
GetItem | Get a row by ID | GetItemById, GetRecord, RetrieveRow | recordId, $select, $expand. |
CreateRecord | Add a new row | CreateRow | Answers 201 with the created row. |
UpdateOnlyRecord | Update a row | UpdateRow | PATCH with If-Match: *: a missing row fails with 404. Answers 200 with the updated row. |
UpdateRecord | Upsert a row | UpsertRow, UpsertRecord | PATCH without If-Match: creates the row when the ID is new (201, null columns kept), otherwise updates it (200). |
DeleteRecord | Delete a row | DeleteRow | 204, no body. |
AssociateEntities | Relate rows | AssociateRecords | 204, no body. |
DisassociateEntities | Unrelate rows | DisassociateRecords | 204, no body. |
PerformBoundAction | Perform a bound action | See Actions. | |
PerformUnboundAction | Perform an unbound action | See Actions. | |
GetEntityFileImageFieldContent | Download a file or an image | GetFileContent | See Files and images. |
UpdateEntityFileImageFieldContent | Upload a file or an image | UploadFileContent | 204, no body. |
ExecuteChangeset | ExecuteBatch | Sends requests (an array of { method, entityName, recordId?, body?, contentId? } or { method, url, body? }) as one OData changeset and answers { responses: [{ status, body }] }. This shape is FlowForger's own; it has not been compared with the cloud. | |
GetRelevantRows | Search rows | RelevanceSearch | Dataverse search with searchText, entities, filter, orderby, top, skip. Answers with the search API's response; not compared with the cloud. |
RetrieveRecordis an alias ofGetItem. The editor offers both; the local connector runs either, and a deployed flow carriesGetItem. The same goes for the other local names in the table (ListRows,CreateRow,UpsertRow, ...): the emitter writes the cloud operation id.
UpdateRecordis the upsert. It is the cloud's "Upsert a row" and creates the row when the ID does not exist. For a plain update that must not create anything, useUpdateOnlyRecord.
Writing columns
Column values go in item/<column> keys (an object literal under item is flattened to them), or in a body object:
await ctx.connectors.dataverse.CreateRecord('CreateAccount', {
entityName: 'accounts',
item: {
name: 'Fabrikam',
'primarycontactid@odata.bind': '/contacts(00000000-0000-0000-0000-000000000001)',
},
}, 'shared_commondataserviceforapps');
A column set to an expression that finds nothing is sent as null and clears the column, as in the cloud.
List rows
ListRecords takes $select, $filter, $orderby, $expand, $top, $skip, $skiptoken, $apply, $count and fetchXml.
- Rows come back with null columns left out (on the row itself; nulls inside expanded related rows are kept), and with the OData annotations the cloud returns (formatted values and lookup names).
- A collection-valued
$expandkeeps the Web API's<navigation>@odata.nextLinkper row, pointing at the v9.1 endpoint the cloud connector calls. - A FetchXML query answers with the Web API's output as is: link-entity columns as
<alias>.<column>, aggregates under their aliases, and@Microsoft.Dynamics.CRM.fetchxmlpagingcookie/morerecords. - An OData option set to null or
''(an expression that found nothing) is sent empty, as the cloud sends it ($filter=), and Dataverse rejects it with400"The value for OData query '$filter' cannot be empty." Guard such options withctx.coalesce(...)or leave the key out.
Pagination: with @runtimeConfig {"paginationPolicy":{"minimumItemCount":N}} on the call, the engine follows @odata.nextLink until at least N rows are collected (whole pages) and the result is { value } alone. A FetchXML paging cookie is not followed, so a FetchXML query gives one page. See Outputs and failures.
@Action()
async run(ctx: FlowContext) {
/** @runtimeConfig {"paginationPolicy":{"minimumItemCount":5000}} */
await ctx.connectors.dataverse.ListRecords('ListAccounts', {
entityName: 'accounts',
'$select': 'name,accountnumber',
'$filter': 'statecode eq 0',
}, 'shared_commondataserviceforapps');
await ctx.compose('AccountCount', ctx.length(ctx.body('ListAccounts')?.['value']));
}
Relate and unrelate rows
await ctx.connectors.dataverse.AssociateEntities('LinkContact', {
entityName: 'accounts',
recordId: ctx.triggerBody()?.['accountId'],
associationEntityRelationship: 'contact_customer_accounts',
'item/@odata.id': 'https://contoso.crm.dynamics.com/api/data/v9.1/contacts(00000000-0000-0000-0000-000000000001)',
}, 'shared_commondataserviceforapps');
AssociateEntities takes the related row's URL in item/@odata.id; DisassociateEntities takes it in $id. The designer writes v9.1 row URLs; the local connector rebases them onto the version it calls.
Actions
PerformBoundAction (with entityName and recordId) and PerformUnboundAction POST the action named in actionName, exactly as written:
- A bound action needs its namespace:
Microsoft.Dynamics.CRM.<name>, for exampleMicrosoft.Dynamics.CRM.contoso_Recalculate. The plain name fails with404. - An unbound action must not have the namespace:
contoso_SendWelcome,WhoAmI. Power Automate refuses to save a namespaced unbound action. - Parameters go in
item/<parameter>keys (or a nesteditem). With no parameters, no request body is sent at all, so a missing required parameter fails with Dataverse's "Required field 'X' is missing". - The call is a single POST; there is no fallback to calling a function with GET.
- The answer is the response's properties (nulls left out), or
204with no body for an action without a response.
Files and images
GetEntityFileImageFieldContent takes recordId and fileImageFieldName (and size for images). It asks for the first 4 MB (Range: bytes=0-4194303), as the cloud does, so a file answers 206 PartialContent (recorded without outputs.statusCode) and an image 200. The body is binary content { "$content-type", "$content" }.
UpdateEntityFileImageFieldContent takes recordId, fileImageFieldName, the content in item (text, or binary content such as another action's file body) and the file name in x-ms-file-name.
Failures
A failed call's outputs are { statusCode, body }, where the body is the Web API's own { error: { code, message } }, which is also what the cloud records. Read it with ctx.outputs('X')?.['statusCode'] and ctx.body('X')?.['error']?.['message'] in a catch scope; see Outputs and failures.
Reading a lookup's _<column>_value and passing it to GetItem fails when the lookup is empty. flowforger validate points this out (DV_LOOKUP_UNGUARDED) when no enclosing if checks that column first.
Triggers
Dataverse triggers ("When a row is added, modified or deleted", SubscribeWebhookTrigger) are written into the compiled flow but never fire locally. Pass the row you want to test with as the trigger body: flowforger run flow.ff.ts --in row.json. See Connector triggers.
Running locally
flowforger run flow.ff.ts --auth
flowforger run flow.ff.ts --dv-url https://contoso.crm.dynamics.com --dv-token <token>
To run without touching Dataverse, mock the actions (--mock CreateAccount=created.json), or use --live to allow only the reads you name; see Local testing.
More examples: examples/dataverse/ (list-rows.ff.ts, create-row.ff.ts, update-row.ff.ts, delete-row.ff.ts, relationships.ff.ts, batch-operations.ff.ts, custom-actions.ff.ts, search.ff.ts, file-operations.ff.ts).
Packages
@flowforger/connectors-dataverse: the connector@flowforger/dataverse-sdk: a thin Dataverse Web API client, also used for environment-variable resolution