8.6 KiB
The Data SDK call API (@salesforce/platform-sdk)
The query/mutate namespace, typing, and error handling. For caching and the reactive
refresh modes see caching.md; for old→new conversion see migration.md.
Import
import { createDataSDK, gql, type CacheControl, type NodeOfConnection } from "@salesforce/platform-sdk";
createDataSDK, gql, CacheControl, and NodeOfConnection all come from
@salesforce/platform-sdk. The old @salesforce/sdk-data package name is dead — any
@salesforce/sdk-data string you see in the repo is a stale dist/ build artifact, not the
canonical import.
sdk.graphql is a NAMESPACE, not a callable
The previous SDK exposed sdk.graphql as a callable: await sdk.graphql?.(query, vars).
That form is gone. sdk.graphql is now a namespace with two methods:
const sdk = await createDataSDK();
// QUERY — reactive, cached on WebApp
const result = await sdk.graphql!.query<TData, TVariables>({
query: MY_QUERY, // gql-tagged string (the `query` key)
variables, // optional
operationName, // optional
cacheControl, // optional — see caching.md
});
// MUTATE — request/response, never cached
const { data, errors } = await sdk.graphql!.mutate<TData, TVariables>({
mutation: MY_MUTATION, // NOTE: the key is `mutation`, not `query`
variables,
operationName,
});
Type signatures (from core/data.ts):
query<T, V>(options: QueryOptions<V>): Promise<QueryResult<T>>;
mutate<T, V>(options: MutateOptions<V>): Promise<MutationResult<T>>;
sdk.graphql! vs guard
sdk.graphql may still be undefined on surfaces that do not support data operations, so it
is typed graphql?: DataSDKGraphQL. Real consumer code asserts it is present after
createDataSDK() with the non-null assertion:
const result = await sdk.graphql!.query<...>({ query, variables });
This replaces the old optional-call sdk.graphql?.(...). Do not reach for the dead
callable form. Which of ! vs a guard you use is a surface decision (the spine's
Surfaces table is the routing): ! only if the
bundle is WebApp-exclusive; otherwise guard, because a bare sdk.graphql! that later ships to
another surface throws Cannot read properties of undefined at runtime — TypeScript won't catch
it because ! silences exactly that check.
// `!` form — WebApp-only bundles; every shipped WebApp consumer uses it.
const result = await sdk.graphql!.query<...>({ query, variables });
// Portable guard form — safe on every surface. Use when the bundle is not WebApp-exclusive.
if (!sdk.graphql) {
// No data SDK on this surface — degrade gracefully (render empty, throw, or feature-flag off).
return;
}
const result = await sdk.graphql.query<...>({ query, variables });
(The same !-vs-guard call applies to sdk.fetch!.)
QueryResult<T> — the reactive query handle
query() resolves to a QueryResult<T>, which IS a snapshot AND carries two reactive methods:
interface QuerySnapshot<T> { data: T | undefined; errors?: GraphQLError[]; }
type QuerySubscriber<T> = (snapshot: QuerySnapshot<T>) => void;
type Unsubscribe = () => void;
interface QueryResult<T> extends QuerySnapshot<T> {
subscribe(cb: QuerySubscriber<T>): Unsubscribe;
refresh(): Promise<void>;
}
result.data/result.errors— the initial snapshot at await-time (cached value on cached surfaces, else the network response).result.subscribe(cb)— register a callback for every subsequent snapshot (a cache update, a stale-while-revalidate refill, or an explicitrefresh()). Returns anUnsubscribe. Eachsubscribeis independent — unsubscribing one leaves the others live — and it does not fire on registration, only on later snapshots.result.refresh()— re-issue the request bypassing the cache, write the fresh result back, and push the new snapshot to all current subscribers. ReturnsPromise<void>.
When to reach for subscribe/refresh over the default cache, plus the uncached-surface
caveats, is a strategy call — see caching.md.
cacheControl — the per-call cache policy
cacheControl is an optional field on the query options bag. It overrides the default cache
behavior for one call and does not change the cache key, so a "no-cache" call and a
default call read/write the same cache slot. The type:
type CacheControlShorthand = "no-cache" | "only-if-cached";
interface CacheControlMaxAge { type: "max-age"; maxAge: number; }
type CacheControl = CacheControlShorthand | CacheControlMaxAge;
cacheControl |
Behavior |
|---|---|
| (omitted) | Default — return the cached entry if fresh (300s TTL), else fetch and write back. |
"no-cache" |
Skip the cache read, always hit the network. Still writes the response back for later default callers. |
"only-if-cached" |
Read from cache only. Hit → return; miss → DataNotFoundError on result.errors (no network, no throw). |
{ type: "max-age", maxAge: <seconds> } |
Custom TTL instead of 300s. maxAge: 0 = written but immediately stale; invalid values silently fall back to 300s. |
An only-if-cached miss is not an exception — the Promise resolves and the miss surfaces on
result.errors as a DataNotFoundError. This is offline-first: a miss is expected, not an
error. Check result.errors, render an empty state, and do not fall back to a network read
— a fallback defeats the point of only-if-cached:
const result = await sdk.graphql!.query({ query: GET_ACCOUNTS, variables, cacheControl: "only-if-cached" });
if (result.errors?.length) {
// cold cache — render the empty state. Do NOT fall back to the network; that defeats offline-first.
return renderEmptyState();
}
Which policy fits which goal (force-refresh button, offline-first, fast-changing data) is a strategy call — see the decision matrix in caching.md.
MutationResult<T> — one-shot, NO subscribe/refresh
interface MutationResult<T> { data: T | undefined; errors?: GraphQLError[]; }
Mutations are request/response. The type deliberately has no subscribe and no
refresh — mutating on subscribe is incoherent, and re-running a mutation on refresh is
dangerous. To refresh data after a mutation, hold a query result and call result.refresh()
(see caching.md).
HTTP 200 ≠ success — always read errors
The Promise resolves even for GraphQL/parse errors; they surface on result.errors, never as a
thrown exception from query()/mutate(). Both shipped consumers gate on errors first:
// accounts.ts pattern
if (result.errors?.length) {
throw new Error(result.errors.map((e) => e.message).join("; "));
}
// graphqlClient.ts (canonical scaffold) — strict wrapper
export async function executeGraphQL<TData, TVariables>(
query: string,
variables?: TVariables,
): Promise<TData> {
const sdk = await createDataSDK();
const result = await sdk.graphql!.query<TData, TVariables>({ query, variables });
if (result.errors?.length) {
throw new Error(`GraphQL Error: ${result.errors.map((e) => e.message).join("; ")}`);
}
if (result.data == null) {
throw new Error("GraphQL response data is null");
}
return result.data;
}
Three error-handling stances (all read result.errors):
// Strict — any errors = failure
if (result.errors?.length) throw new Error(result.errors.map((e) => e.message).join("; "));
// Tolerant — log, use whatever data came back (partial success)
if (result.errors?.length) console.warn("GraphQL partial errors:", result.errors);
// Discriminated — fail only when NO data returned
if (!result.data && result.errors?.length) {
throw new Error(result.errors.map((e) => e.message).join("; "));
}
Generated types on the call
After npm run graphql:codegen, import the generated <Op>Query / <Op>QueryVariables types
and pass them as the type parameters. The call shape changed — types now go on .query(), not
the old callable:
import type { GetAccountsQuery, GetAccountsQueryVariables } from "../graphql-operations-types";
const result = await sdk.graphql!.query<GetAccountsQuery, GetAccountsQueryVariables>({
query: GET_ACCOUNTS,
variables,
});
NodeOfConnection<T> extracts a node type from a Connection for cleaner typing:
import { type NodeOfConnection } from "@salesforce/platform-sdk";
type AccountNode = NodeOfConnection<GetAccountsQuery["uiapi"]["query"]["Account"]>;