afv-library/skills/experience-ui-bundle-salesforce-data-access/references/graphiti-cli.md

19 KiB

Building queries with the graphiti CLI

The base UI-bundle template ships @salesforce/graphiti as a devDependency, exposing a graphiti CLI. Its sf-gql-* subcommands turn a small JSON spec into a schema-correct GraphQL document — the query string, its typed variables, and a TypeScript shape — with every platform guardrail (@optional, value/displayValue wrappers, edges/node, pagination, the mutation Record envelope) already applied. This is the preferred way to author the GraphQL in Read workflow / Write workflow step 2 — you get a query that's already grounded against the org's live schema instead of hand-writing one and discovering field errors at runtime.

The CLI is a query compiler, not a data fetcher. Every sf-gql-* command returns a { query, variables, types, warnings } envelope. It never calls Salesforce for records and never returns rows — execution still happens at runtime through sdk.graphql!.query() / .mutate() exactly as the Read/Write workflows describe. Think of it as "codegen for the query string itself": dev-time you compile the operation, persist it, generate types; runtime the SDK runs it.

Fallback only when the CLI genuinely can't run. Fall back to the schema-grep path (bash <skill-dir>/scripts/graphql-search.sh <Entity>) and hand-author per graphql-hand-authoring.md only when @salesforce/graphiti isn't installed or the org can't be primed (SCHEMA_PRIME_FAILED). The guardrails there are the same ones the CLI automates — you're just applying them by hand.

A primed CLI returning empty / "not found" / Cannot query field is NOT a fallback trigger. That's a fact about the org — a wrong API name, or metadata that isn't deployed/refreshed — not a CLI failure. Re-discover (list before describe), sf-gql-connect --forceRefresh if you just deployed, or deploy the metadata; do not switch to the script or hand-author around it, and never edit schema.graphql to make the name resolve (it grants no org access — see graphql-hand-authoring.md).

MCP, later. These sf-gql-* commands mirror, one-for-one, the sf_gql_* tools of the graphiti MCP server (same args, same output, different transport). If graphiti is later approved as an MCP server, an agent calls the sf_gql_* tools directly and everything below about shapes and behavior still holds — only the invocation changes.


How to invoke it

Run from the UI bundle dir (where package.json with the @salesforce/graphiti dep lives). Each command takes one JSON argument (positional, or piped on stdin) and emits exactly one JSON line on stdout:

npx graphiti sf-gql-list '{"org":"myOrgAlias","object":"Account","fields":["Name","Industry"],"first":10}'

# stdin form (handy for large specs):
echo '{"org":"myOrgAlias","object":"Account","fields":["Name"]}' | npx graphiti sf-gql-list

Always single-quote the JSON argument. It contains " and may contain $varName tokens (see Variables); an unquoted $foo is expanded by the shell to an empty string before graphiti ever sees it.

org is an org alias from the local Salesforce CLI auth (~/.sf / ~/.sfdx) — the same aliases sf org list shows. It is required on every command.

Exit code is 0 on success, 1 on any error (the error is also in the JSON envelope, so you can parse stdout rather than branch on the code).


The three phases

1. Prepare — make sure the org's schema is available

The build commands auto-prime: the first sf-gql-* call for an org downloads and caches its schema, so you usually don't need a separate step. You only call sf-gql-connect explicitly to refresh after a deploy (new objects/fields/picklist values won't appear until you do):

npx graphiti sf-gql-connect '{"org":"myOrgAlias","forceRefresh":true}'
# → {"org":"myOrgAlias","instanceUrl":"https://…","refreshed":true,"cached":false,"durationMs":…}

If priming fails, the command returns a SCHEMA_PRIME_FAILED error envelope (see Errors) — the org is unreachable, unauthed, or hitting a server-side introspection issue. You can't build verified queries against an org you can't prime; surface that to the user rather than guessing field names.

2. Discover — never guess an object or field

sf-gql-discover is how you ground intent against the org before building. Three modes:

# What objects exist (optional substring filter)?
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"list_objects","search":"Account"}'
# → {"mode":"list_objects","objects":[{"name":"Account"}, …]}

