diff --git a/src/collections/d360Dependencies.ts b/src/collections/d360Dependencies.ts new file mode 100644 index 000000000..b24ad5791 --- /dev/null +++ b/src/collections/d360Dependencies.ts @@ -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 `.json` + * carrying the component's definition, and a `.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. `.`). 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 (`.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 = (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 `.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(component, getSidecarPath(component))?.componentName; + +/** The `componentName` values this component lists in its sidecar `retrieveWith` (its deploy-closure peers). */ +const getRetrieveWithNames = (component: SourceComponent): string[] => + (readJson(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(); + 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(); + 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; +}; diff --git a/src/collections/index.ts b/src/collections/index.ts index 04bd794c6..ec71285c9 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -23,3 +23,4 @@ export { FromManifestOptions, } from './types'; export { ComponentSetBuilder, ComponentSetOptions } from './componentSetBuilder'; +export { expandD360ComponentSet } from './d360Dependencies'; diff --git a/src/convert/transformers/d360MetadataTransformer.ts b/src/convert/transformers/d360MetadataTransformer.ts new file mode 100644 index 000000000..7911bb4c5 --- /dev/null +++ b/src/convert/transformers/d360MetadataTransformer.ts @@ -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///.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///` 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///` wrapper: the payload `.json` (the component's + * content) and a `.meta.json` sidecar (`{ componentType, componentName, dataspaceName, + * retrieveWith, dependsOn }`). The stock `calculateRelativePath` would collapse the path to + * `/.json` (dropping the `d360/dataspace//` 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///.json` (+ `.meta.json`) + * - metadata format: `d360/dataspace///.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 { + return getWriteInfos(component, 'metadata'); + } + + // eslint-disable-next-line @typescript-eslint/require-await, class-methods-use-this + public async toSourceFormat({ component }: { component: SourceComponent }): Promise { + 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 + // `.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///.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)); +}; diff --git a/src/convert/transformers/metadataTransformerFactory.ts b/src/convert/transformers/metadataTransformerFactory.ts index 7f875260d..de521bf57 100644 --- a/src/convert/transformers/metadataTransformerFactory.ts +++ b/src/convert/transformers/metadataTransformerFactory.ts @@ -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'); @@ -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]); } diff --git a/src/index.ts b/src/index.ts index a23a937cb..4b52a0e1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,7 @@ export { DestructiveChangesType, FromSourceOptions, FromManifestOptions, + expandD360ComponentSet, } from './collections'; export { diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index 57bb33529..a222e674e 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -51,7 +51,9 @@ "botBlocks": "botblock", "botTemplates": "bottemplate", "bots": "bot", + "calculatedInsight": "calculatedinsight", "contentTypes": "contenttypebundle", + "mktDataModelObject": "datamodelobject", "documents": "document", "emailservices": "emailservicesfunction", "experiencePropertyTypeBundles": "experiencepropertytypebundle", @@ -344,6 +346,7 @@ "integrationProviderDefinition": "integrationproviderdef", "internalDataConnector": "internaldataconnector", "internalOrganization": "internalorganization", + "json": "calculatedinsight", "keywords": "keywordlist", "labels": "customlabels", "layout": "layout", @@ -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" + } } } } diff --git a/src/registry/types.ts b/src/registry/types.ts index dc68fa737..f88ed69b8 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -161,7 +161,8 @@ export type MetadataType = { | 'bundle' | 'default' | 'partiallyDecomposed' - | 'uiBundles'; + | 'uiBundles' + | 'd360'; transformer?: | 'decomposed' | 'staticResource' @@ -170,7 +171,8 @@ export type MetadataType = { | 'decomposedLabels' | 'decomposedPermissionSet' | 'decomposeExternalServiceRegistration' - | 'uiBundle'; + | 'uiBundle' + | 'd360'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; }; diff --git a/src/resolve/adapters/d360SourceAdapter.ts b/src/resolve/adapters/d360SourceAdapter.ts new file mode 100644 index 000000000..ec7d8b5f7 --- /dev/null +++ b/src/resolve/adapters/d360SourceAdapter.ts @@ -0,0 +1,122 @@ +/* + * 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 { sep } from 'node:path'; +import { Messages } from '@salesforce/core/messages'; +import { SfError } from '@salesforce/core/sfError'; +import { baseName } from '../../utils/path'; +import { SourcePath } from '../../common/types'; +import { MetadataXml } from '../types'; +import { SourceComponent } from '../sourceComponent'; +import { MixedContentSourceAdapter } from './mixedContentSourceAdapter'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); + +/** Filename suffix of the per-component metadata sidecar (`.meta.json`). */ +export const METADATA_SIDECAR_SUFFIX = '.meta.json'; + +/** + * Handles any Data Cloud dataspace-scoped type whose layout nests a pair of JSON files per + * component under a `d360/dataspace///` wrapper. This adapter is type-agnostic: + * it derives everything from the path shape and the registered type, so onboarding a new + * dataspace-scoped type is a registry change (a type entry + directory mapping), never a code change. + * + * Each component is TWO files: the payload `.json` (the component's definition) and a sidecar + * `.meta.json` (`{ componentType, componentName, dataspaceName, retrieveWith, dependsOn }`). + * There is no `-meta.xml`. The payload file is the component's content; the sidecar rides along as an + * extra file (written by the transformer) and is NOT resolved as a separate component. + * + * The component fullName is dataspace-scoped: `.` (matching how the CLI + * addresses it, e.g. `:.`), where `` is the path + * segment immediately above the type's directory. + * + * __Example Structure__ (`` is the registered `directoryName` of any dataspace-scoped type): + * + *```text + * d360/dataspace/ + * ├── / + * | ├── / + * | | ├── .json -> :. + * | | ├── .meta.json -> sidecar (not its own component) + *``` + */ +export class D360SourceAdapter extends MixedContentSourceAdapter { + // Each component is a single JSON file; there is no separate metadata xml. + protected metadataWithContent = false; + + /** + * The single JSON file IS the content, not a root metadata xml. Returning undefined here + * (and from {@link getRootMetadataXmlPath}) ensures the base `getComponent` does NOT + * pre-build a SourceComponent with a plain, non-dataspace-scoped name — instead `populate` + * builds it with the correct `.` fullName. + */ + // eslint-disable-next-line class-methods-use-this + protected parseAsRootMetadataXml(): MetadataXml | undefined { + return undefined; + } + + // eslint-disable-next-line class-methods-use-this + protected getRootMetadataXmlPath(): SourcePath | undefined { + return undefined; + } + + protected populate(trigger: SourcePath, component?: SourceComponent): SourceComponent | undefined { + const contentPath = this.trimPathToContent(trigger); + // The `.meta.json` sidecar is part of its sibling payload component, not a component of + // its own. Skip it here so resolution never yields a spurious component; the transformer emits + // the sidecar alongside the payload. This exclusion is REQUIRED: `baseName` splits on the first + // `.`, so `.json` and `.meta.json` both reduce to `` — without it the sidecar + // would resolve to the same dataspace-scoped name as the payload and collide. + if (contentPath.endsWith(METADATA_SIDECAR_SUFFIX)) { + return undefined; + } + if (!contentPath || !this.tree.exists(contentPath)) { + throw new SfError( + messages.getMessage('error_expected_source_files', [trigger, this.type.name]), + 'ExpectedSourceFilesError' + ); + } + + const name = this.calculateD360Name(contentPath); + if (component) { + component.content = contentPath; + } else { + component = new SourceComponent( + { + name, + type: this.type, + content: contentPath, + }, + this.tree, + this.forceIgnore + ); + } + return component; + } + + /** + * Build `.` from a path shaped like + * `.../dataSpaces///.json`. The dataspace is the path segment + * immediately preceding the type's directory. + */ + private calculateD360Name(contentPath: SourcePath): string { + const pathParts = contentPath.split(sep); + const typeFolderIndex = pathParts.lastIndexOf(this.type.directoryName); + const dataspace = typeFolderIndex > 0 ? pathParts[typeFolderIndex - 1] : undefined; + const shortName = baseName(contentPath); + return dataspace ? `${dataspace}.${shortName}` : shortName; + } +} diff --git a/src/resolve/adapters/index.ts b/src/resolve/adapters/index.ts index 44ac84cf1..70900caee 100644 --- a/src/resolve/adapters/index.ts +++ b/src/resolve/adapters/index.ts @@ -21,3 +21,4 @@ export { DefaultSourceAdapter } from './defaultSourceAdapter'; export { BaseSourceAdapter } from './baseSourceAdapter'; export { DigitalExperienceSourceAdapter } from './digitalExperienceSourceAdapter'; export { UiBundlesSourceAdapter } from './uiBundlesSourceAdapter'; +export { D360SourceAdapter } from './d360SourceAdapter'; diff --git a/src/resolve/adapters/sourceAdapterFactory.ts b/src/resolve/adapters/sourceAdapterFactory.ts index 591d75f2c..5e0158c9d 100644 --- a/src/resolve/adapters/sourceAdapterFactory.ts +++ b/src/resolve/adapters/sourceAdapterFactory.ts @@ -28,6 +28,7 @@ import { DefaultSourceAdapter } from './defaultSourceAdapter'; import { DigitalExperienceSourceAdapter } from './digitalExperienceSourceAdapter'; import { UiBundlesSourceAdapter } from './uiBundlesSourceAdapter'; import { PartialDecomposedAdapter } from './partialDecomposedAdapter'; +import { D360SourceAdapter } from './d360SourceAdapter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -58,6 +59,8 @@ export class SourceAdapterFactory { return new UiBundlesSourceAdapter(type, this.registry, forceIgnore, this.tree); case 'partiallyDecomposed': return new PartialDecomposedAdapter(type, this.registry, forceIgnore, this.tree); + case 'd360': + return new D360SourceAdapter(type, this.registry, forceIgnore, this.tree); case 'default': case undefined: return new DefaultSourceAdapter(type, this.registry, forceIgnore, this.tree); diff --git a/test/registry/registryValidation.test.ts b/test/registry/registryValidation.test.ts index b794905e5..04c7f4f84 100644 --- a/test/registry/registryValidation.test.ts +++ b/test/registry/registryValidation.test.ts @@ -294,6 +294,7 @@ describe('will run preset tests', () => { 'partiallyDecomposed', 'digitalExperience', 'uiBundles', + 'd360', ]).includes(type.strategies?.adapter); }); });