---
name: experience-ui-bundle-salesforce-data-access
description: "MUST activate whenever a uiBundles/*/src/ project reads, writes, or displays Salesforce data — INCLUDING building a page, list, table, card grid, dashboard, or form that shows, filters, counts, or edits records of any object (e.g. Property__c, Account, Case), even when the prompt names only the UI or the object and never says query, GraphQL, or SDK. Records behind such a component come from Salesforce, so use this ALONGSIDE experience-ui-bundle-frontend-generate: that skill styles the component, this one wires its data. Also triggers on @salesforce/platform-sdk imports, sdk.graphql.query / mutate / sdk.fetch calls, *.graphql files, or stale data needing force-refresh. New read/write work uses the current @salesforce/platform-sdk API; migrate only EXISTING old @salesforce/sdk-data callable code. Not for pure styling/layout with no records, app shell, file upload, or auth/search scaffolding. DO NOT TRIGGER for OAuth, object/field schema changes, Bulk/Tooling/Metadata API, or declarative automation."
metadata:
  version: "2.2"
  domains: ["Experience", "Platform"]
  minApiVersion: "66.0"
  relatedSkills:
    - "experience-ui-bundle-frontend-generate"
    - "platform-metadata-deploy"
  cliTools:
    - tool: ["node"]
      semver: ">=18.0.0"
    - tool: ["npm"]
      semver: ">=9.0.0"
    - tool: ["npx"]
      semver: ">=9.0.0"
    - tool: ["sf"]
      semver: ">=2.0.0"
---

# Salesforce Data Access (UI bundles)

All Salesforce data access in a UI bundle goes through the **`@salesforce/platform-sdk`**
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](references/graphiti-cli.md)** — the **`graphiti` CLI** (`sf-gql-*`
  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](references/sdk-api.md)** — `query`/`mutate` call surface + generated-type
  placement; the behavior nuance (surfaces, error stances, `QueryResult`) grounds on **tier-2b**.
- **[references/caching.md](references/caching.md)** — the on-by-default cache + two refresh modes;
  behavior grounds on **tier-2b** `docs/data/` when installed, with the full version-stamped fallback here.
- **[references/graphql-hand-authoring.md](references/graphql-hand-authoring.md)** — schema lookup, read /
  mutation templates, every platform guardrail (`@optional`, pagination, limits,
  semi-join, wrappers, error table…).
- **[references/rest-and-integration.md](references/rest-and-integration.md)** — `sdk.fetch`,
  the supported-API allowlist, and the reactive/lifecycle integration patterns.
- **[references/migration.md](references/migration.md)** — old `@salesforce/sdk-data` callable code
  → new namespace. The **only** place the dead API appears as usable code.

## The one-paragraph mental model

`const sdk = await createDataSDK()`. Then `sdk.graphql` is a **namespace**, not a
function: **`sdk.graphql!.query({...})`** for reads, **`sdk.graphql!.mutate({...})`**
for writes. On WebApp, **every `query()` is cached by default** (300s). HTTP 200 never
means success — always check `result.errors`. Verify every entity and field against the
schema before you query it: one unverified field fails the *whole* query at runtime, and
`schema.graphql` 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 `CacheControl`
type, and `NodeOfConnection<T>` (extracts a node type from a Connection for clean typing) all
live in [references/sdk-api.md](references/sdk-api.md).