# What fields does an object have (+ type, filterable, sortable, picklist values)?
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"describe_object","object":"Hero__c"}'
# → {"mode":"describe_object","object":{"name":"Hero__c","fields":[
#      {"name":"Class__c","label":"Class","type":"PICKLIST","filterable":true,"sortable":true,
#       "picklistValues":["Warrior","Mage","Rogue","Cleric"], …}, …]}}

# Drill into one field
npx graphiti sf-gql-discover '{"org":"myOrgAlias","mode":"describe_field","object":"Hero__c","field":"Class__c"}'

mode is required; object is required for describe_object/describe_field; field is required for describe_field. Use this output to pick exact API names, valid picklist values, and which fields are filterable/sortable — the same facts that otherwise cause silent runtime failures.

3. Build — compile the operation

Pick the command for the task, pass the spec, read the query + variables + types out of the envelope. The next section is the catalogue.


The commands

Command Builds Key spec fields
sf-gql-list List query (uiapi.query) object, fields[], first?, filter?, orderBy?, parentFields?, childRelationships?, scope?
sf-gql-detail Single-record-by-Id query object, fields[], idVariable? (default id)
sf-gql-aggregate Aggregate query (uiapi.aggregate) object, groupBy?[], aggregations?[], filter?, first?
sf-gql-create Create mutation object, returnFields? (default ["Id"]), inputVariable? (default input)
sf-gql-update Update mutation object, returnFields?, inputVariable?
sf-gql-delete Delete mutation object, inputVariable?
sf-gql-raw Arbitrary query from CLI-style select/set/var commands commands[], operation? (query|mutation|aggregate)
sf-gql-discover Schema metadata (no GraphQL) mode, object?, field?, search?
sf-gql-connect Primes/refreshes the schema cache (no GraphQL) forceRefresh?

operationName (most commands) overrides the generated operation name — set it to something meaningful (e.g. "GetActiveHeroes") so the persisted .graphql file and the types codegen generates off it read well. Defaults are derived (<Object>List, <Object>Detail, …).

Output envelope

Every build command emits the same four-key envelope:

{
  "query":     "query …{ … }",          // the GraphQL document — paste verbatim
  "variables": [{ "name": "after", "type": "String", "required": false }],
  "types":     "export interface …",     // TS shape of variables + result (a preview)
  "warnings":  []                         // schema/semantic warnings — READ THESE
}
  • query — paste it verbatim into your .graphql file or inline gql. The guardrails are baked in; don't "tidy" them out (that's the load-bearing part — see Primed vs degraded).
  • variables — the GraphQL variables the operation declares, with types and nullability. These map straight to the variables object you pass to sdk.graphql!.query({ query, variables }).
  • types — a TypeScript preview of the variables + result shape, with anonymized interface names (S37eaResult). Useful to see the shape, but the canonical typed path is still the bundle's npm run graphql:codegen over your saved .graphql (it produces named types you import) — see Wiring into runtime.
  • warnings — non-fatal, but always read them. An empty [] means the operation validated cleanly against the live schema. A non-empty entry usually means a field/object isn't in the primed schema or a selection is semantically off (examples below).

Ground-truth examples

These are real outputs captured from the CLI (query strings pretty-printed for readability; the CLI emits them as a single JSON line).

sf-gql-list{"org":"…","object":"Hero__c","fields":["Name","Level__c","Class__c"],"first":5,"orderBy":{"Level__c":{"order":"DESC"}}}

query Hero__cList($after: String) {
  uiapi {
    query {
      Hero__c(first: 5, after: $after, orderBy: { Level__c: { order: DESC } }) {
        edges {
          node {
            Name @optional { value displayValue }
            Level__c @optional { value displayValue }
            Class__c @optional { value displayValue }
          }
        }
        pageInfo { hasNextPage endCursor }
      }
    }
  }
}

variables: [{ "name": "after", "type": "String", "required": false }] — note the CLI adds forward-pagination ($after + pageInfo) for you. orderBy is a singleton object ({Field:{order:DESC}}), not an array or {field,direction}.

sf-gql-detail{"org":"…","object":"Hero__c","fields":["Name","Level__c"]}

query Hero__cDetail($id: ID!) {
  uiapi { query { Hero__c(where: { Id: { eq: $id } }, first: 1) {
    edges { node {
      Name @optional { value displayValue }
      Level__c @optional { value displayValue }
    } }
  } } }
}

