← Public packages
@kody/salesforce
Salesforce REST helpers for identity, SOQL, multi-org routing, and error investigation.
src/triage-error.ts
1183 lines · 34.9 KB · TypeScriptimport { describeSObject } from './describe-sobject.ts'
import { getRecord } from './get-record.ts'
import {
extractOrganizationId,
lookupOrgMap,
normalizeOrganizationId,
resolveOrg,
type OrgMap,
} from './orgs.ts'
import { query } from './query.ts'
import {
SalesforceApiError,
getInstanceUrl,
request,
resolveIntegrationName,
} from './request.ts'
import type { JsonRecord } from './types.ts'
import {
optionalBoolean,
optionalString,
requireRecord,
requireSalesforceId,
} from './types.ts'
export type ErrorFamily =
| 'validation'
| 'dml'
| 'query'
| 'auth'
| 'apex'
| 'flow'
| 'limits'
| 'api'
| 'unknown'
export type SalesforceIdHint = {
id: string
prefix: string
likelySObject: string | null
}
export type TriageErrorInput = {
/** Loose REST body, Flow fault email, Apex exception, or any JSON/text. */
error?: unknown
message?: string
text?: string
body?: unknown
payload?: unknown
statusCode?: number
status?: number
recordId?: string
recordIds?: Array<string>
flowId?: string
flowName?: string
classId?: string
className?: string
sobject?: string
fields?: Array<string> | string
instanceUrl?: string
apiVersion?: string
/** Salesforce Organization id (00D…). Used to pick the right connection. */
organizationId?: string
/** Caller-owned organizationId → integrationName map from packageStorage. */
orgMap?: OrgMap
/** Saved OAuth integration name. Defaults to `salesforce` only when no org id is present. */
integrationName?: string
/** Skip org lookups and return the local briefing plus suggested queries. */
dryRun?: boolean
}
export type TriageLookup = {
kind: string
ok: boolean
skipped?: string
summary?: JsonRecord
}
export type SuggestedQuery = {
kind: 'soql' | 'tooling'
q: string
why: string
}
export type ErrorBriefing = {
family: ErrorFamily
statusCode: number | null
errorCodes: Array<string>
message: string | null
fields: Array<string>
ids: Array<SalesforceIdHint>
likelySObjects: Array<string>
flow: {
name: string | null
id: string | null
interviewId: string | null
element: string | null
}
apex: {
className: string | null
classId: string | null
line: number | null
exceptionType: string | null
}
}
export type TriageErrorResult = {
dryRun: boolean
integrationName: string
organizationId: string | null
orgSource: 'explicit' | 'org-map' | 'live-discovery' | 'default'
briefing: ErrorBriefing
lookups: Array<TriageLookup>
suggestedQueries: Array<SuggestedQuery>
summary: string
}
const SOBJECT_PREFIXES: Record<string, string> = {
'001': 'Account',
'003': 'Contact',
'005': 'User',
'006': 'Opportunity',
'00D': 'Organization',
'00G': 'Group',
'00Q': 'Lead',
'00T': 'Task',
'00U': 'Event',
'00e': 'Profile',
'00k': 'OpportunityLineItem',
'01I': 'CustomObject',
'01p': 'ApexClass',
'01q': 'ApexTrigger',
'01t': 'Product2',
'03d': 'ValidationRule',
'07L': 'ApexLog',
'0QT': 'Quote',
'300': 'FlowInterview',
'301': 'Flow',
'308': 'FlowDefinition',
'500': 'Case',
'701': 'Campaign',
'800': 'Contract',
'801': 'Order',
}
const FAMILY_BY_CODE: Record<string, ErrorFamily> = {
CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY: 'dml',
DUPLICATE_VALUE: 'dml',
ENTITY_IS_DELETED: 'dml',
FIELD_CUSTOM_VALIDATION_EXCEPTION: 'validation',
FIELD_FILTER_VALIDATION_EXCEPTION: 'validation',
FIELD_INTEGRITY_EXCEPTION: 'validation',
INSUFFICIENT_ACCESS: 'auth',
INSUFFICIENT_ACCESS_ON_CROSS_REFERENCE_ENTITY: 'auth',
INSUFFICIENT_ACCESS_OR_READONLY: 'auth',
INVALID_EMAIL_ADDRESS: 'validation',
INVALID_FIELD: 'query',
INVALID_FIELD_FOR_INSERT_UPDATE: 'validation',
INVALID_LOGIN: 'auth',
INVALID_QUERY_FILTER_OPERATOR: 'query',
INVALID_SESSION_ID: 'auth',
INVALID_TYPE: 'query',
MALFORMED_QUERY: 'query',
MALFORMED_SEARCH: 'query',
REQUEST_LIMIT_EXCEEDED: 'limits',
REQUIRED_FIELD_MISSING: 'validation',
STORAGE_LIMIT_EXCEEDED: 'limits',
STRING_TOO_LONG: 'validation',
UNABLE_TO_LOCK_ROW: 'dml',
}
const ID_PATTERN = /\b[a-zA-Z0-9]{15}(?:[a-zA-Z0-9]{3})?\b/g
const ERROR_CODE_PATTERN = /\b[A-Z][A-Z0-9_]{2,}\b/g
const FIELD_LIST_PATTERN = /\[([A-Za-z][A-Za-z0-9_,.\s]*)\]/g
const APEX_CLASS_PATTERN = /Class\.([A-Za-z][A-Za-z0-9_]+)/
const APEX_LINE_PATTERN = /line (\d+)/i
const APEX_EXCEPTION_PATTERN = /\b((?:System|Flow)\.[A-Za-z][A-Za-z0-9_]+)\b/
const FLOW_NAME_PATTERN = /Flow API Name:\s*([A-Za-z][A-Za-z0-9_]+)/i
const FLOW_ELEMENT_PATTERN =
/(?:An error occurred at element|Current Element:)\s*([A-Za-z][A-Za-z0-9_]+)/i
const MAX_LOOKUPS = 6
const MAX_LOG_CHARS = 1800
type OrgContext = {
integrationName: string
instanceUrl?: string
apiVersion?: string
}
/**
* Normalize a Salesforce REST, Flow, or Apex error and, when useful, run read-only org lookups. Never mutates Salesforce.
* @param input.error - Raw Salesforce error payload.
* @param input.sobject - Optional sObject context for field lookups.
* @returns Error briefing with optional read-only enrichment.
* @example
* import triageError from 'kody:@kody/salesforce/triage-error'
* const briefing = await triageError({
* error: [{ errorCode: 'REQUIRED_FIELD_MISSING', fields: ['LastName'] }],
* sobject: 'Lead',
* dryRun: true,
* })
*/
export async function triageError(input: TriageErrorInput): Promise<TriageErrorResult> {
const resolved = await resolveTriageOrg(input)
const integrationName = resolved.integrationName
const briefing = normalizeError(input)
const suggestedQueries = buildSuggestedQueries(briefing)
const dryRun = input.dryRun === true
if (dryRun) {
return {
dryRun: true,
integrationName,
organizationId: resolved.organizationId,
orgSource: resolved.orgSource,
briefing,
lookups: [
{
kind: 'org',
ok: false,
skipped: 'dryRun: local briefing only; org lookups are not sent.',
},
],
suggestedQueries,
summary: writeSummary(briefing, true),
}
}
const org: OrgContext = {
integrationName,
instanceUrl: optionalString(input.instanceUrl, 'instanceUrl'),
apiVersion: optionalString(input.apiVersion, 'apiVersion'),
}
const lookups: Array<TriageLookup> = []
try {
const instanceUrl = await getInstanceUrl(org.instanceUrl, integrationName)
lookups.push({
kind: 'connection',
ok: true,
summary: { instanceHost: new URL(instanceUrl).host },
})
await collectOrgLookups(briefing, org, lookups)
} catch (error) {
lookups.push({
kind: 'connection',
ok: false,
skipped: projectError(error),
})
}
return {
dryRun: false,
integrationName,
organizationId: resolved.organizationId,
orgSource: resolved.orgSource,
briefing,
lookups,
suggestedQueries,
summary: writeSummary(briefing, false),
}
}
async function resolveTriageOrg(input: TriageErrorInput): Promise<{
integrationName: string
organizationId: string | null
orgSource: TriageErrorResult['orgSource']
}> {
const organizationId = input.organizationId?.trim()
? normalizeOrganizationId(input.organizationId)
: extractOrganizationId(input.error, input.body, input.payload)
if (input.integrationName?.trim()) {
return {
integrationName: resolveIntegrationName(input.integrationName),
organizationId,
orgSource: 'explicit',
}
}
if (!organizationId) {
return {
integrationName: resolveIntegrationName(undefined),
organizationId: null,
orgSource: 'default',
}
}
if (input.dryRun === true) {
const mapped = lookupOrgMap(input.orgMap, organizationId)
if (mapped) {
return {
integrationName: mapped.integrationName,
organizationId,
orgSource: 'org-map',
}
}
throw new Error(
`triage-error dryRun found organization ${organizationId} but no integrationName or orgMap entry. Pass one so this does not fall back to the default salesforce connection.`,
)
}
const resolved = await resolveOrg({
organizationId,
orgMap: input.orgMap,
payload: input.payload,
error: input.error,
body: input.body,
})
return {
integrationName: resolved.integrationName,
organizationId: resolved.organizationId,
orgSource: resolved.source,
}
}
/**
* Normalize a Salesforce REST, Flow, or Apex error and, when useful, run
* read-only org lookups. Never mutates Salesforce.
* @example
* import triageError from 'kody:@kody/salesforce/triage-error'
* const briefing = await triageError({
* error: [{ errorCode: 'REQUIRED_FIELD_MISSING', fields: ['LastName'] }],
* sobject: 'Lead',
* dryRun: true,
* })
*/
export default async function triageErrorEntrypoint(params: unknown = {}) {
if (typeof params === 'string' || Array.isArray(params)) {
return triageError({ error: params })
}
const input = requireRecord(params, 'triage-error')
const recordIds = Array.isArray(input.recordIds)
? input.recordIds.filter((id): id is string => typeof id === 'string')
: undefined
const fields = Array.isArray(input.fields)
? input.fields.filter((field): field is string => typeof field === 'string')
: optionalString(input.fields, 'fields')
return triageError({
error: input.error,
message: optionalString(input.message, 'message'),
text: optionalString(input.text, 'text'),
body: input.body,
payload: input.payload,
statusCode: optionalLooseNumber(input.statusCode),
status: optionalLooseNumber(input.status),
recordId: optionalString(input.recordId, 'recordId'),
recordIds,
flowId: optionalString(input.flowId, 'flowId'),
flowName: optionalString(input.flowName, 'flowName'),
classId: optionalString(input.classId, 'classId'),
className: optionalString(input.className, 'className'),
sobject: optionalString(input.sobject, 'sobject'),
fields,
instanceUrl: optionalString(input.instanceUrl, 'instanceUrl'),
apiVersion: optionalString(input.apiVersion, 'apiVersion'),
organizationId: optionalString(input.organizationId, 'organizationId'),
orgMap: isOrgMap(input.orgMap) ? input.orgMap : undefined,
integrationName: optionalString(input.integrationName, 'integrationName'),
dryRun: optionalBoolean(input.dryRun, 'dryRun'),
})
}
function isOrgMap(value: unknown): value is OrgMap {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
export function normalizeError(input: TriageErrorInput): ErrorBriefing {
const fragments = collectFragments(input)
const text = fragments.map((fragment) => fragment.text).join('\n')
const errorCodes = unique(fragments.flatMap((fragment) => fragment.errorCodes))
const fields = unique([
...explicitFields(input.fields),
...fragments.flatMap((fragment) => fragment.fields),
...extractFieldsFromText(text),
])
const ids = uniqueIds([
...explicitIds(input),
...fragments.flatMap((fragment) => fragment.ids),
...extractIdsFromText(text),
])
const sobjects = unique([
...(input.sobject ? [input.sobject] : []),
...ids
.map((id) => id.likelySObject)
.filter((name): name is string => Boolean(name) && name !== 'CustomObject'),
])
const flowName =
optionalTrim(input.flowName) ?? firstMatch(text, FLOW_NAME_PATTERN)
const flowId =
optionalTrim(input.flowId) ??
ids.find((id) => id.likelySObject === 'Flow')?.id ??
null
const interviewId =
ids.find((id) => id.likelySObject === 'FlowInterview')?.id ?? null
const className =
optionalTrim(input.className) ?? firstMatch(text, APEX_CLASS_PATTERN)
const classId =
optionalTrim(input.classId) ??
ids.find((id) => id.likelySObject === 'ApexClass')?.id ??
null
const exceptionType = firstMatch(text, APEX_EXCEPTION_PATTERN)
const line = Number.parseInt(firstMatch(text, APEX_LINE_PATTERN) ?? '', 10)
const family = classifyFamily({
errorCodes,
text,
flowName,
className,
exceptionType,
})
const message = pickMessage(fragments, input)
return {
family,
statusCode: pickStatusCode(input, fragments, family),
errorCodes,
message,
fields,
ids,
likelySObjects: sobjects,
flow: {
name: flowName,
id: flowId,
interviewId,
element: firstMatch(text, FLOW_ELEMENT_PATTERN),
},
apex: {
className,
classId,
line: Number.isFinite(line) ? line : null,
exceptionType,
},
}
}
async function collectOrgLookups(
briefing: ErrorBriefing,
org: OrgContext,
lookups: Array<TriageLookup>,
) {
const sobject = briefing.likelySObjects[0]
if (sobject && briefing.fields.length > 0 && lookups.length < MAX_LOOKUPS) {
lookups.push(await lookupDescribe(sobject, briefing.fields, org))
}
for (const hint of briefing.ids) {
if (lookups.length >= MAX_LOOKUPS) break
if (!hint.likelySObject || hint.likelySObject === 'CustomObject') continue
if (hint.likelySObject === 'ApexClass') continue
if (hint.likelySObject === 'ApexLog') continue
if (hint.likelySObject === 'Flow') continue
if (hint.likelySObject === 'FlowDefinition') continue
if (hint.likelySObject === 'FlowInterview') continue
if (hint.likelySObject === 'Organization') continue
lookups.push(await lookupRecord(hint, briefing.fields, org))
}
if (briefing.apex.className || briefing.apex.classId) {
if (lookups.length < MAX_LOOKUPS) {
lookups.push(await lookupApexClass(briefing, org))
}
}
if (briefing.flow.name || briefing.flow.id) {
if (lookups.length < MAX_LOOKUPS) {
lookups.push(await lookupFlow(briefing, org))
}
}
if (briefing.flow.interviewId && lookups.length < MAX_LOOKUPS) {
lookups.push(await lookupFlowInterview(briefing.flow.interviewId, org))
}
if (
(briefing.family === 'apex' ||
briefing.family === 'flow' ||
briefing.apex.className) &&
lookups.length < MAX_LOOKUPS
) {
lookups.push(await lookupRecentApexLogs(org))
}
const logLookup = lookups.find((lookup) => lookup.kind === 'apex-logs' && lookup.ok)
const logId = firstString(logLookup?.summary?.latestId)
if (logId && lookups.length < MAX_LOOKUPS) {
lookups.push(await lookupApexLogBody(logId, org))
}
}
async function lookupDescribe(
sobject: string,
fields: Array<string>,
org: OrgContext,
): Promise<TriageLookup> {
return tryLookup('describe-sobject', async () => {
const describe = await describeSObject({
sobject,
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
const describedFields = Array.isArray(describe.fields) ? describe.fields : []
const wanted = new Set(fields.map((field) => field.toLowerCase()))
const matched = describedFields
.filter((field): field is JsonRecord => Boolean(field) && typeof field === 'object')
.filter((field) => wanted.has(String(field.name ?? '').toLowerCase()))
.slice(0, 12)
.map((field) => ({
name: field.name ?? null,
label: field.label ?? null,
type: field.type ?? null,
nillable: field.nillable ?? null,
createable: field.createable ?? null,
updateable: field.updateable ?? null,
custom: field.custom ?? null,
}))
return {
sobject,
label: typeof describe.label === 'string' ? describe.label : null,
matchedFieldCount: matched.length,
fields: matched,
}
})
}
async function lookupRecord(
hint: SalesforceIdHint,
fields: Array<string>,
org: OrgContext,
): Promise<TriageLookup> {
const sobject = hint.likelySObject
if (!sobject) {
return { kind: 'get-record', ok: false, skipped: 'No likely sObject for this id.' }
}
return tryLookup('get-record', async () => {
const requested = unique(['Id', ...fields]).slice(0, 8).join(',')
try {
const record = await getRecord({
sobject,
id: hint.id,
fields: requested,
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
return projectRecord(record, sobject, fields)
} catch {
const record = await getRecord({
sobject,
id: hint.id,
fields: 'Id',
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
return projectRecord(record, sobject, fields)
}
})
}
async function lookupApexClass(
briefing: ErrorBriefing,
org: OrgContext,
): Promise<TriageLookup> {
return tryLookup('apex-class', async () => {
const clause = briefing.apex.classId
? `Id = ${soqlString(briefing.apex.classId)}`
: `Name = ${soqlString(briefing.apex.className ?? '')}`
const result = await toolingQuery(
`SELECT Id, Name, Status, ApiVersion, LastModifiedDate, LengthWithoutComments FROM ApexClass WHERE ${clause} LIMIT 3`,
org,
)
return {
totalSize: result.totalSize,
records: result.records.slice(0, 3).map((record) => ({
id: record.Id ?? null,
name: record.Name ?? null,
status: record.Status ?? null,
apiVersion: record.ApiVersion ?? null,
lastModifiedDate: record.LastModifiedDate ?? null,
lengthWithoutComments: record.LengthWithoutComments ?? null,
})),
}
})
}
async function lookupFlow(
briefing: ErrorBriefing,
org: OrgContext,
): Promise<TriageLookup> {
return tryLookup('flow', async () => {
const clause = briefing.flow.id
? `Id = ${soqlString(briefing.flow.id)}`
: `ApiName = ${soqlString(briefing.flow.name ?? '')}`
let result: { totalSize: number; records: Array<JsonRecord> }
try {
result = await toolingQuery(
`SELECT Id, ApiName, DurableId, MasterLabel, ProcessType, Status, LastModifiedDate FROM Flow WHERE ${clause} LIMIT 5`,
org,
)
} catch {
const fallback = briefing.flow.id
? `Id = ${soqlString(briefing.flow.id)}`
: `FullName LIKE ${soqlString(`${briefing.flow.name ?? ''}%`)}`
result = await toolingQuery(
`SELECT Id, FullName, Status, ProcessType, LastModifiedDate FROM Flow WHERE ${fallback} LIMIT 5`,
org,
)
}
return {
totalSize: result.totalSize,
records: result.records.slice(0, 5).map((record) => ({
id: record.Id ?? null,
apiName: record.ApiName ?? record.FullName ?? null,
durableId: record.DurableId ?? null,
label: record.MasterLabel ?? null,
processType: record.ProcessType ?? null,
status: record.Status ?? null,
lastModifiedDate: record.LastModifiedDate ?? null,
})),
}
})
}
async function lookupFlowInterview(
interviewId: string,
org: OrgContext,
): Promise<TriageLookup> {
return tryLookup('flow-interview', async () => {
const result = await query({
q: `SELECT Id, InterviewLabel, CurrentElement, InterviewStatus, CreatedDate FROM FlowInterview WHERE Id = ${soqlString(interviewId)}`,
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
const record = result.records[0] ?? null
return {
totalSize: result.totalSize,
id: record?.Id ?? null,
label: record?.InterviewLabel ?? null,
currentElement: record?.CurrentElement ?? null,
status: record?.InterviewStatus ?? null,
createdDate: record?.CreatedDate ?? null,
}
})
}
async function lookupRecentApexLogs(org: OrgContext): Promise<TriageLookup> {
return tryLookup('apex-logs', async () => {
const result = await toolingQuery(
'SELECT Id, Status, Operation, Request, DurationMilliseconds, LogLength, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 5',
org,
)
const records = result.records.slice(0, 5).map((record) => ({
id: record.Id ?? null,
status: record.Status ?? null,
operation: record.Operation ?? null,
request: record.Request ?? null,
durationMilliseconds: record.DurationMilliseconds ?? null,
logLength: record.LogLength ?? null,
lastModifiedDate: record.LastModifiedDate ?? null,
}))
return {
totalSize: result.totalSize,
latestId: records[0]?.id ?? null,
records,
}
})
}
async function lookupApexLogBody(id: string, org: OrgContext): Promise<TriageLookup> {
return tryLookup('apex-log-body', async () => {
const paths = [`sobjects/ApexLog/${id}/Body`, `tooling/sobjects/ApexLog/${id}/Body`]
let lastError: unknown
for (const path of paths) {
try {
const result = await request({
path,
method: 'GET',
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
const body = 'body' in result ? result.body : result
const text = typeof body === 'string' ? body : JSON.stringify(body)
return {
id,
chars: text.length,
excerpt: excerptLog(text),
}
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error ? lastError : new Error(projectError(lastError))
})
}
async function toolingQuery(
q: string,
org: OrgContext,
): Promise<{ totalSize: number; records: Array<JsonRecord> }> {
const result = await request({
path: 'tooling/query',
method: 'GET',
query: { q },
instanceUrl: org.instanceUrl,
apiVersion: org.apiVersion,
integrationName: org.integrationName,
})
const body = ('body' in result ? result.body : result) as JsonRecord
const records = Array.isArray(body.records)
? body.records.filter((record): record is JsonRecord => Boolean(record) && typeof record === 'object')
: []
return {
totalSize: typeof body.totalSize === 'number' ? body.totalSize : records.length,
records,
}
}
async function tryLookup(
kind: string,
work: () => Promise<JsonRecord>,
): Promise<TriageLookup> {
try {
return { kind, ok: true, summary: await work() }
} catch (error) {
return { kind, ok: false, skipped: projectError(error) }
}
}
function buildSuggestedQueries(briefing: ErrorBriefing): Array<SuggestedQuery> {
const queries: Array<SuggestedQuery> = []
const sobject = briefing.likelySObjects[0]
const recordId = briefing.ids.find(
(id) => id.likelySObject && id.likelySObject === sobject,
)?.id
if (sobject && recordId) {
const fieldList = unique(['Id', 'Name', ...briefing.fields]).slice(0, 8).join(', ')
queries.push({
kind: 'soql',
q: `SELECT ${fieldList} FROM ${sobject} WHERE Id = ${soqlString(recordId)}`,
why: `Inspect the ${sobject} named in the error.`,
})
} else if (sobject && briefing.fields.length > 0) {
const fieldList = unique(['Id', ...briefing.fields]).slice(0, 8).join(', ')
queries.push({
kind: 'soql',
q: `SELECT ${fieldList} FROM ${sobject} ORDER BY LastModifiedDate DESC LIMIT 5`,
why: `Sample recent ${sobject} rows for the fields named in the error.`,
})
}
if (briefing.apex.className || briefing.apex.classId) {
const clause = briefing.apex.classId
? `Id = ${soqlString(briefing.apex.classId)}`
: `Name = ${soqlString(briefing.apex.className ?? '')}`
queries.push({
kind: 'tooling',
q: `SELECT Id, Name, Status, ApiVersion, LastModifiedDate FROM ApexClass WHERE ${clause} LIMIT 5`,
why: 'Confirm the Apex class named in the stack trace.',
})
}
if (briefing.flow.name || briefing.flow.id) {
const clause = briefing.flow.id
? `Id = ${soqlString(briefing.flow.id)}`
: `ApiName = ${soqlString(briefing.flow.name ?? '')}`
queries.push({
kind: 'tooling',
q: `SELECT Id, ApiName, MasterLabel, ProcessType, Status, LastModifiedDate FROM Flow WHERE ${clause} LIMIT 5`,
why: 'Confirm the Flow named in the fault.',
})
}
if (briefing.flow.interviewId) {
queries.push({
kind: 'soql',
q: `SELECT Id, InterviewLabel, CurrentElement, InterviewStatus, CreatedDate FROM FlowInterview WHERE Id = ${soqlString(briefing.flow.interviewId)}`,
why: 'Inspect the Flow interview that faulted.',
})
}
if (briefing.family === 'apex' || briefing.family === 'flow' || briefing.apex.className) {
queries.push({
kind: 'tooling',
q: 'SELECT Id, Status, Operation, Request, DurationMilliseconds, LogLength, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 10',
why: 'Find recent debug logs that may include this exception.',
})
}
if (briefing.family === 'query' && briefing.message) {
queries.push({
kind: 'soql',
q: 'SELECT QualifiedApiName FROM EntityDefinition ORDER BY QualifiedApiName LIMIT 20',
why: 'Confirm the sObject API name before retrying the failed query.',
})
}
if (queries.length === 0) {
queries.push({
kind: 'soql',
q: 'SELECT Id, Status, LastModifiedDate FROM ApexLog ORDER BY LastModifiedDate DESC LIMIT 5',
why: 'Start with recent debug logs when the payload has no record or class ids.',
})
}
return queries.slice(0, 6)
}
function writeSummary(briefing: ErrorBriefing, dryRun: boolean): string {
const parts: Array<string> = []
const code = briefing.errorCodes[0]
const familyLabel = briefing.family === 'unknown' ? 'Salesforce' : briefing.family
if (code && briefing.message) {
parts.push(`${capitalize(familyLabel)} error ${code}: ${clip(briefing.message, 180)}`)
} else if (briefing.message) {
parts.push(`${capitalize(familyLabel)} error: ${clip(briefing.message, 180)}`)
} else if (code) {
parts.push(`${capitalize(familyLabel)} error ${code}.`)
} else {
parts.push('The payload does not include a clear Salesforce error code or message.')
}
if (briefing.fields.length > 0) {
parts.push(`Fields: ${briefing.fields.slice(0, 8).join(', ')}.`)
}
if (briefing.likelySObjects.length > 0) {
parts.push(`Likely sObjects: ${briefing.likelySObjects.slice(0, 5).join(', ')}.`)
}
if (briefing.apex.className) {
const line = briefing.apex.line ? `:${briefing.apex.line}` : ''
parts.push(`Apex class ${briefing.apex.className}${line}.`)
}
if (briefing.flow.name || briefing.flow.element) {
const element = briefing.flow.element ? ` at ${briefing.flow.element}` : ''
parts.push(`Flow ${briefing.flow.name ?? 'interview'}${element}.`)
}
if (briefing.statusCode) {
parts.push(`HTTP ${briefing.statusCode}.`)
}
parts.push(
dryRun
? 'Org lookups are skipped in dryRun; use the suggested SOQL/Tooling queries next.'
: 'Lookups are read-only; mutating Salesforce stays out of scope for this helper.',
)
return parts.join(' ')
}
type ParsedFragment = {
text: string
errorCodes: Array<string>
fields: Array<string>
ids: Array<SalesforceIdHint>
statusCode: number | null
message: string | null
}
function collectFragments(input: TriageErrorInput): Array<ParsedFragment> {
const seen = new Set<unknown>()
const fragments: Array<ParsedFragment> = []
walk(input.error, 0, seen, fragments)
walk(input.body, 0, seen, fragments)
walk(input.payload, 0, seen, fragments)
walk(input.message, 0, seen, fragments)
walk(input.text, 0, seen, fragments)
walk(input, 0, seen, fragments)
return fragments
}
function walk(
value: unknown,
depth: number,
seen: Set<unknown>,
fragments: Array<ParsedFragment>,
) {
if (value === undefined || value === null || depth > 6 || fragments.length > 40) return
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return
const parsed = tryParseJson(trimmed)
if (parsed !== undefined && parsed !== trimmed) {
walk(parsed, depth + 1, seen, fragments)
return
}
fragments.push(parseTextFragment(trimmed))
return
}
if (typeof value === 'number' || typeof value === 'boolean') return
if (typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
if (Array.isArray(value)) {
for (const item of value.slice(0, 20)) walk(item, depth + 1, seen, fragments)
return
}
const record = value as JsonRecord
const errorCode = firstString(record.errorCode ?? record.statusCode ?? record.error)
const message = firstString(
record.message ?? record.error_description ?? record.errorMessage ?? record.msg,
)
const fields = explicitFields(record.fields)
const statusCode =
optionalLooseNumber(record.httpStatusCode) ??
optionalLooseNumber(record.status) ??
optionalLooseNumber(record.statusCode)
if (errorCode || message || fields.length > 0) {
fragments.push({
text: [errorCode, message].filter(Boolean).join(': '),
errorCodes: errorCode && looksLikeErrorCode(errorCode) ? [errorCode] : [],
fields,
ids: [],
statusCode: typeof statusCode === 'number' && statusCode >= 100 ? statusCode : null,
message,
})
}
for (const [key, nested] of Object.entries(record)) {
if (
key === 'error' ||
key === 'body' ||
key === 'payload' ||
key === 'compositeResponse' ||
key === 'results' ||
key === 'errors' ||
key === 'details'
) {
walk(nested, depth + 1, seen, fragments)
}
}
}
function parseTextFragment(text: string): ParsedFragment {
return {
text,
errorCodes: extractErrorCodes(text),
fields: extractFieldsFromText(text),
ids: extractIdsFromText(text),
statusCode: extractStatusFromText(text),
message: clip(text.split('\n').map((line) => line.trim()).find(Boolean) ?? text, 300),
}
}
function classifyFamily(input: {
errorCodes: Array<string>
text: string
flowName: string | null
className: string | null
exceptionType: string | null
}): ErrorFamily {
const text = input.text
if (input.exceptionType?.startsWith('Flow.') || input.flowName || /flow fault/i.test(text)) {
return 'flow'
}
if (input.exceptionType?.startsWith('System.') || input.className || /\bApex\b/i.test(text)) {
if (/LimitException|CPU time|Apex CPU/i.test(text)) return 'limits'
if (/QueryException|\bSOQL\b/i.test(text)) return 'query'
return 'apex'
}
for (const code of input.errorCodes) {
const family = FAMILY_BY_CODE[code]
if (family) return family
}
if (/REQUIRED_FIELD|VALIDATION|INVALID_EMAIL|STRING_TOO_LONG/i.test(text)) return 'validation'
if (/MALFORMED_QUERY|INVALID_FIELD|INVALID_TYPE/i.test(text)) return 'query'
if (/INVALID_SESSION|INSUFFICIENT_ACCESS|INVALID_LOGIN/i.test(text)) return 'auth'
if (/REQUEST_LIMIT|STORAGE_LIMIT/i.test(text)) return 'limits'
if (input.errorCodes.length > 0) return 'api'
return 'unknown'
}
function pickMessage(fragments: Array<ParsedFragment>, input: TriageErrorInput): string | null {
const explicit = optionalTrim(input.message) ?? optionalTrim(input.text)
if (explicit && explicit.length < 400) return explicit
const fromError = fragments.find((fragment) => fragment.message)?.message
if (fromError) return clip(fromError, 300)
if (explicit) return clip(explicit, 300)
return null
}
function pickStatusCode(
input: TriageErrorInput,
fragments: Array<ParsedFragment>,
family: ErrorFamily,
): number | null {
const explicit = optionalLooseNumber(input.statusCode) ?? optionalLooseNumber(input.status)
if (explicit && explicit >= 100) return explicit
const fromFragment = fragments.find((fragment) => fragment.statusCode)?.statusCode
if (fromFragment) return fromFragment
if (family === 'auth') return 401
if (family === 'limits') return 429
if (family === 'validation' || family === 'query' || family === 'dml') return 400
return null
}
function explicitFields(value: Array<string> | string | unknown): Array<string> {
if (typeof value === 'string') {
return value
.split(',')
.map((field) => field.trim())
.filter((field) => /^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(field))
}
if (!Array.isArray(value)) return []
return value
.filter((field): field is string => typeof field === 'string')
.map((field) => field.trim())
.filter((field) => /^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(field))
}
function extractFieldsFromText(text: string): Array<string> {
const fields: Array<string> = []
for (const match of text.matchAll(FIELD_LIST_PATTERN)) {
const inner = match[1]
if (!inner) continue
for (const field of inner.split(',')) {
const trimmed = field.trim()
if (/^[A-Za-z][A-Za-z0-9_.]{0,79}$/.test(trimmed)) fields.push(trimmed)
}
}
return fields
}
function extractErrorCodes(text: string): Array<string> {
return unique((text.match(ERROR_CODE_PATTERN) ?? []).filter(looksLikeErrorCode))
}
function looksLikeErrorCode(value: string): boolean {
return (
value.includes('_') &&
value === value.toUpperCase() &&
value.length >= 6 &&
!['HTTP', 'API', 'SOQL', 'REST'].includes(value)
)
}
function extractIdsFromText(text: string): Array<SalesforceIdHint> {
const ids: Array<SalesforceIdHint> = []
for (const match of text.matchAll(ID_PATTERN)) {
const id = match[0]
if (!id) continue
const hint = idHint(id)
if (!hint) continue
if (id.length === 15 && !hint.likelySObject) continue
ids.push(hint)
}
return ids
}
function explicitIds(input: TriageErrorInput): Array<SalesforceIdHint> {
const raw = [
input.recordId,
input.flowId,
input.classId,
...(Array.isArray(input.recordIds) ? input.recordIds : []),
]
return raw
.filter((id): id is string => typeof id === 'string')
.map((id) => {
try {
return idHint(requireSalesforceId(id, 'id'))
} catch {
return null
}
})
.filter((hint): hint is SalesforceIdHint => Boolean(hint))
}
function extractStatusFromText(text: string): number | null {
const match = text.match(/\b(?:HTTP|status(?:Code)?)[:\s]+(\d{3})\b/i)
if (!match?.[1]) return null
return Number.parseInt(match[1], 10)
}
function idHint(id: string): SalesforceIdHint | null {
if (!/^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$/.test(id)) return null
const prefix = id.slice(0, 3)
const known = SOBJECT_PREFIXES[prefix]
const likelySObject =
known ?? (/^a[a-zA-Z0-9]{2}$/.test(prefix) ? 'CustomObject' : null)
return { id, prefix, likelySObject }
}
function uniqueIds(ids: Array<SalesforceIdHint>): Array<SalesforceIdHint> {
const seen = new Set<string>()
const output: Array<SalesforceIdHint> = []
for (const hint of ids) {
if (seen.has(hint.id)) continue
seen.add(hint.id)
output.push(hint)
}
return output.slice(0, 12)
}
function projectRecord(record: JsonRecord, sobject: string, fields: Array<string>): JsonRecord {
const extra: JsonRecord = {}
for (const field of fields.slice(0, 8)) {
if (field in record) extra[field] = record[field]
}
return {
sobject,
id: record.Id ?? record.id ?? null,
name: record.Name ?? record.Subject ?? null,
lastModifiedDate: record.LastModifiedDate ?? null,
fields: extra,
}
}
function excerptLog(text: string): string {
const lines = text.split(/\r?\n/)
const interesting = lines.filter((line) =>
/EXCEPTION_THROWN|FATAL_ERROR|Error|Exception|VALIDATION/i.test(line),
)
const source = interesting.length > 0 ? interesting.slice(-20) : lines.slice(-20)
return clip(source.join('\n'), MAX_LOG_CHARS)
}
function projectError(error: unknown): string {
if (error instanceof SalesforceApiError) {
return clip(`Salesforce ${error.status} ${error.statusText}: ${error.message}`, 220)
}
if (error instanceof Error) return clip(error.message, 220)
return clip(String(error), 220)
}
function soqlString(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
function unique(values: Array<string>): Array<string> {
const seen = new Set<string>()
const output: Array<string> = []
for (const value of values) {
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
output.push(trimmed)
}
return output
}
function optionalTrim(value: string | undefined): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function firstMatch(text: string, pattern: RegExp): string | null {
const match = text.match(pattern)
const value = match?.[1]?.trim()
return value ? value : null
}
function firstString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
}
function optionalLooseNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function tryParseJson(text: string): unknown {
if (!(text.startsWith('{') || text.startsWith('['))) return undefined
try {
return JSON.parse(text)
} catch {
return undefined
}
}
function clip(value: string, max: number): string {
if (value.length <= max) return value
return `${value.slice(0, max - 1)}…`
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1)
}