Skip to content
Open
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
147 changes: 147 additions & 0 deletions src/collections/d360Dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RegistryAccess } from '../registry/registryAccess';
import { SourceComponent } from '../resolve/sourceComponent';
import { ComponentSet } from './componentSet';

/**
* Any Data Cloud dataspace-scoped component is stored as a PAIR of files: a payload `<name>.json`
* carrying the component's definition, and a `<name>.meta.json` sidecar carrying
* `{ componentType, componentName, dataspaceName, retrieveWith, dependsOn }`. This module is
* type-agnostic — it works off the sidecar convention alone, so it covers every dataspace-scoped
* type without per-type logic.
*
* The sidecar declares TWO distinct relationship lists, and they mean different things.
* `retrieveWith` is the set of components that travel together with this one as a single deployable
* unit — the deploy closure that {@link expandD360ComponentSet} walks. `dependsOn` is
* informational metadata about what this component references (the components it points at) and is
* NOT used to build the deploy closure.
*
* Both lists reference the target component's `componentName` — NOT the SDR fullName
* (e.g. `<dataspace>.<name>`). Resolution therefore matches on each component's sidecar
* `componentName`, which every dataspace-scoped type carries regardless of how its payload is shaped
* (payload shapes vary by type — some are objects, some are bare arrays).
*
* This module is intentionally standalone: it does NOT modify `ComponentSet` or any deploy
* machinery. A caller (the CLI plugin) resolves the full project into `full`, decides what the
* user requested into `requested`, and calls {@link expandD360ComponentSet} to obtain
* the exact closure to deploy — the requested components plus every dataspace-scoped component in
* their transitive `retrieveWith` set, and nothing else.
*/

/** The adapter strategy id shared by every dataspace-scoped type. */
const D360_ADAPTER = 'd360';

/** Filename suffix of the per-component metadata sidecar (`<name>.meta.json`). */
const METADATA_SIDECAR_SUFFIX = '.meta.json';

type ComponentRef = { componentType?: string; componentName?: string };

type ComponentSidecar = {
componentType?: string;
componentName?: string;
dataspaceName?: string;
/** Components that deploy together with this one as one unit — drives the deploy closure. */
retrieveWith?: ComponentRef[];
/** Informational: what this component references (e.g. source DMOs). Not used for the closure. */
dependsOn?: ComponentRef[];
};

const isD360 = (component: SourceComponent): boolean => component.type.strategies?.adapter === D360_ADAPTER;

const readJson = <T>(component: SourceComponent, path: string | undefined): T | undefined => {
if (!path || !component.tree.exists(path)) {
return undefined;
}
try {
return JSON.parse(component.tree.readFileSync(path).toString()) as T;
} catch {
return undefined;
}
};

/** The `<name>.meta.json` sidecar sitting next to the payload content file. */
const getSidecarPath = (component: SourceComponent): string | undefined =>
component.content?.replace(/\.json$/, METADATA_SIDECAR_SUFFIX);

/** The component's own identity: the sidecar `componentName` (the key space that `retrieveWith` references). */
const getComponentName = (component: SourceComponent): string | undefined =>
readJson<ComponentSidecar>(component, getSidecarPath(component))?.componentName;

/** The `componentName` values this component lists in its sidecar `retrieveWith` (its deploy-closure peers). */
const getRetrieveWithNames = (component: SourceComponent): string[] =>
(readJson<ComponentSidecar>(component, getSidecarPath(component))?.retrieveWith ?? [])
.map((entry) => entry.componentName)
.filter((name): name is string => Boolean(name));

