sfgql
v0.1.3
Published
Developer-experience layer for Salesforce UI API GraphQL: React hooks, LWC helpers, ESLint rules, SOQL converter and codegen.
Maintainers
Readme
sfgql
Salesforce GraphQL, without the traps.
Salesforce's UI API GraphQL is powerful, but the raw API makes you write the same boilerplate and hides a few nasty surprises. sfgql is one small package that fixes them, for React (Multi-Framework UI bundles) and LWC.
| Raw UI API GraphQL | With sfgql |
|---|---|
| Every value is wrapped: edges → node → Name → value | Plain objects: { Id, Name: 'Acme' } |
| Forget first and the API silently returns 10 rows (10 of 17 in a real org) | A lint error (SFGQL001) before it ships |
| Errors arrive as HTTP 200 with HTML-escaped messages | Per-field errors, ready for your form |
| After a save, other screens go stale, or you refetch everything | Only affected queries refresh (12 refetches become 4) |
| You rewrite every SOQL query by hand | sfgql convert "SELECT ..." |
| Parent + 10 children = 11 requests | 1 request (with a small Apex service) |
See the difference
// Before: data.uiapi.query.Account.edges.map((e) => e.node.Name.value), plus paging state and cache refresh
// After:
const { records, loadMore, hasMore } = useSfConnection(ACCOUNTS); // records = [{ Id, Name }]Start in a minute
npm i sfgql @tanstack/react-query @salesforce/sdk-dataThen follow the 9-line React example, the LWC setup, or just try the converter with no setup:
npx sfgql convert "SELECT Name, Industry FROM Account WHERE Industry = 'Technology' LIMIT 20"Honest limits: sfgql saves you requests and code, not CPU: a hand-written mapper flattens faster than flattenQuery. It is early (0.1.x), tested against responses recorded from a real org, and the Apex service is verified in one Developer Edition org. Numbers and caveats below.
Benchmarks
What you gain is fewer requests and less code, not faster in-memory work. Every number comes from a script in benchmarks/ run for this release; request counts are exact, times are simulated (120 ms per round trip), and the createTree row is a projection.
| Measured | Without sfgql | With sfgql | Result |
|---|---|---|---|
| Code to paginate (infinite scroll) | 40 lines | 9 lines | 78% fewer |
| Rows returned if you forget first | 10 of 17, silently | lint error SFGQL001 | caught before production |
| Refetches after one update (12 mounted queries) | 12 | 4 | 67% fewer (360 -> 240 ms simulated @120 ms) |
| 20 components, same query | 20 requests | 1 request | same as plain react-query |
| Create parent + 10 children | 11 requests | 1 request | 91% fewer (projection) |
| Bundle, gzip (whole stack) | Apollo 63.2 kB | 18.9 kB | 3.3x smaller than Apollo; urql (11.1 kB) is smaller |
| Flatten 10,000 records (CPU) | hand-written 2.7 ms | 22.8 ms | not faster: 8.5x slower than a hand-written mapper |
| Mapping a validation error to fields | 13 lines | 1 line | 92% fewer |
Try it interactively: open docs/benchmarks-interactive.html (after install: node_modules/sfgql/docs/benchmarks-interactive.html). Sliders for latency, number of children and number of components recalculate every row from the measured data. Full tables: docs/benchmarks.md.
- Machine: AMD Ryzen 5 7520U with Radeon Graphics, 8 threads, Node v22.18.0, 2026-10-15. Re-run with
npm run bench; timing ratios vary by tens of percent between runs. - Baselines are competent hand-written versions; each was asserted to produce identical data before comparing.
- Nothing here measures Salesforce server speed or a real network. Simulated time = one mutation round trip, then refetches in waves of up to 6 parallel requests.
- The Apex tree service was verified separately in one Developer Edition org (3-level tree, rollback); the benchmark itself uses a fake endpoint, so the
createTreerow is a request-count projection. - Bundle sizes are esbuild minify + gzip of what a typical app imports; Apollo and urql would also need Salesforce-specific glue, which is not counted.
The GitHub repository is private. Links below point to the
docs/folder that ships inside the npm package.
Install
Requires Node 20+. The package is ESM-only (import, not require). Any package manager works:
npm i sfgql # or: pnpm add sfgql | yarn add sfgql | bun add sfgqlEverything else is an optional peer, so install only what your use case needs:
| You are building | Install | Jump to |
|---|---|---|
| A React app inside Salesforce (Multi-Framework UI bundle) | npm i sfgql @tanstack/react-query @salesforce/sdk-data | React in Salesforce |
| A React app outside Salesforce | npm i sfgql @tanstack/react-query | React elsewhere |
| LWC components | npm i -D sfgql (nothing is installed in the org) | LWC |
| A Node script, test or backend | npm i sfgql | Node |
| Lint rules for your GraphQL | npm i -D sfgql eslint | ESLint |
| Types generated on save | npm i -D sfgql vite | Vite |
| Just the command line | npx sfgql ... or npm i -g sfgql | CLI |
| Nested parent + children create | npx sfgql apex install ... | Nested create |
| An AI coding assistant | copy one file | AI assistants |
Peer ranges: react >=18, @tanstack/react-query ^5, eslint >=9, vite >=5, @salesforce/sdk-data >=1.135.0 <2, esbuild (only to load a sfgql.config.ts). Every snippet below was run against an installed copy of the package.
React in Salesforce
npm i sfgql @tanstack/react-query @salesforce/sdk-dataimport { createDataSDK } from '@salesforce/sdk-data';
import { gql, createSdkTransport } from 'sfgql/core';
import { SfgqlProvider, useSfConnection } from 'sfgql/react';
const ACCOUNTS = gql`query Accounts($first: Int = 25, $after: String) {
uiapi { query { Account(first: $first, after: $after) {
edges { node { Id Name { value } } } pageInfo { hasNextPage endCursor } } } } }`;
function List() {
const { records, loadMore, hasMore } = useSfConnection<{ Id: string; Name: string }>(ACCOUNTS);
return <>{records.map((a) => <p key={a.Id}>{a.Name}</p>)}{hasMore && <button onClick={() => loadMore()}>More</button>}</>;
}
export const App = () => <SfgqlProvider transport={createSdkTransport(createDataSDK())}><List /></SfgqlProvider>;More: getting started (React), react module.
React outside Salesforce
Use createFetchTransport with an access token. Never ship a token in browser code; put your own server in between and hand the browser a transport that calls it.
import { createFetchTransport } from 'sfgql/core';
const transport = createFetchTransport({ instanceUrl, accessToken, apiVersion: '67.0' }); // server-side or tests
// <SfgqlProvider transport={transport}> ... same hooks as aboveLWC
LWC cannot import npm packages, so the CLI exports a plain-JS service module into your SFDX project (nothing from npm runs in the org):
npm i -D sfgql
npx sfgql lwc export --out force-app/main/default/lwc/sfgqlCore # writes sfgqlCore.js + .js-meta.xmlimport { graphql, executeMutation } from 'lightning/graphql';
import { flattenQuery, trackWire, mutateAndRefresh } from 'c/sfgqlCore';
@wire(graphql, { query: ACCOUNTS })
wired(result) { trackWire(this, 'accounts', result); this.rows = flattenQuery(result.data)?.Account?.records ?? []; }
async save() { await mutateAndRefresh(this, executeMutation, { query: UPDATE, variables }); }Re-run the export after upgrading sfgql. Auto-refresh works within one component; see LWC guide. --inline prints a paste-able snippet instead of files.
Node scripts, tests and backends
import { createFetchTransport, flattenQuery, gql } from 'sfgql/core';
const transport = createFetchTransport({ instanceUrl, accessToken }); // fetch is injectable for tests
const { data } = await transport.query({ query: gql`query { uiapi { query { Account(first: 5) {
edges { node { Id Name { value } } } pageInfo { hasNextPage endCursor } } } } }` });
console.log(flattenQuery(data)); // { Account: { records: [{ Id, Name }], pageInfo } }From CommonJS, use dynamic import: const core = await import('sfgql/core').
ESLint rules
npm i -D sfgql eslint// eslint.config.js
import sfgql from 'sfgql/eslint';
export default [sfgql.configs.recommended]; // or sfgql.configs.strictCatches the mistakes that fail silently, for example a missing first (SFGQL001: the API quietly returns 10 rows). Rules: docs/rules.
Vite plugin
npm i -D sfgql vite// vite.config.ts
import sfgql from 'sfgql/vite';
export default defineConfig({ plugins: [react(), sfgql({ schema: 'schema.graphql', out: 'src/generated/sfgql-types.ts', validate: true })] });Regenerates operation types on save from your local schema (npx sfgql codegen creates schema.graphql).
CLI
Use it without installing, per project, or globally:
npx sfgql --help # no install
npm i -D sfgql && npx sfgql ... # pinned per project
npm i -g sfgql && sfgql ... # globalsfgql convert "SELECT Name FROM Account WHERE Industry = 'Technology' LIMIT 20" # SOQL -> GraphQL
sfgql explain query.graphql # plain-English description, cost, lint findings, equivalent SOQL
sfgql codegen --target-org my-org # introspect the org and generate TypeScript types
sfgql validate --as [email protected] --target-org my-org # check queries against a user's permissions
sfgql doctor # check your setup
sfgql init # write config, ESLint setup and npm scripts (--dry-run to preview)Org commands use your sf CLI login (sf org login web); tokens and instance URLs are never printed.
Nested create (Apex)
createTree creates a parent and its children in one request through a small Apex REST service. Deploy it to your org first (preview with --dry-run; production orgs need --allow-production):
npx sfgql apex install --target-org my-org --dry-run
npx sfgql apex install --target-org my-orgimport { createTree } from 'sfgql/nested';
await createTree(transport, { object: 'Account', fields: { Name: 'Acme' },
children: { Contacts: [{ object: 'Contact', fields: { LastName: 'Doe' } }] } });Limits: 200 records, depth 3; all-or-nothing by default. Verified in a Developer Edition org; see apex-verification.
AI coding assistants
The package ships a skill and an API index so assistants write correct sfgql code:
mkdir -p .claude/skills/sfgql && cp node_modules/sfgql/skills/sfgql/SKILL.md .claude/skills/sfgql/
# llms.txt (concise API index): node_modules/sfgql/llms.txtModules
| Import | What | Docs |
|---|---|---|
| sfgql/core | transports, flatten, errors, record-Id keys, cost, gql | core |
| sfgql/react | provider and hooks on TanStack Query v5, cache sync | react |
| sfgql/lwc | LWC helpers, exported to the org as c/sfgqlCore | lwc |
| sfgql/realtime | CDC, polling and focus invalidation | realtime |
| sfgql/forms | headless FLS-aware form model | forms |
| sfgql/nested | parent + children create (bundled Apex) | nested |
| sfgql/eslint | flat-config plugin, 10 rules | eslint |
| sfgql/vite | codegen on save, operation validation | vite |
| sfgql/devtools | event bridge for a browser panel | devtools |
| sfgql (bin) | convert codegen validate doctor apex lwc explain init | cli |
Start here: React, LWC. Learn: benchmarks, SOQL to GraphQL, v1 vs v2, gotchas, personas and FLS, recipes, error codes, rules. AI assistants: skills/sfgql/SKILL.md, llms.txt.
Requirements: Node >= 20, ESM only, API v67.0+. Peers (all optional): react >= 18, @tanstack/react-query ^5, eslint >= 9, vite >= 5, @salesforce/sdk-data >=1.135.0 <2.
Status and limits
Verified against a real org (Developer Edition, API v67.0): the transport endpoint, introspection, query/mutation shapes, error shapes, date literals/ranges, semi-joins, aggregates, no-offset, silent 10-row default, IdOrRef!. The SOQL converter is golden-tested (99 cases) against the schema snapshot.
Not verified (treat as experimental):
- Anything that runs inside an org: the React hooks in a deployed UI bundle, LWC
refresh()(A5), importingc/sfgqlCore(A6),lightning/empApiCDC (A8), streaming in a UI bundle (A9), Apex REST from a UI bundle (A11). - The Apex tree service compiled and passed its 25 tests in a Developer Edition org (coverage 95% / 100% / 100%), and a real
createTreecall created a 3-level tree, rolled back a bad one and rejected an unknown relationship. It has not been run in a scratch org or from a UI bundle (A11). Details: docs/reports/apex-verification.md. - Persona validation: introspection as another user (A4) is untested; there are no validation-rule, duplicate-rule, trigger or FLS error fixtures, so those error kinds are classified from documented wording.
- The devtools panel is not part of this package.
- Maximum page size and
upperBoundsemantics. - Both examples are unverified scaffolds (react, lwc).
Differences from the original design are listed in ADR 0002. Non-goals: no UI components, no Aura, no LDS replacement in LWC, no LLM calls.
License
MIT