> **This changed (breaking — PR #502).** The previous callable `sdk.graphql(...)` 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 `dist/` artifact), don't copy it; convert it per
> [Working on existing code](#working-on-existing-code-migration).
>
> **`sdk.graphql!` 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](#surfaces--sdkgraphql-vs-guard)** below.

---

## Ground the SDK contract on the installed types (tier-2a)

`@salesforce/platform-sdk` force-publishes on a shared version line and moves
fast. This SKILL's prose is a point-in-time snapshot of the call contract; the
**installed declarations are authoritative for the version you actually have**.
Before writing any `query`/`mutate`, read the installed types and let them win:

- `node_modules/@salesforce/platform-sdk/dist/core/data.d.ts` — `query`/`mutate`
  signatures, `QueryResult` (has `subscribe`/`refresh`) vs `MutationResult` (has
  neither, by design), the `CacheControl` union, the default TTL.
- `node_modules/@salesforce/platform-sdk/dist/data/index.d.ts` — `createDataSDK`,
  `gql`, `NodeOfConnection`.

**Precedence — installed `.d.ts` beats this SKILL's prose.** If a signature,
type, or default here disagrees with the installed declaration, follow the
declaration and note the drift; do not "correct" the types to match the prose.

**Grounding ladder** (one model, two axes):

| Tier | Grounds | Answers | Via |
|---|---|---|---|
| tier-1 | GraphQL **schema** | *what data exists* | graphiti / `graphql-search.sh` (Precondition #2) |
| tier-2a | SDK **contract** | *how you call it* | the installed `.d.ts` above |
| tier-2b | SDK **behavior** | *how it behaves* | the installed `docs/data/` folder (below) |
| spine | this SKILL.md | workflow + guardrails that orchestrate all three; the fallback when a tier can't ground |

**Fallback when the `.d.ts` is absent** — the package **is installed** but ships
no declarations (a stale or types-stripped build artifact). Then use this SKILL's
prose as best-effort. This fallback does **not** cover a missing package: if
`@salesforce/platform-sdk` isn't installed, stop and install it (Precondition #1)
— do not author calls from prose against a dependency you don't have.

---

## Ground the SDK behavior on the installed docs (tier-2b)

The same package ships an authored **behavior** guide beside its types:
`node_modules/@salesforce/platform-sdk/docs/data/` (numbered files, read them in order).
Tier-2a's `.d.ts` fixes the call *contract*; this folder is authoritative for the *behavior* the
contract doesn't spell out — the caching model, the surface `!`-vs-guard decision, error-handling
stances, the migration mindset. **Read it before choosing a caching policy, a surface assertion, or
an error stance, and let it win** — same precedence as tier-2a (the installed source beats this
prose; when present it's the fuller, version-current copy).

**Fallback when the folder is absent** (older SDK, or a types-only build): this SKILL keeps a thin
per-behavior fallback — below and in each section — sized only to keep you moving; act on it. As
with tier-2a, a missing *package* is different: if `@salesforce/platform-sdk` isn't installed, stop
and install it (Precondition #1).

---

## Surfaces — `sdk.graphql!` vs guard

`sdk.graphql` / `sdk.fetch` are genuinely optional (typed `graphql?: …`), and whether you may
assert them with `!` is a *runtime-crash* decision — make it before writing any `query`/`mutate`.
**Fallback rule: WebApp-only bundle → `sdk.graphql!` is safe; any bundle that might run
off-WebApp (Mosaic / OpenAI / MCPApps) → guard first (`if (!sdk.graphql) return …`), then call.**
If you cannot prove WebApp-only, guard — a bare `!` that later ships elsewhere throws
`Cannot read properties of undefined` and TypeScript won't catch it (same for `sdk.fetch!`).

The surface matrix, the portable guard snippet, and the full reasoning ground on **tier-2b**
`docs/data/` (fallback above); the guard snippet is also in
[references/sdk-api.md](references/sdk-api.md#sdkgraphql-vs-guard).

---

## Step 0 — Route the task

| The task is… | Go to |
|---|---|
| Read records | **[Read workflow](#read-workflow)** below |
| Create / update / delete records | **[Write workflow](#write-workflow)** below |
| Object/field metadata, picklist values, related-list metadata, aggregations | **[Beyond record CRUD](#beyond-record-crud)** below |
| Data is stale / "add a refresh button" / "cache it longer" | **[Freshness & caching](#freshness--caching)** below |
| Something GraphQL can't express (Apex REST, file upload, Einstein) | [references/rest-and-integration.md](references/rest-and-integration.md) |
| Migrating old `sdk.graphql?.(query, vars)` code | **[Working on existing code](#working-on-existing-code-migration)** below |

GraphQL covers far more than record reads and writes — prefer it for **anything the `uiapi`
namespace exposes** (see [Beyond record CRUD](#beyond-record-crud)). Reach for REST only when
the data genuinely lives outside `uiapi` (Apex REST, file upload, Einstein) — see
[references/rest-and-integration.md](references/rest-and-integration.md).

---

## Preconditions — verify before writing any query

`<skill-dir>` below is wherever this skill is installed (the directory this
`SKILL.md` loaded from). The schema-lookup script ships inside it. The script does
**not** hunt for `schema.graphql` 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 `schema.graphql` lives),
or pass `--schema <path>` / set `GRAPHQL_SCHEMA=<path>`. 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 | `@salesforce/platform-sdk` installed **and its contract + behavior docs read** | `package.json` in the UI bundle dir lists it; then read `dist/core/data.d.ts` + `dist/data/index.d.ts` ([tier-2a](#ground-the-sdk-contract-on-the-installed-types-tier-2a)) **and** the `docs/data/` folder ([tier-2b](#ground-the-sdk-behavior-on-the-installed-docs-tier-2b)), and let them win over this SKILL's prose | Not installed → tell user to install it; cannot proceed. Installed but `.d.ts` / `docs/` absent (stale or types-only artifact) → use prose fallback |
| 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 `schema.graphql` → pass `--schema <path>`, or `npm run graphql:schema` from the UI bundle dir. ([references/graphiti-cli.md](references/graphiti-cli.md) covers CLI setup) |
| 3 | Target objects/fields deployed | The object appears in `sf-gql-discover` (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 `npm run graphql:schema` (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 / `null` 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

1. **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
   `forceRefresh`; **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](references/graphql-hand-authoring.md).)
2. **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 `@optional`, `value`/`displayValue`,
   `edges/node`, and `first:`/`pageInfo` **already applied**. Confirm `warnings: []` (a non-empty
   array means the object wasn't in the primed schema — the query is degraded; don't ship it), then
   paste the `query` verbatim into inline `gql` (simple) or an external `.graphql` file (one operation
   per file, imported with the bundler's `?raw` suffix — `import Q from "./q.graphql?raw"` brings the
   file in as a plain string). **Fallback — hand-author:** apply `@optional` to every **selectable
   FLS-gated field** — scalar leaf fields (`Name @optional { value }`) and parent/child
   relationships *and* the fields inside them — but **NOT** on `Id`, on connection plumbing
   (`edges`, `node`, the connection field itself), or on `pageInfo`; the graphiti output leaves
   those bare and is the canonical placement. Always set `first:`, include `pageInfo` if it may
   page. Either way, full mechanics and the primed-vs-degraded behavior:
   [references/graphiti-cli.md](references/graphiti-cli.md).
3. **Generate types** — `npm run graphql:codegen` (from the UI bundle dir) →
   `src/api/graphql-operations-types.ts`.
4. **Call `query()`** 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
   });
   ```
5. **Handle the result.** `result.data` + `result.errors` are the initial snapshot;
   `result.subscribe` / `result.refresh` are the reactive handles. Always check
   `errors` before reading `data`:

   ```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 `@optional` can omit fields). Error-handling
stances (strict / tolerant / discriminated) ground on **tier-2b** `docs/data/` (fallback:
guardrail #1 — always check `result.errors`); `NodeOfConnection` typing in [references/sdk-api.md](references/sdk-api.md).

---

## Write workflow

1–3 as above (schema lookup → write the **mutation** → codegen). To compile the mutation with
graphiti, use `sf-gql-create` / `sf-gql-update` / `sf-gql-delete` — they emit the
`uiapi { <Object>Create(input: $input) { Record {…} } }` shape; the `types` field tells you
the input shape. Details: [references/graphiti-cli.md](references/graphiti-cli.md).
4. **Call `mutate()`** — note the option key is **`mutation`**, not `query`, and that
   mutations are **never cached**. The runtime `variables` shape differs per operation —
   values are **raw** (never `{value}`-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 **`variables` shape** the spine owns; the CLI `types`-field interpretation is in
   [references/graphiti-cli.md](references/graphiti-cli.md) and the GraphQL-document field constraints
   (`createable`/`updateable`, `ApiName` references, `@{alias}` chaining) in
   [references/graphql-hand-authoring.md](references/graphql-hand-authoring.md).
5. **Re-freshen affected reads.** `mutate()` has no `refresh`. To update a live list
   after a write, hold the `QueryResult` from your earlier `query()` call (e.g.
   `accountsResult`) and call `await accountsResult.refresh()` (forced re-fetch, pushes
   to subscribers) — note this is the read's handle, not anything `mutate()` returns. See
   **[Freshness & caching](#freshness--caching)**.

Mutation syntax is exacting: wrap under `uiapi(input: { allOrNone: ... })`, only
`createable`/`updateable` fields, Create/Update output is always `Record` but **Delete has no
`Record` field — select `Id` only**. Full template + chaining + constraints:
[references/graphql-hand-authoring.md](references/graphql-hand-authoring.md).

---

## Beyond record CRUD

The `uiapi` namespace is not just record reads/writes. Before reaching for REST, check
whether GraphQL already covers it — the same `sdk.graphql!.query()` call, different
sub-selection. The top-level `uiapi` fields:

| Need | Use | Returns |
|---|---|---|
| Query records | `uiapi { query { <Entity>(...) } }` | records (the [Read workflow](#read-workflow)) |
| Counts / sums / grouped rollups without pulling rows | `uiapi { aggregate { <Entity>(groupBy: …) } }` | aggregated buckets |
| Object/field metadata — labels, data types, `createable`/`updateable`, record types | `uiapi { objectInfos(apiNames: […]) }` | `ObjectInfo[]` |
| 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) }` | `RelatedListInfo` |

Same rules as record reads: verify every type/field first, `@optional` where FLS applies, check
`result.errors`. Aggregations can be compiled with `npx graphiti sf-gql-aggregate` (pass
`groupBy` + `aggregations`); object metadata / picklists / related lists are hand-authored —
templates: [references/graphql-hand-authoring.md](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

Ground the cache model on **tier-2b** `docs/data/` — cache-key mechanics, what-gets-cached,
the shared-by-`baseUrl` details, uncached-surface semantics, and th