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
121 changes: 121 additions & 0 deletions src/collections/dataspaceScopedDependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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';

/**
* Data Cloud dataspace-scoped components (CalculatedInsight, DataModelObject) carry an explicit
* dependency declaration in their on-disk envelope, e.g.
* `{ "entityPayload": { "name": "ciTest" }, "dependsOn": [{ "DataModelObject": "account__dlm" }] }`.
*
* A `dependsOn` entry is `{ "<TypeName>": "<referenced entityPayload.name>" }`. The referenced
* value is the target component's `entityPayload.name` (e.g. `account__dlm`) — NOT the SDR
* fullName (e.g. `default.account`). Dependency resolution therefore matches on the envelope's
* `entityPayload.name`, read from each component's content file.
*
* 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 expandDataspaceScopedComponentSet} to obtain
* the exact closure to deploy — the requested components plus every dataspace-scoped component they
* transitively depend on, and nothing else.
*/

/** The adapter strategy id that marks a dataspace-scoped type (CalculatedInsight, DataModelObject). */
const DATASPACE_SCOPED_ADAPTER = 'dataspaceScoped';

type DataspaceScopedEnvelope = {
entityPayload?: { name?: string };
dependsOn?: Array<Record<string, string>>;
};

const isDataspaceScoped = (component: SourceComponent): boolean =>
component.type.strategies?.adapter === DATASPACE_SCOPED_ADAPTER;

/** Parse a dataspace-scoped component's envelope from its content file. Returns undefined if unreadable. */
const readEnvelope = (component: SourceComponent): DataspaceScopedEnvelope | undefined => {
if (!component.content) {
return undefined;
}
try {
return JSON.parse(component.tree.readFileSync(component.content).toString()) as DataspaceScopedEnvelope;
} catch {
return undefined;
}
};

/** The referenced `entityPayload.name` values a component declares in its `dependsOn`. */
const getDependencyNames = (component: SourceComponent): string[] =>
(readEnvelope(component)?.dependsOn ?? []).flatMap((entry) => Object.values(entry)).filter(Boolean);

