salesforce-reports-mcp 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "salesforce-reports",
3
+ "owner": { "name": "nelsben" },
4
+ "description": "Salesforce reports for Claude.",
5
+ "plugins": [
6
+ {
7
+ "name": "salesforce-reports",
8
+ "source": "./",
9
+ "description": "Explore, design and deploy Salesforce reports with Claude.",
10
+ "category": "productivity"
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "salesforce-reports",
3
+ "version": "0.2.0",
4
+ "description": "Explore, design and deploy Salesforce reports with Claude: three skills and an MCP server over the Reports and Dashboards REST API.",
5
+ "author": { "name": "nelsben" },
6
+ "homepage": "https://github.com/nelsben/salesforce-reports-mcp",
7
+ "repository": "https://github.com/nelsben/salesforce-reports-mcp",
8
+ "license": "MIT",
9
+ "keywords": ["salesforce", "reports", "mcp", "analytics"],
10
+ "skills": "./skills",
11
+ "mcpServers": {
12
+ "salesforce-reports": {
13
+ "command": "npx",
14
+ "args": ["-y", "salesforce-reports-mcp"]
15
+ }
16
+ }
17
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nelsben
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,164 @@
1
- # Temporary Holding Version
1
+ # salesforce-reports-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Claude finds, builds, runs and saves Salesforce reports. You ask a question in plain words and get the answer, with the report behind it.
4
+
5
+ This repo is working software, and a demonstration. It takes one useful job, reports, and delivers it to Claude three ways, from a server on your laptop to a feature that lives inside Salesforce. Every design choice is written down with what it costs. Built by [Ben Nelsen](https://github.com/nelsben).
6
+
7
+ > **You:** List the report types I can build on, then build me an opportunity report grouped by stage without saving it.
8
+ >
9
+ > **Claude:** The org has 156 report types; here they are by category. Opportunities by stage: 8 open deals, $3,091,000. Negotiation/Review holds 3 of them and 39% of the value. Salesforce applied its default filters: close date this fiscal quarter, probability above 0. Nothing was saved. Want it across all close dates, or saved to your Private Reports?
10
+ >
11
+ > *claude.ai, through the MCP server hosted by Salesforce, signed in as the user ([rehearsal](docs/rehearsals/runner-hosted.md)).*
12
+
13
+ ## What this repo shows
14
+
15
+ | Claim | Where to look | Evidence you can check |
16
+ | --- | --- | --- |
17
+ | **1. Three ways to connect Salesforce and Claude**, each with its own trade-offs | [Three ways to connect](#three-ways-to-connect) | One tool contract and result shape on every door (12 tools locally, 11 in the org); a [parity check](docs/decisions/0010-verification-by-parity.md) runs the same calls through two doors against a real org and compares them |
18
+ | **2. A useful feature added to Salesforce itself**, packaged the way an admin expects | [A feature inside Salesforce](#a-feature-inside-salesforce) | Three package directories; layered Apex with a CI test that fails if the layers leak; 65 Apex tests at 98% coverage |
19
+ | **3. Security, authentication and integration** done deliberately, per door | [Security and identity](#security-and-identity) | A different identity per door, a per-user JWT named credential with no stored secret, writes behind a custom permission, and a test that keeps org values out of the repo |
20
+ | **4. Product and architecture judgment**, shown in the work | [How it was built](#how-it-was-built) | Personas and [experiences](docs/experiences.md), [6 probes](docs/probes/) before building, [14 decision records](docs/decisions/README.md), [live findings](docs/design.md), [7 rehearsals](docs/rehearsals/README.md) |
21
+
22
+ ## The product
23
+
24
+ **The problem.** Reports are how a Salesforce org answers questions, and they are hard to use. A seller wants a number, not Report Builder. An analyst wants to try three versions of a report before saving one. An admin wants a report in another org or in git. As of October 2026, Salesforce's own Claude skills and its standard hosted MCP servers had no report tools, and the closest open-source servers could run or create a report, but none could change, clone or combine one, or run one that was never saved.
25
+
26
+ **Three experiences.** Each is a Claude skill, and Claude picks the right one from what you say.
27
+
28
+ | Skill | For | What Claude does |
29
+ | --- | --- | --- |
30
+ | [report-runner](skills/report-runner/SKILL.md) | A seller or manager who wants an answer | Finds the report, says in plain words what it counts, runs it, leads with the answer, offers the next slice. Never saves. |
31
+ | [report-designer](skills/report-designer/SKILL.md) | Someone who wants a report changed or made | Drafts the report in your words, previews it on real data, changes it until you like it, saves it in one step when you ask. |
32
+ | [report-deploy](skills/report-deploy/SKILL.md) | An admin moving a report between orgs or into git | Retrieves the report's metadata, checks it with a dry run, deploys only after you say yes. |
33
+
34
+ > **You:** Start from my Pipeline by Stage report. Add Next Step and group it by owner, then save it as Pipeline by Owner.
35
+ >
36
+ > **Claude:** Preview, with 8 deals under two owners. Saved as a new report in your Private Reports; the original is untouched. Link.
37
+
38
+ **Answers you can trust.** Every run returns the same compact shape: the filters that were applied, including the ones Salesforce adds on its own, the groups and totals, and notes that say when rows were cut or Salesforce ignored part of the request. The [experiences page](docs/experiences.md) has the personas and the facts that shaped them.
39
+
40
+ **A report card in the chat.** In Claude Desktop, a report run on the local server also draws as a card the way Salesforce draws it: the title, the filters, the groups with totals, a link to open it in Salesforce and, for an unsaved report, a button that asks Claude to save it. It is an MCP App that loads nothing from the network. Clients that show only text, such as Claude Code, get the same result as text ([decision 0013](docs/decisions/0013-report-view-as-an-mcp-app.md)).
41
+
42
+ ![The report card for an unsaved opportunity report grouped by stage, drawn in light, then dark, then at phone width](docs/assets/report-view.gif)
43
+
44
+ *Drawn by the [render test](docs/decisions/0014-render-tests-through-a-test-host.md)'s host page from anonymised test data.*
45
+
46
+ ## Three ways to connect
47
+
48
+ The same tools reach Claude three ways. They differ in where the code runs, who hosts it and how the user signs in.
49
+
50
+ ```mermaid
51
+ flowchart LR
52
+ subgraph Laptop["Your machine"]
53
+ CC["Claude Code / Desktop"]
54
+ LS["Local MCP server<br/>(Node)"]
55
+ CLI["sf CLI"]
56
+ end
57
+ subgraph Cloud["claude.ai"]
58
+ CAI["Claude"]
59
+ end
60
+ subgraph SF["Salesforce org"]
61
+ GW["Hosted MCP<br/>(Salesforce gateway)"]
62
+ REST["ReportsApi<br/>(Apex REST)"]
63
+ CORE["Reports service<br/>(Apex)"]
64
+ API["Reports REST API"]
65
+ end
66
+ CC -- "1. stdio" --> LS -- "REST" --> API
67
+ CC -- "2. skills + CLI" --> CLI -- "Apex REST" --> REST
68
+ CAI -- "3. MCP over OAuth" --> GW --> REST
69
+ REST --> CORE --> API
70
+ ```
71
+
72
+ | | 1. Local MCP server | 2. In-org REST door with skills | 3. Hosted MCP door |
73
+ | --- | --- | --- | --- |
74
+ | **Direction** | Claude reaches into Salesforce | Salesforce offers a feature; Claude calls it | Salesforce serves Claude directly |
75
+ | **Runs** | Node on your machine | Apex in the org | Salesforce's MCP gateway, then the same Apex |
76
+ | **Installed in the org** | Nothing | `force-app` | `force-app`, `mcp-app`, optionally `callout-app` |
77
+ | **Signs in with** | Your `sf` CLI session or a token | Your own Salesforce session | OAuth with PKCE through an External Client App |
78
+ | **Clients** | Claude Code, Claude Desktop, any MCP client | Claude Code and any agent with a shell; Flow, Agentforce, any HTTP client | claude.ai and any remote MCP client, with nothing to host |
79
+ | **Tools** | All 12, delete only with a flag | 11, no delete | 11, no delete |
80
+ | **Choose it when** | One person, no org change allowed | An admin wants the feature in the org, used from scripts and agents | A team wants Claude in the browser, governed by the org |
81
+
82
+ Each door gets the same tool names, the same input and the same result ([decision 0001](docs/decisions/0001-one-contract-three-doors.md)). The skills bind to tool names, not to a door, so one skill works over all three. [docs/architecture.md](docs/architecture.md) has the sequence diagrams.
83
+
84
+ ## A feature inside Salesforce
85
+
86
+ The in-org doors are a Salesforce feature an admin installs, not a script. They ship as three package directories in [salesforce/](salesforce/README.md) ([decision 0009](docs/decisions/0009-two-shipping-units.md)):
87
+
88
+ | Package | Holds | Needs |
89
+ | --- | --- | --- |
90
+ | `force-app` | The Apex service, its tests, two permission sets and a custom permission | Nothing else; complete on its own |
91
+ | `mcp-app` | The API Catalog entry, the MCP server definition and the External Client App Claude signs in through. No code. | `force-app` |
92
+ | `callout-app` | The per-user named credential that lets the hosted door call the org's own API as the user. No code. | `force-app`; used by the hosted door |
93
+
94
+ The Apex is layered as ports and adapters ([decision 0003](docs/decisions/0003-one-apex-class-behind-both-in-org-doors.md)). One service does the work; the REST class is only an adapter, and [a test](test/apex-layers.test.ts) fails CI if the core ever names it or sets an HTTP status. Reads use `Reports.ReportManager` inside the org; only what Apex cannot do (list report types, run an unsaved report, save) calls the org's own REST API ([decision 0004](docs/decisions/0004-reportmanager-first-callout-for-the-rest.md)). There is no delete in the org ([decision 0006](docs/decisions/0006-no-delete-in-the-org.md)).
95
+
96
+ ## Security and identity
97
+
98
+ Each door signs in differently, and each is least privilege by default.
99
+
100
+ | Door | Identity | What it can do |
101
+ | --- | --- | --- |
102
+ | Local | The user's `sf` CLI session, refetched on expiry; never written anywhere | Whatever the user can do. `--read-only` removes every write tool; `delete_report` exists only with `--allow-delete`. |
103
+ | REST | The caller's own Salesforce session | Reads with `Salesforce_Reports`. Saves only with `Salesforce_Reports_Write`, checked in the service, not by hiding tools ([decision 0005](docs/decisions/0005-saves-behind-a-custom-permission.md)). |
104
+ | Hosted MCP | OAuth with PKCE through the External Client App `SalesforceReportsMcp`, scopes `mcp_api` and `refresh_token` only | The same permission sets. For the calls that need the org's REST API, a per-user JWT named credential mints a token for the signed-in user. |
105
+
106
+ Sharing, folder access and field-level security apply on every door, because every call runs as the user.
107
+
108
+ **The hard part, and how it was solved.** Under a hosted MCP sign-in, Apex has no session, and asking for one throws an exception no `catch` block can stop. The first version leaked a raw platform error to claude.ai. The fix came in two steps, each with a decision record:
109
+
110
+ 1. The hosted door got its own route, so the code knows which door called before it asks for a session, and answers a clear `CALLOUT_SESSION` instead ([decision 0007](docs/decisions/0007-callout-identity-under-hosted-mcp.md)).
111
+ 2. A probe showed that a [per-user JWT named credential](docs/probes/v0.2-phase3c.md) lets the org mint a token for the running user from a certificate it made itself. No secret is stored, no user clicks anything, and the connector's own app keeps its narrow scopes ([decision 0012](docs/decisions/0012-per-user-jwt-named-credential.md)). `npm run callout:renew` renews the certificate.
112
+
113
+ No token, consumer key, org address or certificate reaches the repo, logs or error messages. The tracked metadata holds placeholders that setup scripts fill at deploy time, and a test enforces it.
114
+
115
+ ## How it was built
116
+
117
+ The method was the same for every feature: probe the platform, decide in writing, build, then check against a real org.
118
+
119
+ | Step | What it produced |
120
+ | --- | --- |
121
+ | **Probe** before building | [6 probe write-ups](docs/probes/): can a hosted MCP server wrap Apex, can Apex call its own org's API, how big a result fits in the heap, which identity works under a hosted sign-in |
122
+ | **Decide** in writing | [14 decision records](docs/decisions/README.md), each with what it costs and what was turned down |
123
+ | **Build** to one contract | [design.md](docs/design.md): the input and output shapes, and the live findings where Salesforce did not behave as documented |
124
+ | **Verify** against a real org | 161 unit and server tests, 8 render tests that draw the report card in a browser, 65 Apex tests, a live end-to-end check that leaves no report behind, and a parity check across doors |
125
+ | **Rehearse** the experience | [7 rehearsals](docs/rehearsals/README.md): real Claude runs of each skill on each door, with what went wrong and what changed |
126
+
127
+ ## Get started
128
+
129
+ | You want | Start here |
130
+ | --- | --- |
131
+ | Claude Code or Desktop on your own org, nothing installed in Salesforce | [Local MCP server](docs/install.md#the-local-mcp-server) |
132
+ | The feature inside your org, called from the CLI, scripts or agents | [The REST door](docs/install.md#the-rest-door) |
133
+ | Claude in the browser, signed in through Salesforce | [The MCP door](docs/install.md#the-mcp-door), then [the callout tools](docs/install.md#the-callout-tools-on-the-mcp-door) |
134
+
135
+ The quickest try is the Claude Code plugin, which bundles the skills and the local server:
136
+
137
+ ```
138
+ claude plugin marketplace add nelsben/salesforce-reports-mcp
139
+ claude plugin install salesforce-reports@salesforce-reports
140
+ ```
141
+
142
+ The tools, the result shape, the safety rails and the limits are in [docs/reference.md](docs/reference.md).
143
+
144
+ ## Develop
145
+
146
+ ```
147
+ npm install
148
+ npm run build
149
+ npm test
150
+ npm run test:render # draw the report card in Chromium and compare screenshots
151
+ npm run live -- --org <alias> --allow-delete # end-to-end run against an org you can write to
152
+ npm run capture -- --org <alias> # refresh the test fixtures (anonymised)
153
+ ```
154
+
155
+ `npm run live` creates several private reports named `zz-live-check-*`, deletes them, and fails if any remain. Use a sandbox or scratch org.
156
+
157
+ ## Roadmap
158
+
159
+ - Dashboards: list, describe, refresh, and read component data.
160
+ - Joined reports, report charts, and subscriptions.
161
+
162
+ ## License
163
+
164
+ MIT.
package/dist/format.js ADDED
@@ -0,0 +1,49 @@
1
+ import { CombineError } from "./reports/combine.js";
2
+ import { ConnectionError } from "./salesforce/connection.js";
3
+ import { SalesforceError } from "./salesforce/client.js";
4
+ export function ok(value) {
5
+ return { content: [{ type: "text", text: JSON.stringify(value) }] };
6
+ }
7
+ /**
8
+ * ok() for a tool that carries the report view: the same text result, plus the value as
9
+ * structuredContent for a host that draws MCP Apps. Hosts without UI read the text, as before.
10
+ */
11
+ export function okView(value) {
12
+ return { ...ok(value), structuredContent: value };
13
+ }
14
+ export function fail(error) {
15
+ const body = describeError(error);
16
+ return { isError: true, content: [{ type: "text", text: JSON.stringify({ error: body }) }] };
17
+ }
18
+ function describeError(error) {
19
+ if (error instanceof SalesforceError) {
20
+ return { code: error.code, message: error.message, hint: hintFor(error) };
21
+ }
22
+ if (error instanceof ConnectionError) {
23
+ return { code: "CONNECTION", message: error.message, hint: "Check the --org alias, or log in with `sf org login web`." };
24
+ }
25
+ if (error instanceof CombineError || error instanceof InputError) {
26
+ return { code: "INVALID_INPUT", message: error.message };
27
+ }
28
+ return { code: "UNEXPECTED", message: error instanceof Error ? error.message : "Unexpected error." };
29
+ }
30
+ function hintFor(error) {
31
+ const text = `${error.code} ${error.message}`;
32
+ if (error.status === 401)
33
+ return "Log in again with `sf org login web`, or pass a fresh token.";
34
+ if (error.status === 404 || /NOT_FOUND|INVALID_ID/i.test(error.code)) {
35
+ return "Check the id with search_reports (reports) or list_report_types (report types).";
36
+ }
37
+ if (/column|field|grouping|aggregate|filter|operator/i.test(text)) {
38
+ return "Check column API names, filter operators and aggregates with describe_report_type, or the report's own columns with describe_report.";
39
+ }
40
+ if (/REQUEST_LIMIT_EXCEEDED|limit/i.test(text)) {
41
+ return "Salesforce allows 500 synchronous report runs per org per hour. Wait, or reduce the number of runs.";
42
+ }
43
+ if (/INSUFFICIENT_ACCESS|FORBIDDEN|permission/i.test(text)) {
44
+ return "The authenticated user lacks access. They need the Run Reports permission, and for saving, Create and Customize Reports.";
45
+ }
46
+ return undefined;
47
+ }
48
+ export class InputError extends Error {
49
+ }
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { parseArgs } from "node:util";
4
+ import { ReportsApi } from "./reports/api.js";
5
+ import { createClient, DEFAULT_API_VERSION } from "./salesforce/client.js";
6
+ import { resolveConnection } from "./salesforce/connection.js";
7
+ import { buildServer } from "./server.js";
8
+ const HELP = `salesforce-reports-mcp: an MCP server for Salesforce reports (stdio)
9
+
10
+ Usage: salesforce-reports-mcp [--org <alias>] [options]
11
+
12
+ --org <alias> Authenticate through the Salesforce CLI (env SF_ORG). Default: the sf default org.
13
+ Or set SF_INSTANCE_URL and SF_ACCESS_TOKEN to use a token you already hold.
14
+ --api-version <v> REST API version (env SF_API_VERSION). Default ${DEFAULT_API_VERSION}.
15
+ --read-only Register no write tools (env SF_REPORTS_READ_ONLY=1).
16
+ --allow-delete Register delete_report (env SF_REPORTS_ALLOW_DELETE=1).
17
+ --max-rows <n> Default cap on detail rows per result (env SF_REPORTS_MAX_ROWS). Default 200, ceiling 2000.
18
+ -h, --help Show this help.
19
+ `;
20
+ function flag(value, envName) {
21
+ return value ?? ["1", "true"].includes((process.env[envName] ?? "").toLowerCase());
22
+ }
23
+ async function main() {
24
+ const { values } = parseArgs({
25
+ options: {
26
+ org: { type: "string" },
27
+ "api-version": { type: "string" },
28
+ "read-only": { type: "boolean" },
29
+ "allow-delete": { type: "boolean" },
30
+ "max-rows": { type: "string" },
31
+ help: { type: "boolean", short: "h" },
32
+ },
33
+ strict: true,
34
+ });
35
+ if (values.help) {
36
+ process.stderr.write(HELP);
37
+ return;
38
+ }
39
+ const maxRowsRaw = values["max-rows"] ?? process.env.SF_REPORTS_MAX_ROWS;
40
+ const maxRows = maxRowsRaw ? Number.parseInt(maxRowsRaw, 10) : 200;
41
+ if (!Number.isInteger(maxRows) || maxRows < 1 || maxRows > 2000) {
42
+ throw new Error("--max-rows must be a whole number from 1 to 2000.");
43
+ }
44
+ const connection = resolveConnection({ org: values.org });
45
+ const client = createClient(connection, { apiVersion: values["api-version"] ?? process.env.SF_API_VERSION });
46
+ const server = buildServer(new ReportsApi(client), {
47
+ readOnly: flag(values["read-only"], "SF_REPORTS_READ_ONLY"),
48
+ allowDelete: flag(values["allow-delete"], "SF_REPORTS_ALLOW_DELETE"),
49
+ maxRows,
50
+ });
51
+ await server.connect(new StdioServerTransport());
52
+ }
53
+ main().catch((error) => {
54
+ process.stderr.write(`salesforce-reports-mcp: ${error instanceof Error ? error.message : String(error)}\n`);
55
+ process.exit(1);
56
+ });
@@ -0,0 +1,86 @@
1
+ import { SalesforceError } from "../salesforce/client.js";
2
+ const enc = encodeURIComponent;
3
+ export function soqlString(value) {
4
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
5
+ }
6
+ /** For a `LIKE` clause: escape the quote and backslash, and the two wildcards. */
7
+ export function soqlLike(value) {
8
+ return soqlString(value).replace(/([%_])/g, "\\$1");
9
+ }
10
+ export class ReportsApi {
11
+ api;
12
+ sleep;
13
+ constructor(api, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
14
+ this.api = api;
15
+ this.sleep = sleep;
16
+ }
17
+ instanceUrl() {
18
+ return this.api.instanceUrl();
19
+ }
20
+ reportTypes() {
21
+ return this.api.request("GET", "/analytics/reportTypes");
22
+ }
23
+ describeReportType(type) {
24
+ return this.api.request("GET", `/analytics/reportTypes/${enc(type)}`);
25
+ }
26
+ describeReport(id) {
27
+ return this.api.request("GET", `/analytics/reports/${enc(id)}/describe`);
28
+ }
29
+ async run(id, options) {
30
+ const path = `/analytics/reports/${enc(id)}?includeDetails=${options.includeDetails}`;
31
+ try {
32
+ const raw = options.overrides
33
+ ? await this.api.request("POST", path, { reportMetadata: options.overrides })
34
+ : await this.api.request("GET", path);
35
+ return { raw, async: false };
36
+ }
37
+ catch (error) {
38
+ if (!isTimeout(error))
39
+ throw error;
40
+ return { raw: await this.runAsync(id, options.overrides), async: true };
41
+ }
42
+ }
43
+ query(metadata, includeDetails) {
44
+ return this.api.request("POST", `/analytics/reports/query?includeDetails=${includeDetails}`, { reportMetadata: metadata });
45
+ }
46
+ create(metadata) {
47
+ return this.api.request("POST", "/analytics/reports", { reportMetadata: metadata });
48
+ }
49
+ patch(id, metadata) {
50
+ return this.api.request("PATCH", `/analytics/reports/${enc(id)}`, { reportMetadata: metadata });
51
+ }
52
+ clone(id, metadata) {
53
+ return this.api.request("POST", `/analytics/reports?cloneId=${enc(id)}`, { reportMetadata: metadata });
54
+ }
55
+ async remove(id) {
56
+ await this.api.request("DELETE", `/analytics/reports/${enc(id)}`);
57
+ }
58
+ async soql(query) {
59
+ const out = await this.api.request("GET", `/query?q=${enc(query)}`);
60
+ return out.records;
61
+ }
62
+ async runAsync(id, overrides) {
63
+ const started = await this.api.request("POST", `/analytics/reports/${enc(id)}/instances?includeDetails=true`, overrides ? { reportMetadata: overrides } : undefined);
64
+ const instanceId = started.id ?? started.attributes?.id;
65
+ if (!instanceId)
66
+ throw new SalesforceError(500, "NO_INSTANCE", "Salesforce did not return an async report instance.");
67
+ for (let attempt = 0; attempt < 30; attempt++) {
68
+ await this.sleep(Math.min(1000 + attempt * 500, 4000));
69
+ const instance = await this.api.request("GET", `/analytics/reports/${enc(id)}/instances/${enc(instanceId)}`);
70
+ const status = instance.attributes?.status ?? instance.status;
71
+ if (status === "Success")
72
+ return instance;
73
+ if (status === "Error")
74
+ throw new SalesforceError(500, "REPORT_RUN_FAILED", "The async report run failed in Salesforce.");
75
+ }
76
+ throw new SalesforceError(504, "REPORT_RUN_PENDING", `The async report run (${instanceId}) did not finish in time. Try again, or narrow the filters.`);
77
+ }
78
+ }
79
+ function isTimeout(error) {
80
+ if (!(error instanceof SalesforceError))
81
+ return false;
82
+ return (error.status === 408 ||
83
+ error.status === 504 ||
84
+ /TIMEOUT|TOO_LONG|ASYNC/i.test(error.code) ||
85
+ /timed out|time out|asynchronous/i.test(error.message));
86
+ }
@@ -0,0 +1,116 @@
1
+ export class CombineError extends Error {
2
+ }
3
+ export function combineResults(mode, inputs, options = {}) {
4
+ if (inputs.length < 2 || inputs.length > 5)
5
+ throw new CombineError("combine_reports takes 2 to 5 inputs.");
6
+ const aliases = inputs.map((i) => i.alias);
7
+ if (new Set(aliases).size !== aliases.length)
8
+ throw new CombineError("Each input needs its own alias.");
9
+ const base = {
10
+ report: {
11
+ id: null,
12
+ name: `combined: ${aliases.join(", ")}`,
13
+ format: mode === "join" ? "SUMMARY" : "TABULAR",
14
+ reportType: "combined",
15
+ },
16
+ filters: inputs.flatMap((i) => i.result.filters.map((f) => `${i.alias}: ${f}`)),
17
+ notes: inputs.flatMap((i) => i.result.notes.map((n) => `${i.alias}: ${n}`)),
18
+ };
19
+ const body = mode === "join" ? join(inputs, options.joinOn) : stack(inputs);
20
+ return { ...base, ...body };
21
+ }
22
+ function stack(inputs) {
23
+ if (inputs.every((i) => i.result.groups)) {
24
+ const dims = unionBy(inputs.flatMap((i) => i.result.groups.dimensions), (d) => d.name);
25
+ const measures = unionBy(inputs.flatMap((i) => i.result.groups.measures), (m) => m.name);
26
+ const rows = [];
27
+ for (const { alias, result } of inputs) {
28
+ const g = result.groups;
29
+ const dimIdx = g.dimensions.map((d) => d.name);
30
+ const measureIdx = g.measures.map((m) => m.name);
31
+ for (const row of g.rows) {
32
+ rows.push([
33
+ alias,
34
+ ...dims.map((d) => pick(row, dimIdx.indexOf(d.name))),
35
+ ...measures.map((m) => pick(row, measureIdx.indexOf(m.name) < 0 ? -1 : dimIdx.length + measureIdx.indexOf(m.name))),
36
+ ]);
37
+ }
38
+ }
39
+ return { groups: { dimensions: [{ name: "source", label: "Source", axis: "down" }, ...dims], measures, rows } };
40
+ }
41
+ if (inputs.every((i) => i.result.detail)) {
42
+ const columns = unionBy(inputs.flatMap((i) => i.result.detail.columns), (c) => c.name);
43
+ const rows = [];
44
+ for (const { alias, result } of inputs) {
45
+ const d = result.detail;
46
+ const idx = d.columns.map((c) => c.name);
47
+ for (const row of d.rows)
48
+ rows.push([alias, ...columns.map((c) => pick(row, idx.indexOf(c.name)))]);
49
+ }
50
+ const all = [{ name: "source", label: "Source", dataType: "string" }, ...columns];
51
+ return { detail: { columns: all, rows, rowCount: rows.length, truncated: inputs.some((i) => i.result.detail.truncated) } };
52
+ }
53
+ throw new CombineError("To stack, every input must return grouped rows, or every input must return detail rows. Group all of them the same way, or run all of them with detail.");
54
+ }
55
+ function join(inputs, joinOn) {
56
+ const plans = inputs.map(({ alias, result }) => {
57
+ const g = result.groups;
58
+ if (!g)
59
+ throw new CombineError(`Input "${alias}" has no groupings. Join needs grouped reports; use stack for flat tables.`);
60
+ const downs = g.dimensions.filter((d) => d.axis === "down");
61
+ const keys = joinOn ?? (downs[0] ? [downs[0].name] : []);
62
+ if (!keys.length)
63
+ throw new CombineError(`Input "${alias}" has no down grouping to join on.`);
64
+ const idx = keys.map((k) => g.dimensions.findIndex((d) => d.name === k));
65
+ const missing = keys.filter((_, n) => idx[n] < 0);
66
+ if (missing.length) {
67
+ throw new CombineError(`Input "${alias}" is not grouped by ${missing.join(", ")}. It has: ${g.dimensions.map((d) => d.name).join(", ")}.`);
68
+ }
69
+ if (g.dimensions.length > keys.length) {
70
+ throw new CombineError(`Input "${alias}" is grouped by more than the join key (${g.dimensions.map((d) => d.name).join(", ")}). Set joinOn to all of its groupings, or remove the extra groupings.`);
71
+ }
72
+ return { alias, g, idx };
73
+ });
74
+ const first = plans[0];
75
+ const keyDims = first.idx.map((i) => ({ ...first.g.dimensions[i], axis: "down" }));
76
+ const measures = plans.flatMap((p) => p.g.measures.map((m) => ({ name: `${p.alias}:${m.name}`, label: `${p.alias}: ${m.label}`, dataType: m.dataType })));
77
+ const table = new Map();
78
+ for (const p of plans) {
79
+ for (const row of p.g.rows) {
80
+ const labels = p.idx.map((i) => row[i] ?? null);
81
+ const key = labels.map((l) => String(l ?? "").trim().toLowerCase()).join("\u0000");
82
+ let entry = table.get(key);
83
+ if (!entry) {
84
+ entry = { labels, values: new Map() };
85
+ table.set(key, entry);
86
+ }
87
+ entry.values.set(p.alias, row.slice(p.g.dimensions.length));
88
+ }
89
+ }
90
+ const rows = [];
91
+ for (const entry of table.values()) {
92
+ const out = [...entry.labels];
93
+ for (const p of plans) {
94
+ const values = entry.values.get(p.alias);
95
+ for (let m = 0; m < p.g.measures.length; m++)
96
+ out.push(values?.[m] ?? null);
97
+ }
98
+ rows.push(out);
99
+ }
100
+ return { groups: { dimensions: keyDims, measures, rows } };
101
+ }
102
+ function pick(row, index) {
103
+ return index < 0 ? null : (row[index] ?? null);
104
+ }
105
+ function unionBy(items, key) {
106
+ const seen = new Set();
107
+ const out = [];
108
+ for (const item of items) {
109
+ const k = key(item);
110
+ if (!seen.has(k)) {
111
+ seen.add(k);
112
+ out.push(item);
113
+ }
114
+ }
115
+ return out;
116
+ }