Report Date: 2026-03-28 Analyst: Refactoring Expert (Claude Sonnet 4.6) Source Report: code-smell-detector-report.md Total Recommendations: 22
The nforce8 codebase is in good structural shape following a prior refactoring campaign that decomposed a monolithic index.js into domain modules. The 30 remaining code smells cluster into three actionable themes:
- Public surface pollution: Private implementation helpers are mixed onto the Connection prototype alongside the public API, creating an invisible coupling surface for any external consumer.
- Pervasive opts-bag mutation: A single mutable plain object accumulates properties across every architectural layer, making individual functions impossible to test or reason about in isolation.
- Scattered duplication: Multiple blocks of 4–6 lines of identical code are repeated across related functions; each is a low-risk, high-clarity refactoring opportunity.
The recommended roadmap is organized into three phases: Phase 1 (quick wins, zero architectural risk), Phase 2 (design improvements, moderate risk), and Phase 3 (architectural uplift, coordinated effort required). Each recommendation below maps directly to one or more of the 66 canonical refactoring techniques.
| # | Recommendation | Technique | Phase | Impact | Complexity | Risk |
|---|---|---|---|---|---|---|
| R01 | Remove dead opts._refreshResult write |
Remove Dead Code | 1 | M | L | L |
| R02 | Remove commented-out credential block in integration test | Remove Dead Code | 1 | L | L | L |
| R03 | Fix fallacious test description (#getUrl) |
Rename Method (test desc) | 1 | L | L | L |
| R04 | Eliminate duplicate package.json read in index.js |
Inline Temp | 1 | L | L | L |
| R05 | Add err.type = 'empty-response' to emptyResponse() |
Introduce Assertion / symmetry fix | 1 | M | L | L |
| R06 | Extract buildSignal() helper to remove duplicated timeout/AbortSignal setup |
Extract Method | 1 | M | L | L |
| R07 | Extract applyBody() to unify duplicated multipart/JSON body logic |
Extract Method | 2 | M | L | L |
| R08 | Extract _resolveEndpoint() to unify three environment-conditional endpoint functions |
Extract Method | 2 | M | L | L |
| R09 | Inline _resolveOAuth — replace with Promise.resolve() directly |
Inline Method | 2 | M | L | L |
| R10 | Add fail-fast guard for single-mode missing OAuth | Introduce Assertion / Replace Exception with Test | 2 | H | L | L |
| R11 | Extract makeOrg() test helper to eliminate repeated connection boilerplate |
Extract Method | 2 | M | L | L |
| R12 | Replace magic strings 'sandbox' / 'single' with named constants |
Replace Magic Number with Symbolic Constant | 2 | M | L | L |
| R13 | Add err.type to emptyResponse and align error factory API |
Introduce Parameter Object (align API) | 2 | L | L | L |
| R14 | Rename d parameter to input in _getOpts |
Rename Method (parameter) | 2 | L | L | L |
| R15 | Rename getBody API method to getBinaryContent |
Rename Method | 2 | M | M | M |
| R16 | Fix spacing inconsistencies via eslint --fix |
Style tooling | 1 | L | L | L |
| R17 | Move multipart form-building into Record (toMultipartForm) |
Move Method / Hide Delegate | 3 | H | M | M |
| R18 | Separate private helpers from module.exports in auth.js, api.js, http.js |
Hide Method / Extract Interface | 3 | H | H | H |
| R19 | Sub-divide lib/api.js into domain modules |
Extract Class (module) | 3 | H | H | H |
| R20 | Introduce typed request value objects to replace the opts bag | Introduce Parameter Object / Replace Data Value with Object | 3 | H | H | H |
| R21 | Separate retry state from the opts bag in _apiRequest |
Split Temporary Variable / Remove Assignments to Parameters | 3 | M | M | M |
| R22 | Standardize on ES6 class syntax for Connection and Record |
Extract Superclass pattern | 3 | M | H | M |
Smell: Dead Code (Issue #9 in detector report)
Technique: Remove Dead Code
File: lib/http.js, line 177
Risk: Low | Complexity: Low | Impact: Medium
Problem: opts._refreshResult = res is assigned in the auto-refresh retry path but is never read anywhere in the codebase (confirmed by global search). The assignment is a dead write.
Before:
return this.autoRefreshToken(opts).then((res) => {
opts._refreshResult = res; // dead write — never consumed
opts._retryCount = 1;
return this._apiRequest(opts);
});After:
return this.autoRefreshToken(opts).then(() => {
opts._retryCount = 1;
return this._apiRequest(opts);
});Steps:
- Delete line 177 (
opts._refreshResult = res;). - Change the arrow function parameter from
resto_or remove it (change to() =>). - Run
npm testto verify no regression.
Note: opts._retryCount is addressed as a separate concern in R21.
Smell: Dead Code (Issue #20)
Technique: Remove Dead Code
File: test/integration.js, lines 56–67 and the TODO on line 18
Risk: Low | Complexity: Low | Impact: Low
Problem: A commented-out object literal with placeholder credentials was left in the integration test. The TODO: fix the creds comment on line 18 is the corresponding hanging marker.
Steps:
- Delete
test/integration.jslines 56–67 (the/* let x = { ... } */block). - Delete the
// TODO: fix the credscomment on line 18. - Run
npm testto confirm no change in test output.
Smell: Fallacious Comment (Issue #19)
Technique: Rename Method (applied to test description string)
File: test/record.js, line 202
Risk: Low | Complexity: Low | Impact: Low
Before:
describe('#getUrl', function () {
it('should let me get the id', function () { // wrong — tests getUrl, not getId
acc.getUrl().should.equal('http://www.salesforce.com');
});
});After:
describe('#getUrl', function () {
it('should let me get the url', function () {
acc.getUrl().should.equal('http://www.salesforce.com');
});
});Smell: Duplicate Code (Issue #7)
Technique: Inline Temp
Files: index.js line 68; lib/constants.js line 15
Risk: Low | Complexity: Low | Impact: Low
Problem: Both lib/constants.js and index.js independently require('../package.json').sfdx.api. index.js already imports CONST from lib/constants, which already exposes this value as CONST.API.
Before (index.js lines 67–68):
const version = require('./package.json').version;
const API_VERSION = require('./package.json').sfdx.api;After:
const version = require('./package.json').version;
const API_VERSION = CONST.API;Steps:
- In
index.jsline 68, replacerequire('./package.json').sfdx.apiwithCONST.API. - Verify
CONSTis already imported at the top ofindex.js(it is, line 5). - Run
npm test.
Smell: Incomplete Error Factory (Issue #29)
Technique: Introduce Assertion (align error factory API)
File: lib/errors.js, lines 9–11
Risk: Low | Complexity: Low | Impact: Medium
Problem: invalidJson() sets err.type = 'invalid-json', enabling programmatic error discrimination. emptyResponse() lacks a corresponding type, making the API asymmetric. A caller cannot catch empty-response errors by type.
Before:
const emptyResponse = () => {
return new Error('Unexpected empty response');
};After:
const emptyResponse = () => {
const err = new Error('Unexpected empty response');
err.type = 'empty-response';
return err;
};Steps:
- Edit
lib/errors.jsas shown above. - Update
test/errors.jsto asserterr.type === 'empty-response'on empty-response error (currently this assertion is absent; add it for consistency with theinvalidJsontest). - Run
npm test.
Smell: Duplicate Code (Issue #4)
Technique: Extract Method
File: lib/http.js, lines 100–106 and 139–145
Risk: Low | Complexity: Low | Impact: Medium
Problem: An identical 6-line block for merging an AbortSignal.timeout with an optional existing signal appears in both _apiAuthRequest and _apiRequest.
Duplicated block (appears twice):
if (this.timeout) {
const timeoutSignal = AbortSignal.timeout(this.timeout);
opts.signal =
opts.signal !== undefined
? AbortSignal.any([timeoutSignal, opts.signal])
: timeoutSignal;
}Extracted helper:
/**
* Build an AbortSignal that fires after `timeout` ms, optionally
* combining it with a caller-supplied signal.
* @param {AbortSignal|undefined} existingSignal
* @param {number|undefined} timeout milliseconds; falsy = no timeout
* @returns {AbortSignal|undefined}
*/
function buildSignal(existingSignal, timeout) {
if (!timeout) return existingSignal;
const timeoutSignal = AbortSignal.timeout(timeout);
return existingSignal !== undefined
? AbortSignal.any([timeoutSignal, existingSignal])
: timeoutSignal;
}After (both call sites):
// In _apiAuthRequest:
opts.signal = buildSignal(opts.signal, this.timeout);
// In _apiRequest:
ropts.signal = buildSignal(ropts.signal, this.timeout);Steps:
- Add
buildSignalas a module-level function at the top oflib/http.js. - Replace both 6-line timeout blocks with one-line calls to
buildSignal. - Run
npm testto verify identical behaviour.
Smell: Inconsistent Style (Issue #11)
Technique: Substitute Algorithm (tooling-assisted)
File: lib/api.js, lines 150, 163–164, 177–179, 188–189, 454
Risk: Low | Complexity: Low | Impact: Low
Problem: Several assignments omit the required space before the = operator: const type =opts.sobject.getType().
Steps:
- Run
npx eslint --fix lib/api.js. - Verify the corrected assignments in the affected lines.
- Run
npm test.
Smell: Duplicate Code (Issue #5)
Technique: Extract Method, Parameterize Method
File: lib/api.js, lines 153–157 and 167–171
Risk: Low | Complexity: Low | Impact: Medium
Problem: The insert and update functions each contain an identical 5-line conditional that selects multipart vs JSON serialization. The only difference is the payload-extraction function: toPayload() for insert, toChangedPayload() for update.
Duplicated pattern:
if (CONST.MULTIPART_TYPES.includes(type)) {
opts.multipart = multipart(opts);
} else {
opts.body = JSON.stringify(opts.sobject.toPayload()); // differs
}Extracted helper:
/**
* Attach either a multipart form or a JSON body to opts, based on the SObject type.
* @param {object} opts - Request options bag (mutated in-place).
* @param {string} type - Lowercased SObject type string.
* @param {Function} payloadFn - Zero-argument function that returns the payload object.
*/
function applyBody(opts, type, payloadFn) {
if (CONST.MULTIPART_TYPES.includes(type)) {
opts.multipart = multipart(opts);
} else {
opts.body = JSON.stringify(payloadFn());
}
}After:
const insert = function (data) {
const opts = this._getOpts(data);
if (!opts.sobject) throw new Error('insert requires opts.sobject');
const type = opts.sobject.getType();
opts.resource = sobjectPath(type);
opts.method = 'POST';
applyBody(opts, type, () => opts.sobject.toPayload());
return this._apiRequest(opts);
};
const update = function (data) {
const opts = this._getOpts(data);
const type = opts.sobject.getType();
const id = opts.sobject.getId();
opts.resource = sobjectPath(type, id);
opts.method = 'PATCH';
applyBody(opts, type, () => opts.sobject.toChangedPayload());
return this._apiRequest(opts);
};Steps:
- Add
applyBodyas a module-level function insidelib/api.js. - Replace both 5-line conditional blocks in
insertandupdatewithapplyBody(...)calls. - Run
npm test.
Smell: Duplicate Code (Issue #6)
Technique: Extract Method, Consolidate Conditional Expression
File: lib/auth.js, lines 37–48
Risk: Low | Complexity: Low | Impact: Medium
Problem: Three functions apply the identical sandbox-vs-production conditional to different URL properties:
const _authEndpoint = function (opts = {}) {
if (opts.authEndpoint) return opts.authEndpoint;
return this.environment === 'sandbox' ? this.testAuthEndpoint : this.authEndpoint;
};
const _loginEndpoint = function () {
return this.environment === 'sandbox' ? this.testLoginUri : this.loginUri;
};
const _revokeEndpoint = function () {
return this.environment === 'sandbox' ? this.testRevokeUri : this.revokeUri;
};Extracted helper (module-private, not exported):
// Pure function — no 'this' dependency, safe to test standalone.
function resolveEndpoint(environment, prod, test) {
return environment === 'sandbox' ? test : prod;
}After:
const _authEndpoint = function (opts = {}) {
if (opts.authEndpoint) return opts.authEndpoint;
return resolveEndpoint(this.environment, this.authEndpoint, this.testAuthEndpoint);
};
const _loginEndpoint = function () {
return resolveEndpoint(this.environment, this.loginUri, this.testLoginUri);
};
const _revokeEndpoint = function () {
return resolveEndpoint(this.environment, this.revokeUri, this.testRevokeUri);
};Steps:
- Add
resolveEndpointas a module-level function (not exported) at the top oflib/auth.js. - Update the three endpoint functions as shown.
- Run
npm test. The public observable behaviour ofgetAuthUri,authenticate,refreshToken, andrevokeTokenmust be unchanged.
Smell: Lazy Element (Issue #13)
Technique: Inline Method
File: lib/auth.js, lines 120–122; test/connection.js, lines 426–443
Risk: Low | Complexity: Low | Impact: Medium
Problem: _resolveOAuth is a trivially thin wrapper around Promise.resolve that adds no behaviour, documentation, or abstraction value. It is exported publicly, creating an unnecessary coupling point.
const _resolveOAuth = function (newOauth) {
return Promise.resolve(newOauth); // only ever does this
};Steps:
- In
lib/auth.jsauthenticate(), replacereturn this._resolveOAuth(newOauth)withreturn Promise.resolve(newOauth). - Remove
_resolveOAuthfrom the function definition and frommodule.exports. - In
test/connection.js, find the test block exercisingorg._resolveOAuth(lines 426–443). Replace the assertion with an equivalent test usingauthenticate()orPromise.resolve(...)directly — the test should verify that after authentication the OAuth object resolves correctly, not that a private method exists. - Run
npm test.
Risk Mitigation: Any external code that calls org._resolveOAuth will break; however, because it has always been a private helper (leading underscore convention), breaking this is semantically correct and the change is warranted.
Smell: Missing Fail-Fast Guard (Issue #26)
Technique: Introduce Assertion, Replace Exception with Test
File: lib/api.js, lines 18–21 (_getOpts)
Risk: Low | Complexity: Low | Impact: High
Problem: When mode === 'single' and authenticate() has never been called, this.oauth is undefined. _getOpts silently sets data.oauth = undefined, which propagates through the stack until optionhelper.js crashes with a cryptic TypeError: Cannot read properties of undefined (reading 'instance_url').
Before:
if (this.mode === 'single' && !data.oauth) {
data.oauth = this.oauth; // silently injects undefined if not authenticated
}After:
if (this.mode === 'single' && !data.oauth) {
if (!this.oauth) {
throw new Error(
'Connection is in single-user mode but no OAuth token has been set. ' +
'Call authenticate() first.'
);
}
data.oauth = this.oauth;
}Steps:
- Apply the change above in
lib/api.js. - Add a test in
test/connection.jsthat creates a single-mode connection without callingauthenticate(), then calls any API method, and asserts that the descriptive error is thrown. - Run
npm test.
Smell: Required Setup Code Duplication (Issue #16)
Technique: Extract Method
File: test/connection.js, lines 8–168
Risk: Low | Complexity: Low | Impact: Medium
Problem: The same nforce.createConnection({...}) call with FAKE_CLIENT_ID and FAKE_REDIRECT_URI is inlined approximately 17 times. Any option defaults change requires editing 17 locations.
Extracted helper:
function makeOrg(overrides = {}) {
return nforce.createConnection(Object.assign({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_ID,
redirectUri: FAKE_REDIRECT_URI,
environment: 'production'
}, overrides));
}Before (repeated):
let org = nforce.createConnection({
clientId: FAKE_CLIENT_ID,
clientSecret: FAKE_CLIENT_ID,
redirectUri: FAKE_REDIRECT_URI,
environment: 'production'
});After:
let org = makeOrg();
// Or, for variant:
let org = makeOrg({ environment: 'sandbox' });Steps:
- Add
makeOrgfunction after the constant declarations at the top oftest/connection.js. - Replace each verbatim
nforce.createConnection({clientId: FAKE_CLIENT_ID, ...})call withmakeOrg()ormakeOrg({ <changed option> }). - Run
npm testto confirm all tests still pass.
Smell: Magic Strings (Issue #27)
Technique: Replace Magic Number with Symbolic Constant
Files: lib/auth.js, lib/http.js, lib/constants.js
Risk: Low | Complexity: Low | Impact: Medium
Problem: The string literals 'sandbox', 'single', and 'multi' are used as direct string comparisons in multiple files even though constants.js already defines CONST.ENVS = ['sandbox', 'production'] and CONST.MODES = ['multi', 'single']. The constants exist but their individual values are never imported for comparisons.
Recommended additions to lib/constants.js:
const SANDBOX = 'sandbox';
const SINGLE_MODE = 'single';
const constants = {
...
SANDBOX,
SINGLE_MODE,
};Usage in lib/auth.js (after importing CONST):
// Before:
return this.environment === 'sandbox' ? ...
// After:
return this.environment === CONST.SANDBOX ? ...Usage in lib/http.js:
// Before:
if (jBody.access_token && this.mode === 'single') {
// After:
if (jBody.access_token && this.mode === CONST.SINGLE_MODE) {Steps:
- Add
SANDBOXandSINGLE_MODEnamed constants tolib/constants.js. - Export them from the
constantsobject. - Import
CONSTinlib/auth.js(it is not currently imported there — add the require). - Replace all
'sandbox'comparisons inauth.jswithCONST.SANDBOX. - Replace
'single'inhttp.jsandauth.jswithCONST.SINGLE_MODE. - Run
npm test.
Smell: Uncommunicative Name (Issue #24)
Technique: Rename Method (applied to parameter)
File: lib/api.js, line 10
Risk: Low | Complexity: Low | Impact: Low
Before:
const _getOpts = function (d, opts = {}) {
let data = {};
if (opts.singleProp && d && !util.isObject(d)) {
data[opts.singleProp] = d;
} else if (util.isObject(d)) {
data = d;
}
...After:
const _getOpts = function (input, opts = {}) {
let data = {};
if (opts.singleProp && input && !util.isObject(input)) {
data[opts.singleProp] = input;
} else if (util.isObject(input)) {
data = input;
}
...Steps:
- Rename
dtoinputthroughout_getOptsinlib/api.js(4 occurrences within the function body). - Run
npm test.
Smell: Ambiguous Method Name (Issue #28)
Technique: Rename Method
File: lib/api.js, lines 226–234 and module.exports block
Risk: Medium | Complexity: Medium | Impact: Medium
Problem: The API dispatcher getBody (routing to attachment/document/contentversion data) shares its name with Record.prototype.getBody (retrieving binary body from a record). Both exist in the same domain, creating conceptual ambiguity.
Steps:
- Rename the function definition
const getBody = function(...)toconst getBinaryContent = function(...)inlib/api.js. - Update the export:
module.exports = { ..., getBinaryContent, ... }(removegetBody). - Search all existing callers:
test/crud.js,examples/, any external documentation. - Update caller references from
org.getBody(...)toorg.getBinaryContent(...). - Update
BODY_GETTER_MAPreferences that dispatch throughthis[getter]— thegettervalues (getDocumentBody,getAttachmentBody,getContentVersionData) are unaffected. - Run
npm test.
Breaking Change Note: This is a public API rename. For a published npm package it warrants either a major version bump or a deprecation shim:
// Deprecation shim (optional bridge):
Connection.prototype.getBody = function (data) {
process.emitWarning('getBody() is deprecated. Use getBinaryContent() instead.',
{ code: 'NFORCE8_DEPRECATED_GETBODY' });
return this.getBinaryContent(data);
};Smell: Feature Envy / Inappropriate Intimacy (Issue #12)
Technique: Move Method, Hide Delegate
Files: lib/multipart.js, lib/record.js, lib/api.js
Risk: Medium | Complexity: Medium | Impact: High
Problem: multipart.js reaches deeply into Record internals to build the multipart form:
const type = opts.sobject.getType(); // reaches into Record
const fileName = opts.sobject.getFileName(); // reaches into Record
isPatch ? opts.sobject.toChangedPayload() : opts.sobject.toPayload() // reaches in
opts.sobject.getBody() // reaches inThe Record class is the Information Expert for its own representation; multipart.js violates this by owning the "how to serialize a Record as multipart" logic.
Target design:
// lib/record.js — new method
Record.prototype.toMultipartForm = function (isPatch) {
const type = this.getType();
const entity = type === 'contentversion' ? 'content' : type;
const fieldName = type === 'contentversion' ? 'VersionData' : 'Body';
const safeFileName = this.getFileName() || 'file.bin';
const form = new FormData();
form.append(
'entity_' + entity,
new Blob(
[JSON.stringify(isPatch ? this.toChangedPayload() : this.toPayload())],
{ type: 'application/json' }
),
'entity',
);
const body = this.getBody();
if (hasNonEmptyAttachmentBody(body)) {
form.append(
fieldName,
new Blob([body], { type: mimeTypes.lookup(safeFileName) || 'application/octet-stream' }),
safeFileName,
);
}
return form;
};After (lib/api.js):
// insert:
if (CONST.MULTIPART_TYPES.includes(type)) {
opts.multipart = opts.sobject.toMultipartForm(false);
}
// update:
if (CONST.MULTIPART_TYPES.includes(type)) {
opts.multipart = opts.sobject.toMultipartForm(true);
}After (lib/multipart.js): multipart.js can be simplified to a thin delegation shim or removed entirely once toMultipartForm is proven and all callers updated.
Steps:
- Move
hasNonEmptyAttachmentBodyand the FormData construction logic intoRecord.prototype.toMultipartForm. - Add
require('mime-types')andrequire('mime-types')usage torecord.js, or keepmultipart.jsas a pure helper that is called only bytoMultipartForm. - Update
lib/api.jsto callopts.sobject.toMultipartForm(false/true)instead ofmultipart(opts). - Update the
applyBodyhelper introduced in R07 to delegate totoMultipartForm. - Run
npm test.
Dependency: Implement after R07 (applyBody extraction).
Smell: Indecent Exposure (Issue #1)
Technique: Hide Method, Extract Interface
Files: lib/auth.js, lib/api.js, lib/http.js, index.js
Risk: High | Complexity: High | Impact: High
Problem: Private helpers (identified by underscore prefix) are exported from their modules, and then mixed indiscriminately onto Connection.prototype via Object.assign. This makes org._authEndpoint(), org._apiRequest(), org._getOpts(), etc. genuinely callable from external code.
Exported private symbols currently on Connection prototype:
lib/auth.js:_authEndpoint,_loginEndpoint,_revokeEndpoint,_notifyAndResolve,_resolveOAuthlib/api.js:_getOptslib/http.js:_apiAuthRequest,_apiRequest
Target Architecture:
The simplest compliant approach within the existing mixin pattern is to keep private symbols as module-local functions (not exported) and bind them to the instance in the Connection constructor using Object.defineProperty with enumerable: false:
// index.js — Connection constructor
const Connection = function (opts) {
// ... existing setup ...
// Bind private helpers non-enumerably
Object.defineProperty(this, '_getOpts', {
value: _getOpts.bind(this),
enumerable: false, writable: false, configurable: false
});
Object.defineProperty(this, '_apiRequest', {
value: _apiRequest.bind(this),
enumerable: false, writable: false, configurable: false
});
// ... etc for all private helpers
};Alternative (simpler, smaller change surface): Introduce a private namespace object:
// In Connection constructor:
const _private = {
getOpts: _getOpts.bind(this),
apiRequest: _apiRequest.bind(this),
apiAuthRequest: _apiAuthRequest.bind(this),
// ...
};
// Store privately (not enumerable):
Object.defineProperty(this, '_private', { value: _private, enumerable: false });Then all internal callers use this._private.apiRequest(opts) instead of this._apiRequest(opts).
Steps:
- Audit all callers of each private helper in the codebase (use grep for
this\._getOpts,this\._apiRequest, etc.). - For
_getOpts: it is called by all public API methods inapi.js. These are on the prototype and usethis._getOpts; they already work after R18 since the binding installs the function on the instance. - For test files that call private methods directly (e.g.,
test/connection.jslines 377–443): refactor tests to exercise the observable public behaviour instead. - Remove private symbols from
module.exportsin each of the three files. - Update
index.jsConnectionconstructor to install private bindings. - Run
npm testafter each file is updated.
Risk Mitigation:
- This is the highest-risk recommendation. Implement after full test suite runs green on all prior phases.
- Maintain a backwards-compatibility shim for one major version if the package has downstream consumers relying on private methods.
- Document the breaking change clearly in CHANGELOG.md.
Smell: God Module (Issue #3)
Technique: Extract Class (module-level), Move Method
File: lib/api.js (503 lines, 30 exported symbols)
Risk: High | Complexity: High | Impact: High
Problem: lib/api.js combines eight conceptually distinct concerns:
- System Metadata:
getVersions,getResources,getSObjects,getMetadata,getDescribe,getLimits - Identity:
getPasswordStatus,updatePassword,getIdentity - CRUD:
insert,update,upsert,delete,getRecord - Binary/Blob:
getBody/getBinaryContent,getAttachmentBody,getDocumentBody,getContentVersionData - Query/Search:
query,queryAll,search,_queryHandler,respToJson - URL Access:
getUrl,putUrl,postUrl,deleteUrl,_urlRequest - Apex REST:
apexRest - Streaming:
createStreamClient,subscribe,stream
Target file structure:
lib/
crud.js — insert, update, upsert, delete, getRecord
query.js — query, queryAll, search, _queryHandler, respToJson
metadata.js — getVersions, getResources, getSObjects, getMetadata, getDescribe, getLimits, getIdentity, getPasswordStatus, updatePassword
blob.js — getBinaryContent, getAttachmentBody, getDocumentBody, getContentVersionData
url.js — getUrl, putUrl, postUrl, deleteUrl, _urlRequest
apexrest.js — apexRest
streaming.js — createStreamClient, subscribe, stream
apiutils.js — _getOpts, sobjectPath, resolveId, resolveType, requireForwardSlash, applyBody (from R07)
Updated index.js:
const crudMethods = require('./lib/crud');
const queryMethods = require('./lib/query');
const metadataMethods = require('./lib/metadata');
const blobMethods = require('./lib/blob');
const urlMethods = require('./lib/url');
const apexMethods = require('./lib/apexrest');
const streamingMethods = require('./lib/streaming');
Object.assign(Connection.prototype,
httpMethods, authMethods,
crudMethods, queryMethods, metadataMethods, blobMethods,
urlMethods, apexMethods, streamingMethods
);Steps:
- Begin with
streaming.js— the most self-contained concern (createsFDCStream.Client, no shared utilities beyond_getOpts). - Extract
query.jsnext —_queryHandlerandrespToJsonare query-only concerns. - Extract
blob.js— blob methods all follow the same resource-path +blob: truepattern. - Extract
crud.js— depends onapplyBody(R07) andmultipart. - Extract
metadata.js— pure GET calls against system endpoints. - Extract
url.jsandapexrest.js— each is small and independent. - Create
apiutils.js(orlib/requestutils.js) for shared helpers (_getOpts,sobjectPath, etc.). - Update all
requirereferences across affected modules. - Update
index.jsto mix in from the new modules. - Run
npm testafter each extraction.
Dependency: Implement after R07 (applyBody), R17 (multipart into Record). Coordinate with R18 (private helpers) to avoid extracting private symbols into new public module exports.
Smell: Primitive Obsession / Data Clump (Issue #2)
Technique: Introduce Parameter Object, Replace Data Value with Object
Files: lib/api.js, lib/auth.js, lib/http.js, lib/optionhelper.js
Risk: High | Complexity: High | Impact: High
Problem: A single mutable plain object (opts) accumulates all properties — OAuth credentials, HTTP verb, URL fragments, serialized body, retry counter, feature flags — as it flows through every layer. Each layer is implicitly coupled to the full bag schema. Runtime state (_retryCount) is grafted onto the caller's object.
Target design — introduce three lightweight boundary objects:
// Represents what a caller provides to an API method (pre-HTTP):
class ApiRequestOptions {
constructor({ oauth, resource, method = 'GET', body, qs, headers, blob, raw, signal } = {}) {
this.oauth = oauth;
this.resource = resource;
this.method = method;
this.body = body;
this.qs = qs;
this.headers = headers;
this.blob = blob;
this.raw = raw;
this.signal = signal;
}
}
// Represents the resolved HTTP-layer request (post-optionhelper):
class HttpRequest {
constructor({ uri, method, headers, body, qs, signal } = {}) {
this.uri = uri;
this.method = method;
this.headers = headers;
this.body = body;
this.qs = qs;
this.signal = signal;
}
}
// Retry context — separate from the request:
class RetryContext {
constructor({ maxRetries = 1, count = 0 } = {}) {
this.maxRetries = maxRetries;
this.count = count;
}
get canRetry() { return this.count < this.maxRetries; }
increment() { return new RetryContext({ maxRetries: this.maxRetries, count: this.count + 1 }); }
}Migration strategy (incremental):
- Start with
RetryContextonly: removeopts._retryCountand pass context as a second parameter to_apiRequest. This is a low-disruption first step. - Introduce
HttpRequestas the output type ofoptionhelper.getApiRequestOptions. Internal tohttp.js; no external surface change required. - Introduce
ApiRequestOptionsfor the boundary between public API methods and_apiRequest. This is the highest-effort step as it touches every API method inapi.js.
Immediate partial improvement (extract retry state only — see also R21):
// _apiRequest with explicit retry context:
const _apiRequest = function (opts, retryCtx = new RetryContext()) {
// ...
.catch((err) => {
if (isAuthError(err) && this.autoRefresh && retryCtx.canRetry) {
return this.autoRefreshToken(opts).then(() =>
this._apiRequest(opts, retryCtx.increment())
);
}
throw err;
});
};Steps: See detailed sub-steps in R21 for the retry extraction. Full typed-object introduction is an ongoing refactoring and need not be done in a single commit.
Smell: Temporary Field, Status Variable (Issue #8)
Technique: Split Temporary Variable, Remove Assignments to Parameters
File: lib/http.js, lines 167–182
Risk: Medium | Complexity: Medium | Impact: Medium
Problem: opts._retryCount is used as a guard to prevent infinite recursion during auto-refresh. It is written onto the caller's opts object — an object the caller owns. opts._refreshResult is already removed by R01.
Before:
return this.autoRefreshToken(opts).then((res) => {
opts._retryCount = 1;
return this._apiRequest(opts);
});After — pass retry state as a separate parameter (can be private/internal; callers never set it):
const _apiRequest = function (opts, _retryCount = 0) {
const ropts = optionHelper.getApiRequestOptions(opts);
ropts.signal = buildSignal(ropts.signal, this.timeout);
const uri = optionHelper.getFullUri(ropts);
const sobject = opts.sobject;
return fetch(uri, ropts)
.then((res) => responseFailureCheck(res))
.then((res) => unsuccessfulResponseCheck(res))
.then((res) => { /* blob/json/text handling ... */ })
.then((body) => addSObjectAndId(body, sobject))
.catch((err) => {
if (
isAuthError(err) &&
this.autoRefresh === true &&
hasRefreshCredentials(opts) &&
_retryCount === 0
) {
return this.autoRefreshToken(opts).then(() =>
this._apiRequest(opts, 1)
);
}
throw err;
});
};Where isAuthError and hasRefreshCredentials are extracted helper functions:
function isAuthError(err) {
return err.errorCode === 'INVALID_SESSION_ID' || err.errorCode === 'Bad_OAuth_Token';
}
function hasRefreshCredentials(opts) {
return opts.oauth?.refresh_token || (this.username && this.password);
}Steps:
- Add
_retryCount = 0as a second parameter to_apiRequest. - Replace
!opts._retryCountguard with_retryCount === 0. - Replace
opts._retryCount = 1mutation with passing1as the second argument tothis._apiRequest(opts, 1). - Extract
isAuthError(err)as a module-level helper. - Verify
_apiRequestis never called externally with a_retryCountargument (it should not be; callers always pass only opts). - Run
npm test.
Smell: Inconsistent Module Pattern (Issue #30)
Technique: Extract Superclass pattern, Substitute Algorithm
Files: index.js, lib/record.js, lib/fdcstream.js
Risk: Medium | Complexity: High | Impact: Medium
Problem: fdcstream.js uses ES6 class syntax while index.js (Connection) and lib/record.js use the older ES5 constructor-function-with-prototype pattern. This is a stylistic inconsistency that increases cognitive load for contributors unfamiliar with both patterns.
Target design for Record:
class Record {
constructor(data) {
this.attributes = {};
this._changed = new Set();
this._previous = {};
this._fields = Object.entries(data).reduce(/* ... same logic ... */);
}
static fromResponse(data) {
const rec = new Record(data);
rec.reset();
return rec;
}
get(field) { /* ... */ }
set(field, value) { /* ... */ }
// ... all existing prototype methods as class methods ...
}
module.exports = Record;Target design for Connection (with prototype-mixin maintained):
class Connection {
constructor(opts) {
// ... same body as the existing constructor function ...
}
}
// Mixin remains valid with ES6 class:
Object.assign(Connection.prototype, httpMethods, authMethods, apiMethods);Steps:
- Convert
lib/record.jsfirst: translateconst Record = function(data) {...}and allRecord.prototype.*assignments to aclass Record { ... }body. - Verify no external code reads
Record.prototypedirectly. - Convert
index.jsConnection function toclass Connection. - Verify the mixin
Object.assign(Connection.prototype, ...)still works (it does; classes are syntactic sugar over prototypes in JS). - Run
npm testafter each conversion.
Sequencing Note: This is the lowest-priority item in Phase 3. It is purely stylistic and carries no functional benefit. Implement it last, after all functional improvements are in place and tests are green.
| Recommendation | Primary Risk | Mitigation |
|---|---|---|
| R18 (Separate private helpers) | External code may call org._apiRequest() etc. |
Release as a major version bump; add deprecation warnings; maintain one-version shim |
| R19 (Sub-divide api.js) | Cross-module require graph changes; shared helper dependencies | Extract one module at a time; run tests between each extraction |
| R20 (Typed request objects) | All API method signatures change at the boundary | Migrate incrementally; start with retry context (R21) only |
| Recommendation | Primary Risk | Mitigation |
|---|---|---|
| R14 (Rename getBody to getBinaryContent) | Public API rename; downstream breakage | Emit deprecation warning from old name; keep shim for one major version |
| R17 (Move multipart into Record) | Requires adding mime-types dependency to record.js |
Record already indirectly depends on the data; keep mime-types in a helper module that Record delegates to |
| R21 (Separate retry state) | Internal API change; verify no external callers depend on opts._retryCount |
grep for _retryCount across all files first |
| R22 (ES6 class syntax) | Prototype-mixin pattern unchanged, but class syntax may confuse legacy tooling | Test across minimum supported Node.js version (>22 — no issue here) |
Phase 1 (all independent, can be done in any order):
R01 → R02 → R03 → R04 → R05 → R06 → R16
Phase 2 (R08 before R12; others independent):
R07 (extract applyBody)
R08 (extract resolveEndpoint) ← before R12
R09 (inline _resolveOAuth)
R10 (fail-fast guard)
R11 (makeOrg test helper)
R12 (magic string constants) ← after R08
R13 (rename _getOpts param)
R14 (rename getBody)
Phase 3 (strict ordering required):
R21 (retry state separation) ← prerequisite for R20
R07 already done ← prerequisite for R17
R17 (multipart into Record) ← prerequisite for R19
R19 (subdivide api.js) ← coordinate with R18
R18 (hide private helpers) ← last, after all modules stabilized
R20 (typed request objects) ← ongoing, can begin with R21 as first step
R22 (ES6 class syntax) ← truly last, purely cosmetic
| File | Before | After | Technique |
|---|---|---|---|
lib/http.js |
Two 6-line AbortSignal blocks | buildSignal(signal, timeout) helper |
Extract Method |
lib/http.js |
opts._retryCount = 1 mutation |
_apiRequest(opts, 1) second param |
Remove Assignments to Parameters |
lib/http.js |
opts._refreshResult = res dead write |
Deleted | Remove Dead Code |
lib/api.js |
Duplicate multipart/JSON conditional | applyBody(opts, type, payloadFn) |
Extract Method |
lib/auth.js |
Three environment === 'sandbox' conditionals |
resolveEndpoint(env, prod, test) |
Extract Method, Consolidate Conditional |
lib/auth.js |
_resolveOAuth one-liner wrapper |
Promise.resolve(newOauth) inline |
Inline Method |
lib/api.js |
_getOpts(d, ...) |
_getOpts(input, ...) |
Rename Method (parameter) |
lib/api.js |
getBody name collision |
getBinaryContent |
Rename Method |
lib/errors.js |
emptyResponse() no .type |
.type = 'empty-response' added |
Error API alignment |
index.js |
require('./package.json').sfdx.api |
CONST.API |
Inline Temp |
test/connection.js |
17 inline createConnection(...) blocks |
makeOrg(overrides) helper |
Extract Method |
lib/multipart.js |
Deep reach into Record internals | record.toMultipartForm(isPatch) |
Move Method |
lib/api.js |
503 lines, 8 concerns | Domain modules: crud, query, metadata, etc. | Extract Class (module) |
lib/constants.js |
ENVS, MODES arrays only |
+ SANDBOX, SINGLE_MODE named strings |
Replace Magic Number with Symbolic Constant |