/**
* Build the minimal deploy closure for dataspace-scoped components.
*
* The result is the requested components plus the transitive closure of their dataspace-scoped
* dependencies (resolved via `dependsOn` -> `entityPayload.name`), 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 dependency closure.
*/
export const expandDataspaceScopedComponentSet = (
full: ComponentSet,
requested: ComponentSet,
registry?: RegistryAccess
): ComponentSet => {
// Index dataspace-scoped candidates by their envelope entityPayload.name (the dependsOn key space).
const byPayloadName = new Map<string, SourceComponent>();
for (const component of full.getSourceComponents()) {
if (isDataspaceScoped(component)) {
const payloadName = readEnvelope(component)?.entityPayload?.name;
if (payloadName) {
byPayloadName.set(payloadName, 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 (isDataspaceScoped(component)) {
worklist.push(component);
}
}
}

// Walk dependsOn transitively, pulling in only the referenced dataspace-scoped components.
while (worklist.length) {
const component = worklist.pop()!;
for (const depName of getDependencyNames(component)) {
const dep = byPayloadName.get(depName);
if (dep && !seen.has(dep)) {
seen.add(dep);
result.add(dep);
worklist.push(dep);
}
}
}

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 { expandDataspaceScopedComponentSet } from './dataspaceScopedDependencies';
66 changes: 66 additions & 0 deletions src/convert/transformers/dataspaceScopedMetadataTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* 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 { getReplacementStreamForReadable } from '../replacements';
import { WriteInfo } from '../types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { BaseMetadataTransformer } from './baseMetadataTransformer';

// The dataspace wrapper directory that must be preserved on disk and in the mdapi package.
const DATASPACE_ROOT = 'dataSpaces';

/**
* Transformer for Data Cloud dataspace-scoped types (CalculatedInsight, DataModelObject).
*
* These are single generic `.json` files nested under a `dataSpaces/<dataspace>/<typeDir>/`
* wrapper. The stock `calculateRelativePath` would collapse that path to
* `<typeDir>/<name>.json` (dropping the `dataSpaces/<dataspace>/` prefix), so this transformer
* instead preserves the whole path from `dataSpaces/` down. The layout is identical in both
* directions:
*
* - source format: `main/default/dataSpaces/<ds>/<typeDir>/<name>.json`
* - metadata format: `dataSpaces/<ds>/<typeDir>/<name>.json`
*/
export class DataspaceScopedMetadataTransformer 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[] =>
component.walkContent().map((path) => ({
source: getReplacementStreamForReadable(component, path),
output: getDataspaceScopedDestination(path, targetFormat),
}));

/**
* Build the destination path preserving the `dataSpaces/<ds>/<typeDir>/<name>.json` structure.
* Source format is rooted under `main/default`; metadata format keeps it at the package root.
*/
const getDataspaceScopedDestination = (source: SourcePath, targetFormat: 'source' | 'metadata'): SourcePath => {
const base = targetFormat === 'source' ? DEFAULT_PACKAGE_ROOT_SFDX : '';
// trimUntil keeps the path from `dataSpaces` onward (including the dataspace + type dirs + 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 { DataspaceScopedMetadataTransformer } from './dataspaceScopedMetadataTransformer';

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 'dataspaceScoped':
return new DataspaceScopedMetadataTransformer(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 @@ -99,6 +99,7 @@ export {
DestructiveChangesType,
FromSourceOptions,
FromManifestOptions,
expandDataspaceScopedComponentSet,
} from './collections';

export { RegistryAccess, registry, getCurrentApiVersion, MetadataRegistry, MetadataType } from './registry';
Expand Down
26 changes: 26 additions & 0 deletions src/registry/metadataRegistry.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
"botBlocks": "botblock",
"botTemplates": "bottemplate",
"bots": "bot",
"calculatedInsights": "calculatedinsight",
"contentTypes": "contenttypebundle",
"dataModelObjects": "datamodelobject",
"documents": "document",
"emailservices": "emailservicesfunction",
"experiencePropertyTypeBundles": "experiencepropertytypebundle",
Expand Down Expand Up @@ -5394,6 +5396,30 @@
"adapter": "bundle"
},
"supportsPartialDelete": true
},
"calculatedinsight": {
"id": "calculatedinsight",
"name": "CalculatedInsight",
"suffix": "json",
"directoryName": "calculatedInsights",
"inFolder": false,
"strictDirectoryName": true,
"strategies": {
"adapter": "dataspaceScoped",
"transformer": "dataspaceScoped"
}
},
"datamodelobject": {
"id": "datamodelobject",
"name": "DataModelObject",
"suffix": "json",
"directoryName": "dataModelObjects",
"inFolder": false,
"strictDirectoryName": true,
"strategies": {
"adapter": "dataspaceScoped",
"transformer": "dataspaceScoped"
}
}
}
}
6 changes: 4 additions & 2 deletions src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ export type MetadataType = {
| 'bundle'
| 'default'
| 'partiallyDecomposed'
| 'uiBundles';
| 'uiBundles'
| 'dataspaceScoped';
transformer?:
| 'decomposed'
| 'staticResource'
Expand All @@ -165,7 +166,8 @@ export type MetadataType = {
| 'decomposedLabels'
| 'decomposedPermissionSet'
| 'decomposeExternalServiceRegistration'
| 'uiBundle';
| 'uiBundle'
| 'dataspaceScoped';
decomposition?: 'topLevel' | 'folderPerType';
recomposition?: 'startEmpty';
};
Expand Down
107 changes: 107 additions & 0 deletions src/resolve/adapters/dataspaceScopedSourceAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* 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');

/**
* Handles Data Cloud dataspace-scoped types (CalculatedInsight, DataModelObject) whose
* source-format layout nests a single JSON file per component under a `dataSpaces/<dataspace>/`
* wrapper. Each component is a single self-contained `.json` file (a ComponentEnvelope of
* `{ entityPayload, dependsOn }`) with no separate `-meta.xml`.
*
* The component fullName is dataspace-scoped: `<dataspace>.<name>` (matching how the CLI
* addresses it, e.g. `CalculatedInsight:default.ciTest`), where `<dataspace>` is the path
* segment immediately above the type's directory.
*
* __Example Structure__:
*
*```text
* dataSpaces/
* ├── default/
* | ├── calculatedInsights/
* | | ├── ciTest.json -> CalculatedInsight:default.ciTest
* | ├── dataModelObjects/
* | | ├── account.json -> DataModelObject:default.account
*```
*/
export class DataspaceScopedSourceAdapter 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 `<dataspace>.<name>` 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);
if (!contentPath || !this.tree.exists(contentPath)) {
throw new SfError(
messages.getMessage('error_expected_source_files', [trigger, this.type.name]),
'ExpectedSourceFilesError'
);
}

const name = this.calculateDataspaceScopedName(contentPath);
if (component) {
component.content = contentPath;
} else {
component = new SourceComponent(
{
name,
type: this.type,
content: contentPath,
},
this.tree,
this.forceIgnore
);
}
return component;
}

/**
* Build `<dataspace>.<name>` from a path shaped like
* `.../dataSpaces/<dataspace>/<typeDir>/<name>.json`. The dataspace is the path segment
* immediately preceding the type's directory.
*/
private calculateDataspaceScopedName(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;
}
}
1 change: 1 addition & 0 deletions src/resolve/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export { DefaultSourceAdapter } from './defaultSourceAdapter';
export { BaseSourceAdapter } from './baseSourceAdapter';
export { DigitalExperienceSourceAdapter } from './digitalExperienceSourceAdapter';
export { UiBundlesSourceAdapter } from './uiBundlesSourceAdapter';
export { DataspaceScopedSourceAdapter } from './dataspaceScopedSourceAdapter';
Loading