/**
* Build the minimal deploy closure for dataspace-scoped components.
*
* The result is the requested components plus the transitive closure of their dataspace-scoped
* `retrieveWith` peers (resolved via `retrieveWith` -> sidecar `componentName`), and nothing else.
* Non-dataspace-scoped requested components pass through unchanged, so this is safe to call on any set.
*
* @param full A ComponentSet with every candidate component (the whole project); only its dataspace-scoped members are indexed for lookup.
* @param requested The components the user asked to deploy.
* @param registry Optional RegistryAccess to seed the resulting ComponentSet with (defaults to a fresh one).
* @returns A new ComponentSet containing the requested components plus their dataspace-scoped retrieveWith closure.
*/
export const expandD360ComponentSet = (
full: ComponentSet,
requested: ComponentSet,
registry?: RegistryAccess
): ComponentSet => {
// Index dataspace-scoped candidates by their sidecar componentName (the retrieveWith key space).
const byComponentName = new Map<string, SourceComponent>();
for (const component of full.getSourceComponents()) {
if (isD360(component)) {
const componentName = getComponentName(component);
if (componentName) {
byComponentName.set(componentName, component);
}
}
}

const result = new ComponentSet([], registry);
const seen = new Set<SourceComponent>();
const worklist: SourceComponent[] = [];

// Seed with everything the user requested; non-dataspace-scoped members are kept as-is.
for (const component of requested.getSourceComponents()) {
if (!seen.has(component)) {
seen.add(component);
result.add(component);
if (isD360(component)) {
worklist.push(component);
}
}
}

// Walk retrieveWith transitively, pulling in only the referenced dataspace-scoped components.
while (worklist.length) {
const component = worklist.pop()!;
for (const peerName of getRetrieveWithNames(component)) {
const peer = byComponentName.get(peerName);
if (peer && !seen.has(peer)) {
seen.add(peer);
result.add(peer);
worklist.push(peer);
}
}
}

return result;
};
1 change: 1 addition & 0 deletions src/collections/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export {
FromManifestOptions,
} from './types';
export { ComponentSetBuilder, ComponentSetOptions } from './componentSetBuilder';
export { expandD360ComponentSet } from './d360Dependencies';
87 changes: 87 additions & 0 deletions src/convert/transformers/d360MetadataTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { join } from 'node:path';
import { DEFAULT_PACKAGE_ROOT_SFDX } from '../../common/constants';
import { SourcePath } from '../../common/types';
import { trimUntil } from '../../utils/path';
import { METADATA_SIDECAR_SUFFIX } from '../../resolve/adapters/d360SourceAdapter';
import { getReplacementStreamForReadable } from '../replacements';
import { WriteInfo } from '../types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { BaseMetadataTransformer } from './baseMetadataTransformer';

// The Data Cloud wrapper root that must be preserved on disk and in the mdapi package. The full
// wire path is `d360/dataspace/<ds>/<typeDir>/<name>.json`; we anchor on `d360` so the whole
// dataspace-scoped structure below it is kept verbatim.
const DATASPACE_ROOT = 'd360';

/**
* Transformer for any Data Cloud dataspace-scoped type. It is type-agnostic — it operates purely on
* the `d360/dataspace/<dataspace>/<typeDir>/` path shape and the sidecar convention, so it applies
* unchanged to every dataspace-scoped type; onboarding a new one is a registry change, not a code change.
*
* Each component is a PAIR of generic `.json` files nested under a
* `d360/dataspace/<dataspace>/<typeDir>/` wrapper: the payload `<name>.json` (the component's
* content) and a `<name>.meta.json` sidecar (`{ componentType, componentName, dataspaceName,
* retrieveWith, dependsOn }`). The stock `calculateRelativePath` would collapse the path to
* `<typeDir>/<name>.json` (dropping the `d360/dataspace/<ds>/` prefix), so this transformer instead
* preserves the whole path from `d360` down and co-writes the sidecar. The layout is identical in
* both directions:
*
* - source format: `main/default/d360/dataspace/<ds>/<typeDir>/<name>.json` (+ `.meta.json`)
* - metadata format: `d360/dataspace/<ds>/<typeDir>/<name>.json` (+ `.meta.json`)
*/
export class D360MetadataTransformer extends BaseMetadataTransformer {
// eslint-disable-next-line @typescript-eslint/require-await, class-methods-use-this
public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
return getWriteInfos(component, 'metadata');
}

// eslint-disable-next-line @typescript-eslint/require-await, class-methods-use-this
public async toSourceFormat({ component }: { component: SourceComponent }): Promise<WriteInfo[]> {
return getWriteInfos(component, 'source');
}
}

