diff --git a/src/collections/dataspaceScopedDependencies.ts b/src/collections/dataspaceScopedDependencies.ts new file mode 100644 index 0000000000..6a6e460436 --- /dev/null +++ b/src/collections/dataspaceScopedDependencies.ts @@ -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 `{ "": "" }`. 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>; +}; + +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(); + 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(); + 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; +}; diff --git a/src/collections/index.ts b/src/collections/index.ts index 04bd794c69..24cd6a1aa1 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 { expandDataspaceScopedComponentSet } from './dataspaceScopedDependencies'; diff --git a/src/convert/transformers/dataspaceScopedMetadataTransformer.ts b/src/convert/transformers/dataspaceScopedMetadataTransformer.ts new file mode 100644 index 0000000000..90d7275fa8 --- /dev/null +++ b/src/convert/transformers/dataspaceScopedMetadataTransformer.ts @@ -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///` + * wrapper. The stock `calculateRelativePath` would collapse that path to + * `/.json` (dropping the `dataSpaces//` prefix), so this transformer + * instead preserves the whole path from `dataSpaces/` down. The layout is identical in both + * directions: + * + * - source format: `main/default/dataSpaces///.json` + * - metadata format: `dataSpaces///.json` + */ +export class DataspaceScopedMetadataTransformer 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[] => + component.walkContent().map((path) => ({ + source: getReplacementStreamForReadable(component, path), + output: getDataspaceScopedDestination(path, targetFormat), + })); + +/** + * Build the destination path preserving the `dataSpaces///.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)); +}; diff --git a/src/convert/transformers/metadataTransformerFactory.ts b/src/convert/transformers/metadataTransformerFactory.ts index 7f875260d1..88b50c47b6 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 { DataspaceScopedMetadataTransformer } from './dataspaceScopedMetadataTransformer'; 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 'dataspaceScoped': + return new DataspaceScopedMetadataTransformer(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 207cb5ef6c..f4ab1795b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,7 @@ export { DestructiveChangesType, FromSourceOptions, FromManifestOptions, + expandDataspaceScopedComponentSet, } from './collections'; export { RegistryAccess, registry, getCurrentApiVersion, MetadataRegistry, MetadataType } from './registry'; diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index a86a1dcbfd..294665fc1c 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -50,7 +50,9 @@ "botBlocks": "botblock", "botTemplates": "bottemplate", "bots": "bot", + "calculatedInsights": "calculatedinsight", "contentTypes": "contenttypebundle", + "dataModelObjects": "datamodelobject", "documents": "document", "emailservices": "emailservicesfunction", "experiencePropertyTypeBundles": "experiencepropertytypebundle", @@ -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" + } } } } diff --git a/src/registry/types.ts b/src/registry/types.ts index c39f3e637c..822d7d64f9 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -156,7 +156,8 @@ export type MetadataType = { | 'bundle' | 'default' | 'partiallyDecomposed' - | 'uiBundles'; + | 'uiBundles' + | 'dataspaceScoped'; transformer?: | 'decomposed' | 'staticResource' @@ -165,7 +166,8 @@ export type MetadataType = { | 'decomposedLabels' | 'decomposedPermissionSet' | 'decomposeExternalServiceRegistration' - | 'uiBundle'; + | 'uiBundle' + | 'dataspaceScoped'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; }; diff --git a/src/resolve/adapters/dataspaceScopedSourceAdapter.ts b/src/resolve/adapters/dataspaceScopedSourceAdapter.ts new file mode 100644 index 0000000000..824fe28308 --- /dev/null +++ b/src/resolve/adapters/dataspaceScopedSourceAdapter.ts @@ -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//` + * 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: `.` (matching how the CLI + * addresses it, e.g. `CalculatedInsight:default.ciTest`), where `` 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 `.` 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 `.` from a path shaped like + * `.../dataSpaces///.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; + } +} diff --git a/src/resolve/adapters/index.ts b/src/resolve/adapters/index.ts index 44ac84cf18..77beeff7b1 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 { DataspaceScopedSourceAdapter } from './dataspaceScopedSourceAdapter'; diff --git a/src/resolve/adapters/sourceAdapterFactory.ts b/src/resolve/adapters/sourceAdapterFactory.ts index 591d75f2c3..9c32c70793 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 { DataspaceScopedSourceAdapter } from './dataspaceScopedSourceAdapter'; 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 'dataspaceScoped': + return new DataspaceScopedSourceAdapter(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 b794905e5e..9a79b1380a 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', + 'dataspaceScoped', ]).includes(type.strategies?.adapter); }); });