Injects $id: ID! and the where: { Id: { eq: $id } }, first: 1 binding. Pass idVariable to rename $id.

sf-gql-aggregate{"org":"…","object":"Hero__c","groupBy":["Class__c"],"aggregations":[{"function":"count","field":"Id","alias":"total"},{"function":"avg","field":"Level__c","alias":"avgLevel"}]}

query Hero__cAggregate($after: String) {
  uiapi { aggregate { Hero__c(groupBy: { Class__c: { group: true } }, after: $after) {
    edges { node { aggregate {
      Class__c { value }
      total: Id { count { value } }
      avgLevel: Level__c { avg { value } }
    } } }
    pageInfo { hasNextPage endCursor }
  } } }
}

groupBy is ["Field"] (or [{field,function}] for date bucketing); aggregations carry a function (count/countDistinct/sum/avg/min/max), an optional field (defaults to Id for counts), and an alias. Observed quirk: this exact spec returns warnings: ["Validation: Field \"avg\" must not have a selection since type \"Double\" has no subfields."]avg over a Double should be selected bare, not avg { value }. Treat such a warning as a prompt to adjust the selection, not as a failed build.

sf-gql-create{"org":"…","object":"Hero__c","returnFields":["Id","Name"]}

mutation CreateHero__c($input: Hero__cCreateInput!) {
  uiapi { Hero__cCreate(input: $input) { Record {
    Id
    Name @optional { value displayValue }
  } } }
}

The types field describes the input you must supply at runtime — note the entity-keyed wrapper, the single most common mutation-variables mistake:

interface Hero__cCreateInput { Hero__c: Hero__cCreateRepresentation; }
interface Hero__cCreateRepresentation { Name?: string; Class__c?: string; Level__c?: number;  }
// → variables: { input: { Hero__c: { Name: "Aria", Level__c: 5 } } }

sf-gql-update is identical but the input type adds a sibling Id: string ({ input: { Hero__c: {…}, Id: "a0X…" } }). sf-gql-delete uses the generic RecordDeleteInput { Id: string } ({ input: { Id: "a0X…" } }) and selects only Id back — there is no per-entity delete type. Mutation inputs are raw values — never {value}-wrapped (that wrapper is a read-shape thing; mirroring it into a write is the classic failure).

One mutation guardrail the builder does NOT add: allOrNone. The emitted document wraps the operation in a bare uiapi { … }, not uiapi(input: { allOrNone: … }). Before you ship, add the allOrNone wrapper yourself and set it explicitly — uiapi(input: { allOrNone: true }) { Hero__cCreate(input: $input) { Record { … } } } — per guardrail 4 in the spine and the hand-authored templates. graphiti gives you the Record output envelope and the entity-keyed input shape; the transaction policy is still on you.

You do not pass record values to the builder. A create/update spec takes only object, returnFields?, inputVariable?, and operationName? — there is no fields key carrying the values to write. The builder emits the mutation shape (with the input declared as a $variable); the actual values become the runtime variable you pass to sdk.graphql!.mutate({ mutation, variables }). returnFields only controls what you read back after the write.

sf-gql-raw — for shapes the declarative commands don't cover, drive select/set/var:

npx graphiti sf-gql-raw '{"org":"…","commands":["select uiapi/query/Hero__c/edges/node/Name/value","set uiapi/query/Hero__c first=5"]}'

Same @optional/wrapper guardrails are still applied automatically.


Primed vs degraded — why the guardrails sometimes vanish

This is the one behavior that surprises people, and it changes how much you can trust a single build. The guardrail automation (@optional, value/displayValue, edges/node, the typed result shape) is conditional on the object being in the primed schema. When it is, you get the clean output above. When it isn't — wrong API name, not deployed, or schema not yet refreshed after a deploy — the CLI still emits a query, but a degraded one, and flags it:

{"org":"…","object":"Account","fields":["Name","Industry"],"first":3} against an org whose cache doesn't contain Account:

query AccountList($after: String) {
  uiapi { query { Account(first: 3, after: $after) {
    edges { node { Name Industry } }      # ← bare fields: no @optional, no value/displayValue
    pageInfo { hasNextPage endCursor }
  } } }
}

…with types collapsing the result to Account: unknown and:

"warnings": ["Validation: Cannot query field \"Account\" on type \"RecordQuery\"."]

