Salesforce Data Access (UI bundles)
All Salesforce data access in a UI bundle goes through the
data SDK. The SDK handles auth, CSRF, and base-URL resolution, and — on the WebApp
surface — caches every GraphQL query by default.
This file is the workflow + guardrail spine. Depth lives in linked docs:
- references/graphiti-cli.md — the CLI (
commands) that compiles a small JSON spec into a schema-correct, guardrail-applied query +
variables + types. The preferred way to author the GraphQL in steps below; falls back to the
schema-grep script when unavailable.
- references/sdk-api.md — the new call API: /,
, typing, error-handling stances.
- references/caching.md — on-by-default cache + the two refresh
modes (/ vs per-call ).
- references/graphql-hand-authoring.md — schema lookup, read /
mutation templates, every platform guardrail (, pagination, limits,
semi-join, wrappers, error table…).
- references/rest-and-integration.md — ,
the supported-API allowlist, and the reactive/lifecycle integration patterns.
- references/migration.md — old callable code
→ new namespace. The only place the dead API appears as usable code.
The one-paragraph mental model
const sdk = await createDataSDK()
. Then
is a
namespace, not a
function:
sdk.graphql!.query({...})
for reads,
sdk.graphql!.mutate({...})
for writes. On WebApp,
every is cached by default (300s). HTTP 200 never
means success — always check
. Verify every entity and field against the
schema before you query it: one unverified field fails the
whole query at runtime, and
is too large to eyeball — look it up.
typescript
import { createDataSDK, gql } from "@salesforce/platform-sdk"; // gql tags the query string so codegen + eslint validate it
const sdk = await createDataSDK();
const result = await sdk.graphql!.query({ query: GET_ACCOUNTS, variables });
if (result.errors?.length) throw new Error(result.errors.map((e) => e.message).join("; "));
const rows = result.data?.uiapi?.query?.Account?.edges?.map((e) => e.node) ?? []; // unwrap edges/node; read field values via .value
Typed call params (
query<GetAccountsQuery, GetAccountsQueryVariables>
), the
type, and
(extracts a node type from a Connection for clean typing) all
live in
references/sdk-api.md.
This changed (breaking — PR #502). The previous callable
form and the
previous package name are
dead — the code above is the only correct form. If you encounter
the old API in existing code (or a stale
artifact), don't copy it; convert it per
Working on existing code.
is WebApp-only. The non-null assertion above is correct
only if the
bundle runs solely on WebApp. On other surfaces it can crash — decide before you write it.
See
Surfaces — vs guard below.
Surfaces — vs guard
runs on multiple surfaces, and
/ are genuinely
optional (typed
). Whether you may assert them with
depends entirely on
where the bundle runs — this is the one surface decision that turns into a
runtime crash if
you get it wrong, so make it explicitly before writing any
/
call:
| Surface(s) | | Write |
|---|
| WebApp only | always present | sdk.graphql!.query({...})
— is safe; every shipped WebApp consumer uses it |
| Mosaic / OpenAI / MCPApps (or any bundle that might run off-WebApp) | can be | guard first (if (!sdk.graphql) return …
), then call |
Rule of thumb:
if you cannot prove the bundle is WebApp-only, guard. A bare
that later ships to another surface throws
Cannot read properties of undefined
at runtime —
TypeScript won't catch it because
silences exactly that check (same applies to
).
The portable guard snippet lives in
references/sdk-api.md.
Step 0 — Route the task
| The task is… | Go to |
|---|
| Read records | Read workflow below |
| Create / update / delete records | Write workflow below |
| Object/field metadata, picklist values, related-list metadata, aggregations | Beyond record CRUD below |
| Data is stale / "add a refresh button" / "cache it longer" | Freshness & caching below |
| Something GraphQL can't express (Apex REST, file upload, Einstein) | references/rest-and-integration.md |
Migrating old sdk.graphql?.(query, vars)
code | Working on existing code below |
GraphQL covers far more than record reads and writes — prefer it for
anything the
namespace exposes (see
Beyond record CRUD). Reach for REST only when
the data genuinely lives outside
(Apex REST, file upload, Einstein) — see
references/rest-and-integration.md.
Preconditions — verify before writing any query
below is wherever this skill is installed (the directory this
loaded from). The schema-lookup script ships inside it. The script does
not hunt for
by walking up the tree — an ancestor schema can
belong to a different org and would validate fields against the wrong one. Resolve
the schema explicitly: run from the SFDX project root (where
lives),
or pass
/ set
. The script echoes the schema
it resolved (
[graphql-search] using schema: …
on stderr) — glance at it to confirm
you grounded against the right file.
| # | Requirement | Verify | If missing |
|---|
| 1 | installed | in the UI bundle dir | Tell user to install it; cannot proceed |
| 2 | A grounding tool resolves | Preferred: npx graphiti sf-gql-discover '{"org":"<alias>","mode":"list_objects"}'
from the UI bundle dir returns objects. Fallback: bash <skill-dir>/scripts/graphql-search.sh <Entity>
from the project root prints a lookup, not "schema.graphql not found" | No graphiti dep / org won't prime → use the script. Script can't find → pass , or from the UI bundle dir. (references/graphiti-cli.md covers CLI setup) |
| 3 | Target objects/fields deployed | The object appears in (or graphql-search.sh <Entity>
returns output) | Entity absent usually means it isn't deployed (or the cache/schema is stale). Refresh: npx graphiti sf-gql-connect '{"org":"<alias>","forceRefresh":true}'
(CLI) or (script). If still absent, deploy the metadata (the platform-metadata-deploy skill handles this) and assign the permission sets, then re-check |
If preconditions aren't met you may still scaffold components, routes, and layout — but
use empty arrays /
for data, mark query sites with
// TODO: add query after schema verification
, and add a plan item to return. Do
not
write GraphQL strings until the schema workflow is complete.
Read workflow
-
Look up the schema first — never guess a name. Preferred (graphiti): when the exact
API name is at all uncertain,
list before you describe —
npx graphiti sf-gql-discover '{"org":"<alias>","mode":"list_objects","search":"<intent>"}'
to find the real name, then
npx graphiti sf-gql-discover '{"org":"<alias>","mode":"describe_object","object":"<Entity>"}'
for exact field/type names, picklist values, filterable/sortable. An empty list or missing object
is a
fact about the org (wrong name or not deployed),
not a tool failure — re-list or
;
do not fall back to the script for this (see guardrail 2).
Fallback is
only for a CLI that genuinely can't run (no graphiti dep / org won't prime):
bash <skill-dir>/scripts/graphql-search.sh <Entity>
from the SFDX project root.
(Full rules:
references/graphql-hand-authoring.md.)
-
Write the query. Preferred — compile it with graphiti:
npx graphiti sf-gql-list '{"org":"<alias>","object":"<Entity>","fields":[…],"first":N}'
returns a
{ query, variables, types, warnings }
envelope with
,
/
,
, and
/
already applied. Confirm
(a non-empty
array means the object wasn't in the primed schema — the query is degraded; don't ship it), then
paste the
verbatim into inline
(simple) or an external
file (one operation
per file, imported with the bundler's
suffix —
import Q from "./q.graphql?raw"
brings the
file in as a plain string).
Fallback — hand-author: apply
to every
selectable
FLS-gated field — scalar leaf fields (
) and parent/child
relationships
and the fields inside them — but
NOT on
, on connection plumbing
(
,
, the connection field itself), or on
; the graphiti output leaves
those bare and is the canonical placement. Always set
, include
if it may
page. Either way, full mechanics and the primed-vs-degraded behavior:
references/graphiti-cli.md.
-
Generate types —
(from the UI bundle dir) →
src/api/graphql-operations-types.ts
.
-
Call with the generated types:
typescript
import type { GetAccountsQuery, GetAccountsQueryVariables } from "../graphql-operations-types";
const result = await sdk.graphql!.query<GetAccountsQuery, GetAccountsQueryVariables>({
query: GET_ACCOUNTS,
variables: { first: 20 },
// cacheControl, // optional — see Freshness & caching
});
-
Handle the result. +
are the initial snapshot;
/
are the reactive handles. Always check
before reading
:
typescript
if (result.errors?.length) throw new Error(result.errors.map((e) => e.message).join("; "));
const rows = result.data?.uiapi?.query?.Account?.edges?.map((e) => e.node) ?? [];
Defend consuming code with
/
(because
can omit fields). Error-handling
stances (strict / tolerant / discriminated) and
typing:
references/sdk-api.md.
Write workflow
1–3 as above (schema lookup → write the
mutation → codegen). To compile the mutation with
graphiti, use
/
/
— they emit the
uiapi { <Object>Create(input: $input) { Record {…} } }
shape; the
field tells you
the input shape. Details:
references/graphiti-cli.md.
4.
Call — note the option key is
, not
, and that
mutations are
never cached. The runtime
shape differs per operation —
values are
raw (never
-wrapped; that wrapper is a read-shape thing and breaks
writes) and nest under the
entity key:
typescript
// create — input.<Entity> holds the new field values
variables: { input: { Account: { Name: "Acme", Industry: "Technology" } } }
// update — sibling Id alongside the entity key
variables: { input: { Id: "001…", Account: { Industry: "Finance" } } }
// delete — Id only, no entity key (generic RecordDeleteInput)
variables: { input: { Id: "001…" } }
const { data, errors } = await sdk.graphql!.mutate<CreateAccountMutation, CreateAccountMutationVariables>({
mutation: CREATE_ACCOUNT,
variables: { input: { Account: { Name: "Acme" } } },
});
if (errors?.length) throw new Error(errors.map((e) => e.message).join("; "));
This is the
shape the spine owns; the CLI
-field interpretation is in
references/graphiti-cli.md and the GraphQL-document field constraints
(
/
,
references,
chaining) in
references/graphql-hand-authoring.md.
5.
Re-freshen affected reads. has no
. To update a live list
after a write, hold the
from your earlier
call (e.g.
) and call
await accountsResult.refresh()
(forced re-fetch, pushes
to subscribers) — note this is the read's handle, not anything
returns. See
Freshness & caching.
Mutation syntax is exacting: wrap under
uiapi(input: { allOrNone: ... })
, only
/
fields, Create/Update output is always
but
Delete has no
field — select only. Full template + chaining + constraints:
references/graphql-hand-authoring.md.
Beyond record CRUD
The
namespace is not just record reads/writes. Before reaching for REST, check
whether GraphQL already covers it — the same
call, different
sub-selection. The top-level
fields:
| Need | Use | Returns |
|---|
| Query records | uiapi { query { <Entity>(...) } }
| records (the Read workflow) |
| Counts / sums / grouped rollups without pulling rows | uiapi { aggregate { <Entity>(groupBy: …) } }
| aggregated buckets |
| Object/field metadata — labels, data types, /, record types | uiapi { objectInfos(apiNames: […]) }
| |
| Picklist values (per record type) | uiapi { objectInfos(objectInfoInputs: […]) { fields … on PicklistField { … } } }
| picklist values |
| Related-list metadata — display columns, ordering for a parent's related list | uiapi { relatedListByName(parentApiName, relatedListName) }
| |
Same rules as record reads: verify every type/field first,
where FLS applies, check
. Aggregations can be compiled with
npx graphiti sf-gql-aggregate
(pass
+
); object metadata / picklists / related lists are hand-authored —
templates:
references/graphql-hand-authoring.md.
Two related capabilities (the current-user record and layout delivery) need
confirmation against a current org schema before this skill documents a query shape —
tracked as a follow-up, not yet covered here.
Freshness & caching
Caching is ON by default on WebApp. Every
is cached with a
300-second TTL — no opt-in flag, no factory, no import subpath.
Do not
build your own cache (no React Query, SWR,
, or hand-rolled Map). The
cache is
shared across SDK instances by : the same query+variables from a
different
targeting the same host is a cache hit. Only non-empty,
error-free
is cached.
is never cached.
There are two distinct freshness tools — keep them separate:
- Per-call — a one-shot policy override on the query options bag
( / /
{ type: "max-age", maxAge: <seconds> }
). The type and
exact per-value behavior live in references/sdk-api.md.
Take as an optional param on the read function and expose each distinct policy as
a thin named export in the same data-layer file — a "call site" is a named export, not a new
React component. For getAccounts(first, after?, cacheControl?)
: export const refreshAccounts = () => getAccounts(20, undefined, "no-cache")
(and likewise → ,
→ { type: "max-age", maxAge: 10 }
). Keep the policy in the data layer.
- Reactive / — a stateful handle on a live :
fires on every later snapshot, re-fetches bypassing
the cache and pushes to subscribers. Shape in references/sdk-api.md;
subscription lifecycle (always unsubscribe on teardown) in references/caching.md.
| Want | Reach for |
|---|
| Freshness within ~5 min is fine | nothing (default cache) |
| This one read must bypass the cache (refresh button) | |
| Read only cached data, tolerate misses (offline-first) | cacheControl: "only-if-cached"
— a miss is expected, not an error: it surfaces a on (no network, no throw). Check , render empty state, do not throw and do not fall back to the network — that defeats offline-first. |
| Tighter/looser TTL for this query | cacheControl: { type: "max-age", maxAge: 60 }
( is in seconds) |
| Mounted component reflects updates over time | |
| Re-fetch now + notify all subscribers (e.g. after a mutation) | |
is fire-and-forget at call time;
/
is a live handle.
Different mechanisms, different jobs — don't conflate "refresh" with "no-cache". Full
behavior, the reactive-subscription lifecycle, and uncached-surface caveats:
references/caching.md.
Working on existing code (migration)
Only enter this path if the existing code actually uses the old API — i.e. it imports
or calls the callable
form. For any new
read/write, ignore migration entirely and use the
Read workflow /
Write workflow — those already show the
only correct API.
When you do have old code to convert, see references/migration.md for the
before→after diff (imports, query/mutate calls, optional-chaining → non-null assertion, codegen
type placement) and a checklist. The target API is exactly what the Read/Write workflows above
prescribe — migrating is just swapping the old form for that.
Platform guardrails — never regress these
These are Salesforce GraphQL platform behaviors, independent of the SDK. Violations cause
silent runtime failures. (Details + templates: references/graphql-hand-authoring.md.)
- HTTP 200 ≠ success — always parse ; the Promise resolves even on failure.
- Schema is the only source of truth — verify, never invent. Verify every
entity/field/type via graphiti (preferred) or
bash <skill-dir>/scripts/graphql-search.sh <Entity>
before use. Case-sensitive;
/; entity suffix (v60+). When graphiti is primed, a
"not found"/empty/ answer (including from
/, even when the message points at )
is a fact about the org — wrong name or undeployed/inaccessible metadata, not a tool
failure: fix the operation, or deploy the metadata (the platform-metadata-deploy skill)
- assign perms + refresh (
sf-gql-connect --forceRefresh
/ ). Do
not fall back to the script, hand-author around it, or guess a name — a guessed entity or
field silently fails the whole query at runtime; if lookups aren't converging, ask the user
rather than keep spiraling. and the codegen output
(src/api/graphql-operations-types.ts
) are read-only generated mirrors — never open or edit
them (honor any marker). Hand-adding a missing type satisfies codegen/lint
but grants no org access; it just hides the failure until runtime. Fall back to the script
only when the CLI can't run at all (no dep / ).
- on every FLS-gated field at each nesting level — scalar leaf fields plus each
parent/child relationship and the fields inside it (FLS fails the whole query otherwise, v65+).
Do NOT decorate , the connection plumbing (, , the connection field), or
— those are not FLS-gated and the graphiti output leaves them bare. Consume with
/. Placement rules: references/graphql-hand-authoring.md.
- Mutations wrap under
uiapi(input: { allOrNone: ... })
; set explicitly;
output excludes child/navigated-reference fields; the output field is literally named
(unrelated to the entity suffix in rule 2) — Delete → only. GA v66+.
- Explicit pagination — always set , because the server silently caps at 10 and
you'll drop rows with no error; forward-only (/, no /);
(v59+) raises the per-request ceiling for large sets (when set, must be 200–2000).
- SOQL governor limits apply — queries compile to SOQL, so the same governor
limits are inherited: ≤10 subqueries, ≤5 child→parent levels, ≤1 parent→child level,
≤2,000 records/subquery. Split into multiple requests if you'd exceed them.
- Field value wrappers — read the raw value via ; is the
server-formatted string for UI. When a field is both shown and operated on (currency,
dates, picklists), select both and so you don't reformat on the
client. Display-only fields can take just .
- Compound fields — filter/order on constituents (), not the wrapper ().
- Supported APIs only — GraphQL (), UI API REST, Apex REST, Connect REST,
Einstein LLM via . NOT: Enterprise SOQL , Aura-enabled Apex, Chatter
(use ). See references/rest-and-integration.md.
One SDK convention lives in the workflows, not this list (it's not a platform behavior):
always run
and use the generated types after writing an operation
(
Read workflow step 3). Also in the
Pre-flight checklist.
graphiti applies most of these for you. When you compile a query with
against an
object that's in the primed schema, rules 3 (
), 4 (mutation
output
envelope and entity-keyed input —
not , which you still add yourself),
5 (
/
), and 7 (
/
wrappers) come out already satisfied —
which is exactly why you
paste the verbatim rather than re-deriving it. Rules 1
(check
), 6 (governor limits), 8 (compound fields), and 9 (supported APIs) are
still on you. And the automation only fires when the object is primed: a non-empty
array means it isn't, and the emitted query is
degraded (bare fields, no guardrails) —
see
references/graphiti-cli.md.
Commands & layout
text
<skill-dir>/ ← wherever this skill is installed
└── scripts/graphql-search.sh ← schema lookup (ships with the skill)
<project-root>/ ← SFDX project root; run the script from here
├── schema.graphql ← generated mirror; grep target (never open or edit; script reads ./schema.graphql)
└── force-app/main/default/uiBundles/<app>/ ← UI bundle dir
├── package.json ← npm scripts
└── src/api/ ← queries, generated types, SDK calls
| Command | Run from | Purpose |
|---|
npx graphiti sf-gql-discover '{…}'
| UI bundle dir | Discover objects/fields against the live org (preferred grounding) |
npx graphiti sf-gql-<list|detail|aggregate|create|update|delete|raw> '{…}'
| UI bundle dir | Compile a guardrail-applied query/mutation (references/graphiti-cli.md) |
npx graphiti sf-gql-connect '{"org":"<alias>","forceRefresh":true}'
| UI bundle dir | Refresh graphiti's schema cache after a deploy |
bash <skill-dir>/scripts/graphql-search.sh <Entity>
| project root (or pass ; no tree walk-up) | Schema lookup fallback (grep over local ) |
| UI bundle dir | Fetch/refresh (for the fallback script) |
| UI bundle dir | Generate operation types |
| UI bundle dir | Lint (catches schema violations) |
Pre-flight checklist