Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/services/folder-scoped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { addPrefixToKeys, transformOptions, FieldMapping } from '../utils/transf
import { NotFoundError } from '../core/errors';
import { validateName } from '../utils/validation/name-validator';
import { resolveFolderHeaders } from '../utils/folder/folder-headers';
import { resolveOverride, type ResourceOverride } from '../utils/overrides/resolve-override';

/**
* Matches single-quote characters in OData string literals — escaped to `''`
Expand Down Expand Up @@ -96,10 +97,21 @@ export class FolderScopedService extends BaseService {
const validatedName = validateName(resourceType, name);
const { folderId, folderKey, folderPath, ...queryOptions } = options;

// The compiled code passes the DEFAULT name (e.g. "CustomerConfig").
// At runtime, check if an admin override exists for this resource.
// If so, use the overridden name and folderPath for the API call.
const override: ResourceOverride | null = resolveOverride(
resourceType.toLowerCase(),
validatedName,
folderPath ?? '.'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using '.' as the fallback for an undefined folderPath creates a silent coupling with the override generation side — both sides must agree on the same sentinel value, or lookups will always miss when no explicit folder is passed. If the generation side uses a different default (empty string, undefined, or some other sentinel), overrides will silently not apply.

Either document the contract explicitly in the JSDoc of resolveOverride, or pass undefined / '' as-is and let the generation side handle the undefined case consistently:

Suggested change
folderPath ?? '.'
folderPath ?? ''

);
const resolvedName = override?.name ?? validatedName;
const resolvedFolderPath = override?.folderPath ?? folderPath;

const headers = resolveFolderHeaders({
folderId,
folderKey,
folderPath,
folderPath: resolvedFolderPath,
resourceType: `${resourceType}.getByName`,
fallbackFolderKey: this.config.folderKey,
});
Expand All @@ -110,7 +122,7 @@ export class FolderScopedService extends BaseService {

const apiOptions = {
...addPrefixToKeys(apiFieldOptions, ODATA_PREFIX, Object.keys(apiFieldOptions)),
'$filter': `Name eq '${validatedName.replace(SINGLE_QUOTE_RE, "''")}'`,
'$filter': `Name eq '${resolvedName.replace(SINGLE_QUOTE_RE, "''")}'`,
'$top': '1',
};

Expand All @@ -121,9 +133,9 @@ export class FolderScopedService extends BaseService {

const items = response.data?.value;
if (!items?.length) {
const folderHint = describeFolderForError(folderId, folderKey, folderPath);
const folderHint = describeFolderForError(folderId, folderKey, resolvedFolderPath);
throw new NotFoundError({
message: `${resourceType} '${validatedName}' not found${folderHint}.`,
message: `${resourceType} '${resolvedName}' not found${folderHint}.`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using resolvedName in the error message makes debugging harder: a developer who wrote assets.getByName("CustomerConfig") will see Asset 'ProdConfig' not found in their logs and have no idea why, since "ProdConfig" never appears in their code.

When an override is active, include both names in the message:

Suggested change
message: `${resourceType} '${resolvedName}' not found${folderHint}.`,
message: override
? `${resourceType} '${validatedName}' (overridden to '${resolvedName}') not found${folderHint}.`
: `${resourceType} '${validatedName}' not found${folderHint}.`,

});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The not-found error message now exposes resolvedName (the admin-override value, e.g. "ProdConfig") to the developer. The developer wrote getByName("CustomerConfig") — they have no knowledge of "ProdConfig" and can't act on an error about it.

Consider including the original name to make the error actionable:

message: `${resourceType} '${validatedName}'${override ? ` (overridden to '${resolvedName}')` : ''} not found${folderHint}.`,

This way users with overrides configured get a diagnostic hint, while users without overrides see the same clean message as before.

}

Expand Down
66 changes: 66 additions & 0 deletions src/utils/overrides/resolve-override.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Runtime resource override resolution.
*
* Reads the override dict from <script type="application/json" id="uipath-overrides">
* injected into index.html at deploy time (by syncResourceOverwritesToCdn).
*
* The override dict maps binding keys to new resource names/folders:
* { "asset.CustomerConfig.Shared/PublicApps": { "name": "ProdConfig", "folderPath": "Production/Live" } }
*
* The DEFAULT name from bindings.gen.ts (compiled into the bundle) acts as
* a LOOKUP KEY. This function resolves it to the overridden value at runtime.
*/