So: a non-empty warnings array — especially Cannot query field … — means the build is NOT trustworthy. Do not ship a degraded query. Re-discover the correct API name, or sf-gql-connect with forceRefresh: true if you just deployed, then rebuild until warnings is []. A clean build is your signal that the guardrails actually fired. Never edit schema.graphql to make a degraded build pass — the mirror is a generated, read-only reflection of the org; adding the missing field/type there silences the warning but grants no org access, so the operation still fails at runtime. Fix the name or deploy + refresh.


Variables — parameterising a query

A $varName string leaf inside filter is promoted to a typed, nullable GraphQL variable, with the type inferred from the schema:

{"org":"…","object":"Hero__c","fields":["Name"],"filter":{"Class__c":{"eq":"$heroClass"}},"first":5}

query Hero__cList($after: String, $heroClass: Picklist) {
  uiapi { query { Hero__c(first: 5, after: $after, where: { Class__c: { eq: $heroClass } }) {  } } }
}
# variables: [ {after, String, false}, {heroClass, Picklist, false} ]

What does not work on the declarative tools: passing a whole-arg $var for filter or orderBy (e.g. "filter":"$where"). Those args are typed as objects and reject a bare string with an INVALID_ARGS error — promote at the leaf, not the whole argument. first is a number and likewise can't take a $var string.


Wiring the output into runtime

The CLI produces the operation; the rest is the existing Read workflow / Write workflow, unchanged:

  1. Persist the query string — inline gql for simple ops, or a .graphql file (one operation per file, imported with ?raw) for complex ones. Use a meaningful operationName so the file/type names read well.
  2. Codegennpm run graphql:codegen (from the UI bundle dir) generates named types into src/api/graphql-operations-types.ts. (The CLI's types field is a preview of the same shape; codegen is the canonical import source.)
  3. Callsdk.graphql!.query({ query, variables }) for reads, sdk.graphql!.mutate({ mutation, variables }) for writes, using the codegen'd types and the variables the CLI listed. Surface decision (! vs guard), error handling, and caching are exactly as the SKILL spine and sdk-api.md describe.

The CLI never executes anything — it has no part in step 3. It just makes step 1 produce a query you can trust.


Error envelope

Failures come back as a single JSON line and set exit code 1:

{"error":{"code":"INVALID_ARGS","message":"Input failed schema validation.","details":[{"path":["mode"],"message":"Required"}]}}
code Means Do
INVALID_ARGS The spec failed Zod validation (missing/mis-typed field). details[] gives the JSON path. Fix the spec per details — e.g. add the required mode, or stop passing a $var where an object is expected.
AUTH_FAILED Org alias not authed / not found in local CLI auth. Check sf org list; have the user authenticate the alias.
SCHEMA_PRIME_FAILED Schema couldn't be downloaded/introspected (unreachable, or a server-side introspection error). Retry; if it persists the org has an introspection problem — fall back to graphql-search.sh + hand-authoring, and tell the user.
INTERNAL Anything else; verbatim message preserved. Read the message. Set GRAPHITI_DEBUG=1 for a stack trace.

Common pitfalls (failure-first)

Symptom Cause Fix
Built query has bare fields, no @optional/wrappers; types shows unknown Object not in the primed schema (typo, not deployed, stale cache) Check warnings for Cannot query field; re-discover the name or connect --forceRefresh, rebuild until warnings: []
$heroClass came through as an empty string Shell expanded $… in an unquoted arg Single-quote the whole JSON argument
INVALID_ARGS on "filter":"$where" Whole-arg $var not allowed on declarative tools Promote at a leaf: "filter":{"Field":{"eq":"$where"}}
Mutation rejected at runtime / wrong shape {value}-wrapped a mutation input, or dropped the entity-key wrapper Inputs are raw values under the entity key: { input: { <Object>: { Field: v } } }; read the types field
Field "avg" must not have a selection… warning Selected avg { value } on a Double Select the aggregate bare per the warning
New field/object missing from discover or build Schema cached before the deploy sf-gql-connect with forceRefresh: true, then rebuild
Used graphiti new/cd/select/run and it executed against the org That's the legacy interactive/session flow, not the sf-gql-* mirror Use the stateless sf-gql-* commands documented here — they compile, they don't execute