const getWriteInfos = (component: SourceComponent, targetFormat: 'source' | 'metadata'): WriteInfo[] =>
// walkContent() yields only the payload file(s); for each we also emit its sibling
// `<name>.meta.json` sidecar when present, so both halves of the pair are written.
component.walkContent().flatMap((path) => {
const infos: WriteInfo[] = [
{
source: getReplacementStreamForReadable(component, path),
output: getD360Destination(path, targetFormat),
},
];
const sidecar = path.replace(/\.json$/, METADATA_SIDECAR_SUFFIX);
if (sidecar !== path && component.tree.exists(sidecar)) {
infos.push({
source: getReplacementStreamForReadable(component, sidecar),
output: getD360Destination(sidecar, targetFormat),
});
}
return infos;
});

/**
* Build the destination path preserving the `d360/dataspace/<ds>/<typeDir>/<name>.json` structure.
* Source format is rooted under `main/default`; metadata format keeps it at the package root.
*/
const getD360Destination = (source: SourcePath, targetFormat: 'source' | 'metadata'): SourcePath => {
const base = targetFormat === 'source' ? DEFAULT_PACKAGE_ROOT_SFDX : '';
// trimUntil keeps the path from `d360` onward (dataspace wrapper + type dir + file).
return join(base, trimUntil(source, DATASPACE_ROOT, true));
};
3 changes: 3 additions & 0 deletions src/convert/transformers/metadataTransformerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { LabelMetadataTransformer, LabelsMetadataTransformer } from './decompose
import { DecomposedPermissionSetTransformer } from './decomposedPermissionSetTransformer';
import { DecomposeExternalServiceRegistrationTransformer } from './decomposeExternalServiceRegistrationTransformer';
import { UiBundleMetadataTransformer } from './uiBundleMetadataTransformer';
import { D360MetadataTransformer } from './d360MetadataTransformer';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
Expand Down Expand Up @@ -60,6 +61,8 @@ export class MetadataTransformerFactory {
return new DecomposeExternalServiceRegistrationTransformer(this.registry, this.context);
case 'uiBundle':
return new UiBundleMetadataTransformer(this.registry, this.context);
case 'd360':
return new D360MetadataTransformer(this.registry, this.context);
default:
throw messages.createError('error_missing_transformer', [type.name, transformerId]);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export {
DestructiveChangesType,
FromSourceOptions,
FromManifestOptions,
expandD360ComponentSet,
} from './collections';

export {
Expand Down
27 changes: 27 additions & 0 deletions src/registry/metadataRegistry.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"botBlocks": "botblock",
"botTemplates": "bottemplate",
"bots": "bot",
"calculatedInsight": "calculatedinsight",
"contentTypes": "contenttypebundle",
"mktDataModelObject": "datamodelobject",
"documents": "document",
"emailservices": "emailservicesfunction",
"experiencePropertyTypeBundles": "experiencepropertytypebundle",
Expand Down Expand Up @@ -344,6 +346,7 @@
"integrationProviderDefinition": "integrationproviderdef",
"internalDataConnector": "internaldataconnector",
"internalOrganization": "internalorganization",
"json": "calculatedinsight",
"keywords": "keywordlist",
"labels": "customlabels",
"layout": "layout",
Expand Down Expand Up @@ -5422,6 +5425,30 @@
"adapter": "bundle"
},
"supportsPartialDelete": true
},
"calculatedinsight": {
"id": "calculatedinsight",
"name": "CalculatedInsight",
"suffix": "json",
"directoryName": "calculatedInsight",
"inFolder": false,
"strictDirectoryName": true,
"strategies": {
"adapter": "d360",
"transformer": "d360"
}
},
"datamodelobject": {
"id": "datamodelobject",
"name": "DataModelObject",
"suffix": "json",
"directoryName": "mktDataModelObject",
"inFolder": false,
"strictDirectoryName": true,
"strategies": {
"adapter": "d360",
"transformer": "d360"
}
}
}
}
6 changes: 4 additions & 2 deletions src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ export type MetadataType = {
| 'bundle'
| 'default'
| 'partiallyDecomposed'
| 'uiBundles';
| 'uiBundles'
| 'd360';
transformer?:
| 'decomposed'
| 'staticResource'
Expand All @@ -170,7 +171,8 @@ export type MetadataType = {
| 'decomposedLabels'
| 'decomposedPermissionSet'
| 'decomposeExternalServiceRegistration'
| 'uiBundle';
| 'uiBundle'
| 'd360';
decomposition?: 'topLevel' | 'folderPerType';
recomposition?: 'startEmpty';
};
Expand Down
Loading