export interface ResourceOverride {
name: string;
folderPath: string;
}

type OverrideDict = Record<string, ResourceOverride>;

let cachedOverrides: OverrideDict | null = null;
let loaded = false;
Comment on lines +21 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-level mutable state will cause test pollution in vitest. Because vitest runs tests in the same process with a shared module registry, loaded and cachedOverrides persist across test files — once any test triggers loadOverrides(), the cached value is frozen for the rest of the suite unless vi.resetModules() is called explicitly.

Consider exporting a resetOverridesCache() function (for tests only, marked @internal) or refactoring to a factory/class so tests can create a fresh instance per test:

Suggested change
let cachedOverrides: OverrideDict | null = null;
let loaded = false;
export interface ResourceOverride {
name: string;
folderPath: string;
}
type OverrideDict = Record<string, ResourceOverride>;
let cachedOverrides: OverrideDict | null = null;
let loaded = false;
/** @internal — test helper only */
export function _resetOverridesCache(): void {
cachedOverrides = null;
loaded = false;
}


function loadOverrides(): OverrideDict {
if (loaded) return cachedOverrides ?? {};

loaded = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loaded = true is set before the element check at line 32. If this module initialises before <script id="uipath-overrides"> is in the DOM (e.g., a service is instantiated during app startup, before the injected script is parsed), loaded is permanently flipped to true and every subsequent call returns {}. The override system silently stops working for the entire session with no error.

Consider only setting loaded = true (and populating cachedOverrides) when the element is actually found and parsed successfully, or at minimum documenting that callers must not invoke any getByName* method until the DOM is fully loaded.


if (typeof document === 'undefined') return {};

const el = document.getElementById('uipath-overrides');
if (!el) return {};

try {
cachedOverrides = JSON.parse(el.textContent ?? '{}');
console.log('[UiPath SDK] Loaded overrides:', cachedOverrides);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.log calls must be removed from production code. The PR description lists "Remove console.log statements" as a TODO, but these statements are still present on lines 36, 39, and 62. They will emit SDK internals to the browser console for every app using this feature and should be removed before merging.

return cachedOverrides ?? {};
} catch {
console.warn('[UiPath SDK] Failed to parse uipath-overrides script tag');
return {};
}
}

/**
* Resolves a resource override at runtime.
*
* @param resourceType - e.g. "asset", "entity", "bucket"
* @param name - the DEFAULT resource name from compiled bindings (e.g. "CustomerConfig")
* @param folderPath - the DEFAULT folder path (e.g. "Shared/PublicApps")
* @returns the override if found, or null if no override exists
*/
export function resolveOverride(
resourceType: string,
name: string,
folderPath: string,
): ResourceOverride | null {
const overrides = loadOverrides();
const key = `${resourceType}.${name}.${folderPath}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dot-separated key format is ambiguous whenever a resource name or folder path contains a .. For example, an asset named "v2.0" in folder "Shared/Apps" produces asset.v2.0.Shared/Apps, which can't be unambiguously split back into its three parts.

Use a separator that cannot appear in resource names or folder paths, e.g. :::

Suggested change
const key = `${resourceType}.${name}.${folderPath}`;
const key = `${resourceType}::${name}::${folderPath}`;

The generation side (syncResourceOverwritesToCdn) must use the same separator.

const override = overrides[key] ?? null;

if (override) {
console.log(`[UiPath SDK] Override resolved: "${name}" → "${override.name}" (folder: "${override.folderPath}")`);
}

return override;
}
Loading