From 8f238d7a24eb9bfe45465c65ddd09aad2121f7ca Mon Sep 17 00:00:00 2001 From: Aparna Goyal Date: Tue, 22 Sep 2026 02:22:04 +0530 Subject: [PATCH 1/4] feat: support Data Cloud dataspace-scoped metadata types Add SDR support for Data Cloud dataspace-scoped metadata (CalculatedInsight, DataModelObject). Their on-disk shape differs from every existing type: each component is a pair of generic .json files - a payload .json and a .meta.json sidecar - nested under a d360/dataspace/// wrapper, with no -meta.xml. The component fullName is dataspace-scoped (.). - registry: register the two types with dataspaceScoped adapter/transformer strategies and index the `json` suffix so bare .json files resolve as metadata. - resolve: DataspaceScopedSourceAdapter derives the . fullName from the path and folds the .meta.json sidecar into its payload component. - convert: DataspaceScopedMetadataTransformer preserves the full d360/... path in both directions and co-writes the sidecar alongside the payload. - collections: expandDataspaceScopedComponentSet builds the minimal deploy closure from each component's sidecar retrieveWith set. --- .../dataspaceScopedDependencies.ts | 148 ++++++++++++++++++ src/collections/index.ts | 1 + .../dataspaceScopedMetadataTransformer.ts | 87 ++++++++++ .../metadataTransformerFactory.ts | 3 + src/index.ts | 1 + src/registry/metadataRegistry.json | 27 ++++ src/registry/types.ts | 6 +- .../adapters/dataspaceScopedSourceAdapter.ts | 122 +++++++++++++++ src/resolve/adapters/index.ts | 1 + src/resolve/adapters/sourceAdapterFactory.ts | 3 + test/registry/registryValidation.test.ts | 1 + 11 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/collections/dataspaceScopedDependencies.ts create mode 100644 src/convert/transformers/dataspaceScopedMetadataTransformer.ts create mode 100644 src/resolve/adapters/dataspaceScopedSourceAdapter.ts diff --git a/src/collections/dataspaceScopedDependencies.ts b/src/collections/dataspaceScopedDependencies.ts new file mode 100644 index 0000000000..1d7c59293b --- /dev/null +++ b/src/collections/dataspaceScopedDependencies.ts @@ -0,0 +1,148 @@ +/* + * 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 expandDataspaceScopedComponentSet} 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 expandDataspaceScopedComponentSet} 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 DATASPACE_SCOPED_ADAPTER = 'dataspaceScoped'; + +/** 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 isDataspaceScoped = (component: SourceComponent): boolean => + component.type.strategies?.adapter === DATASPACE_SCOPED_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 expandDataspaceScopedComponentSet = ( + 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 (isDataspaceScoped(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 (isDataspaceScoped(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 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..6c33c24fd4 --- /dev/null +++ b/src/convert/transformers/dataspaceScopedMetadataTransformer.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/dataspaceScopedSourceAdapter'; +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 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[] => + // 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: getDataspaceScopedDestination(path, targetFormat), + }, + ]; + const sidecar = path.replace(/\.json$/, METADATA_SIDECAR_SUFFIX); + if (sidecar !== path && component.tree.exists(sidecar)) { + infos.push({ + source: getReplacementStreamForReadable(component, sidecar), + output: getDataspaceScopedDestination(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 getDataspaceScopedDestination = (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 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 a23a937cb1..8d47fe8318 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,7 @@ export { DestructiveChangesType, FromSourceOptions, FromManifestOptions, + expandDataspaceScopedComponentSet, } from './collections'; export { diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index 57bb335297..c84f9d9f50 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": "dataspaceScoped", + "transformer": "dataspaceScoped" + } + }, + "datamodelobject": { + "id": "datamodelobject", + "name": "DataModelObject", + "suffix": "json", + "directoryName": "mktDataModelObject", + "inFolder": false, + "strictDirectoryName": true, + "strategies": { + "adapter": "dataspaceScoped", + "transformer": "dataspaceScoped" + } } } } diff --git a/src/registry/types.ts b/src/registry/types.ts index dc68fa7372..31e77c81a3 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -161,7 +161,8 @@ export type MetadataType = { | 'bundle' | 'default' | 'partiallyDecomposed' - | 'uiBundles'; + | 'uiBundles' + | 'dataspaceScoped'; transformer?: | 'decomposed' | 'staticResource' @@ -170,7 +171,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..cc60626e30 --- /dev/null +++ b/src/resolve/adapters/dataspaceScopedSourceAdapter.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 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); + // 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.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); }); }); From d2eb72f794f19a95b50a1bc6d98da80cb8d22c19 Mon Sep 17 00:00:00 2001 From: Aparna Goyal Date: Wed, 23 Sep 2026 02:00:38 +0530 Subject: [PATCH 2/4] refactor: rename dataspace-scoped adapter, transformer, and closure to D360 --- ...eScopedDependencies.ts => d360Dependencies.ts} | 15 +++++++-------- src/collections/index.ts | 2 +- ...aTransformer.ts => d360MetadataTransformer.ts} | 10 +++++----- .../transformers/metadataTransformerFactory.ts | 6 +++--- src/index.ts | 2 +- src/registry/metadataRegistry.json | 8 ++++---- src/registry/types.ts | 4 ++-- ...copedSourceAdapter.ts => d360SourceAdapter.ts} | 6 +++--- src/resolve/adapters/index.ts | 2 +- src/resolve/adapters/sourceAdapterFactory.ts | 6 +++--- test/registry/registryValidation.test.ts | 2 +- 11 files changed, 31 insertions(+), 32 deletions(-) rename src/collections/{dataspaceScopedDependencies.ts => d360Dependencies.ts} (92%) rename src/convert/transformers/{dataspaceScopedMetadataTransformer.ts => d360MetadataTransformer.ts} (91%) rename src/resolve/adapters/{dataspaceScopedSourceAdapter.ts => d360SourceAdapter.ts} (96%) diff --git a/src/collections/dataspaceScopedDependencies.ts b/src/collections/d360Dependencies.ts similarity index 92% rename from src/collections/dataspaceScopedDependencies.ts rename to src/collections/d360Dependencies.ts index 1d7c59293b..b24ad57912 100644 --- a/src/collections/dataspaceScopedDependencies.ts +++ b/src/collections/d360Dependencies.ts @@ -26,7 +26,7 @@ import { ComponentSet } from './componentSet'; * * 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 expandDataspaceScopedComponentSet} walks. `dependsOn` is + * 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. * @@ -37,13 +37,13 @@ import { ComponentSet } from './componentSet'; * * 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 + * 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 DATASPACE_SCOPED_ADAPTER = 'dataspaceScoped'; +const D360_ADAPTER = 'd360'; /** Filename suffix of the per-component metadata sidecar (`.meta.json`). */ const METADATA_SIDECAR_SUFFIX = '.meta.json'; @@ -60,8 +60,7 @@ type ComponentSidecar = { dependsOn?: ComponentRef[]; }; -const isDataspaceScoped = (component: SourceComponent): boolean => - component.type.strategies?.adapter === DATASPACE_SCOPED_ADAPTER; +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)) { @@ -100,7 +99,7 @@ const getRetrieveWithNames = (component: SourceComponent): string[] => * @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 expandDataspaceScopedComponentSet = ( +export const expandD360ComponentSet = ( full: ComponentSet, requested: ComponentSet, registry?: RegistryAccess @@ -108,7 +107,7 @@ export const expandDataspaceScopedComponentSet = ( // Index dataspace-scoped candidates by their sidecar componentName (the retrieveWith key space). const byComponentName = new Map(); for (const component of full.getSourceComponents()) { - if (isDataspaceScoped(component)) { + if (isD360(component)) { const componentName = getComponentName(component); if (componentName) { byComponentName.set(componentName, component); @@ -125,7 +124,7 @@ export const expandDataspaceScopedComponentSet = ( if (!seen.has(component)) { seen.add(component); result.add(component); - if (isDataspaceScoped(component)) { + if (isD360(component)) { worklist.push(component); } } diff --git a/src/collections/index.ts b/src/collections/index.ts index 24cd6a1aa1..ec71285c9d 100644 --- a/src/collections/index.ts +++ b/src/collections/index.ts @@ -23,4 +23,4 @@ export { FromManifestOptions, } from './types'; export { ComponentSetBuilder, ComponentSetOptions } from './componentSetBuilder'; -export { expandDataspaceScopedComponentSet } from './dataspaceScopedDependencies'; +export { expandD360ComponentSet } from './d360Dependencies'; diff --git a/src/convert/transformers/dataspaceScopedMetadataTransformer.ts b/src/convert/transformers/d360MetadataTransformer.ts similarity index 91% rename from src/convert/transformers/dataspaceScopedMetadataTransformer.ts rename to src/convert/transformers/d360MetadataTransformer.ts index 6c33c24fd4..7911bb4c59 100644 --- a/src/convert/transformers/dataspaceScopedMetadataTransformer.ts +++ b/src/convert/transformers/d360MetadataTransformer.ts @@ -17,7 +17,7 @@ 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/dataspaceScopedSourceAdapter'; +import { METADATA_SIDECAR_SUFFIX } from '../../resolve/adapters/d360SourceAdapter'; import { getReplacementStreamForReadable } from '../replacements'; import { WriteInfo } from '../types'; import { SourceComponent } from '../../resolve/sourceComponent'; @@ -44,7 +44,7 @@ const DATASPACE_ROOT = 'd360'; * - source format: `main/default/d360/dataspace///.json` (+ `.meta.json`) * - metadata format: `d360/dataspace///.json` (+ `.meta.json`) */ -export class DataspaceScopedMetadataTransformer extends BaseMetadataTransformer { +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'); @@ -63,14 +63,14 @@ const getWriteInfos = (component: SourceComponent, targetFormat: 'source' | 'met const infos: WriteInfo[] = [ { source: getReplacementStreamForReadable(component, path), - output: getDataspaceScopedDestination(path, targetFormat), + 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: getDataspaceScopedDestination(sidecar, targetFormat), + output: getD360Destination(sidecar, targetFormat), }); } return infos; @@ -80,7 +80,7 @@ const getWriteInfos = (component: SourceComponent, targetFormat: 'source' | 'met * 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 getDataspaceScopedDestination = (source: SourcePath, targetFormat: 'source' | 'metadata'): SourcePath => { +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 88b50c47b6..de521bf57e 100644 --- a/src/convert/transformers/metadataTransformerFactory.ts +++ b/src/convert/transformers/metadataTransformerFactory.ts @@ -26,7 +26,7 @@ import { LabelMetadataTransformer, LabelsMetadataTransformer } from './decompose import { DecomposedPermissionSetTransformer } from './decomposedPermissionSetTransformer'; import { DecomposeExternalServiceRegistrationTransformer } from './decomposeExternalServiceRegistrationTransformer'; import { UiBundleMetadataTransformer } from './uiBundleMetadataTransformer'; -import { DataspaceScopedMetadataTransformer } from './dataspaceScopedMetadataTransformer'; +import { D360MetadataTransformer } from './d360MetadataTransformer'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -61,8 +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); + 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 8d47fe8318..4b52a0e1c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,7 +100,7 @@ export { DestructiveChangesType, FromSourceOptions, FromManifestOptions, - expandDataspaceScopedComponentSet, + expandD360ComponentSet, } from './collections'; export { diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index c84f9d9f50..a222e674e9 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -5434,8 +5434,8 @@ "inFolder": false, "strictDirectoryName": true, "strategies": { - "adapter": "dataspaceScoped", - "transformer": "dataspaceScoped" + "adapter": "d360", + "transformer": "d360" } }, "datamodelobject": { @@ -5446,8 +5446,8 @@ "inFolder": false, "strictDirectoryName": true, "strategies": { - "adapter": "dataspaceScoped", - "transformer": "dataspaceScoped" + "adapter": "d360", + "transformer": "d360" } } } diff --git a/src/registry/types.ts b/src/registry/types.ts index 31e77c81a3..f88ed69b87 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -162,7 +162,7 @@ export type MetadataType = { | 'default' | 'partiallyDecomposed' | 'uiBundles' - | 'dataspaceScoped'; + | 'd360'; transformer?: | 'decomposed' | 'staticResource' @@ -172,7 +172,7 @@ export type MetadataType = { | 'decomposedPermissionSet' | 'decomposeExternalServiceRegistration' | 'uiBundle' - | 'dataspaceScoped'; + | 'd360'; decomposition?: 'topLevel' | 'folderPerType'; recomposition?: 'startEmpty'; }; diff --git a/src/resolve/adapters/dataspaceScopedSourceAdapter.ts b/src/resolve/adapters/d360SourceAdapter.ts similarity index 96% rename from src/resolve/adapters/dataspaceScopedSourceAdapter.ts rename to src/resolve/adapters/d360SourceAdapter.ts index cc60626e30..ec7d8b5f79 100644 --- a/src/resolve/adapters/dataspaceScopedSourceAdapter.ts +++ b/src/resolve/adapters/d360SourceAdapter.ts @@ -53,7 +53,7 @@ export const METADATA_SIDECAR_SUFFIX = '.meta.json'; * | | ├── .meta.json -> sidecar (not its own component) *``` */ -export class DataspaceScopedSourceAdapter extends MixedContentSourceAdapter { +export class D360SourceAdapter extends MixedContentSourceAdapter { // Each component is a single JSON file; there is no separate metadata xml. protected metadataWithContent = false; @@ -90,7 +90,7 @@ export class DataspaceScopedSourceAdapter extends MixedContentSourceAdapter { ); } - const name = this.calculateDataspaceScopedName(contentPath); + const name = this.calculateD360Name(contentPath); if (component) { component.content = contentPath; } else { @@ -112,7 +112,7 @@ export class DataspaceScopedSourceAdapter extends MixedContentSourceAdapter { * `.../dataSpaces///.json`. The dataspace is the path segment * immediately preceding the type's directory. */ - private calculateDataspaceScopedName(contentPath: SourcePath): string { + 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; diff --git a/src/resolve/adapters/index.ts b/src/resolve/adapters/index.ts index 77beeff7b1..70900caee5 100644 --- a/src/resolve/adapters/index.ts +++ b/src/resolve/adapters/index.ts @@ -21,4 +21,4 @@ export { DefaultSourceAdapter } from './defaultSourceAdapter'; export { BaseSourceAdapter } from './baseSourceAdapter'; export { DigitalExperienceSourceAdapter } from './digitalExperienceSourceAdapter'; export { UiBundlesSourceAdapter } from './uiBundlesSourceAdapter'; -export { DataspaceScopedSourceAdapter } from './dataspaceScopedSourceAdapter'; +export { D360SourceAdapter } from './d360SourceAdapter'; diff --git a/src/resolve/adapters/sourceAdapterFactory.ts b/src/resolve/adapters/sourceAdapterFactory.ts index 9c32c70793..5e0158c9da 100644 --- a/src/resolve/adapters/sourceAdapterFactory.ts +++ b/src/resolve/adapters/sourceAdapterFactory.ts @@ -28,7 +28,7 @@ import { DefaultSourceAdapter } from './defaultSourceAdapter'; import { DigitalExperienceSourceAdapter } from './digitalExperienceSourceAdapter'; import { UiBundlesSourceAdapter } from './uiBundlesSourceAdapter'; import { PartialDecomposedAdapter } from './partialDecomposedAdapter'; -import { DataspaceScopedSourceAdapter } from './dataspaceScopedSourceAdapter'; +import { D360SourceAdapter } from './d360SourceAdapter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -59,8 +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 '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 9a79b1380a..04c7f4f84c 100644 --- a/test/registry/registryValidation.test.ts +++ b/test/registry/registryValidation.test.ts @@ -294,7 +294,7 @@ describe('will run preset tests', () => { 'partiallyDecomposed', 'digitalExperience', 'uiBundles', - 'dataspaceScoped', + 'd360', ]).includes(type.strategies?.adapter); }); }); From a2fb00b9b1be971c8ea6fe3649c8fd2e65e8230b Mon Sep 17 00:00:00 2001 From: Aparna Goyal Date: Wed, 21 Oct 2026 14:10:41 +0530 Subject: [PATCH 3/4] feat: register more Data Cloud types and keep peer-only types out of package.xml Add DataLakeObject, DataspaceMember, DataModelObjectMapping, SecondaryIndex, DataStreamBundle and DataConnection, and use the plural directory names the Metadata API writes (calculatedInsights, dataModelObjects, ...). Add an excludeFromManifest registry flag and ComponentSet.addWithoutManifestEntry so components that only deploy as part of another one travel as files without their own package.xml entry. --- src/collections/componentSet.ts | 18 +++++- src/registry/metadataRegistry.json | 89 +++++++++++++++++++++++++-- src/registry/types.ts | 5 ++ test/collections/componentSet.test.ts | 28 +++++++++ 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index 3566dd9a2b..55b04db4db 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -107,6 +107,8 @@ export class ComponentSet extends LazyCollection { }; // used to store components meant for a "constructive" (not destructive) manifest private manifestComponents = new DecodeableMap>(); + // components whose files are sent in the payload but that get no manifest entry of their own + private withoutManifestEntry = new Set(); private destructiveChangesType = DestructiveChangesType.POST; @@ -460,6 +462,7 @@ export class ComponentSet extends LazyCollection { const typeMap = new Map>(); [...components.entries()].map(([key, cmpMap]) => { + if (this.withoutManifestEntry.has(key)) return; const [typeId, fullName] = splitOnFirstDelimiter(key); const type = this.registry.getTypeByName(typeId); @@ -522,7 +525,7 @@ export class ComponentSet extends LazyCollection { }); const toParse = await this.getObject(destructiveType); toParse.Package[XML_NS_KEY] = XML_NS_URL; - + return XML_DECL.concat(builder.build(toParse)); } @@ -595,6 +598,17 @@ export class ComponentSet extends LazyCollection { } } + /** + * Add a component whose files go into the deploy payload without giving it its own entry in the + * manifest. Use this for components the Metadata API deploys as part of another component in the set. + * + * @param component Component to add + */ + public addWithoutManifestEntry(component: SourceComponent): void { + this.add(component); + this.withoutManifestEntry.add(simpleKey(component)); + } + /** * Tests whether or not a `fullName` and `type` pair is present in the set. * @@ -833,7 +847,7 @@ const addToTypeMap = ({ fullName: string; destructiveType?: DestructiveChangesType; }): void => { - if (type.isAddressable === false) return; + if (type.isAddressable === false || type.excludeFromManifest) return; if (fullName === ComponentSet.WILDCARD && !type.supportsWildcardAndName && !destructiveType) { // if the type doesn't support mixed wildcards and specific names, overwrite the names to be a wildcard typeMap.set(type.name, new Set([fullName])); diff --git a/src/registry/metadataRegistry.json b/src/registry/metadataRegistry.json index a222e674e9..4b544f90d3 100644 --- a/src/registry/metadataRegistry.json +++ b/src/registry/metadataRegistry.json @@ -51,9 +51,15 @@ "botBlocks": "botblock", "botTemplates": "bottemplate", "bots": "bot", - "calculatedInsight": "calculatedinsight", + "calculatedInsights": "calculatedinsight", "contentTypes": "contenttypebundle", - "mktDataModelObject": "datamodelobject", + "dataModelObjects": "datamodelobject", + "dataModelObjectMappings": "datamodelobjectmapping", + "dataLakeObjects": "datalakeobject", + "dataspaceMembers": "dataspacemember", + "secondaryIndexes": "secondaryindex", + "dataStreamBundles": "datastreambundle", + "dataConnections": "dataconnection", "documents": "document", "emailservices": "emailservicesfunction", "experiencePropertyTypeBundles": "experiencepropertytypebundle", @@ -5430,7 +5436,7 @@ "id": "calculatedinsight", "name": "CalculatedInsight", "suffix": "json", - "directoryName": "calculatedInsight", + "directoryName": "calculatedInsights", "inFolder": false, "strictDirectoryName": true, "strategies": { @@ -5442,7 +5448,82 @@ "id": "datamodelobject", "name": "DataModelObject", "suffix": "json", - "directoryName": "mktDataModelObject", + "directoryName": "dataModelObjects", + "inFolder": false, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "datalakeobject": { + "id": "datalakeobject", + "name": "DataLakeObject", + "suffix": "json", + "directoryName": "dataLakeObjects", + "inFolder": false, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "dataspacemember": { + "id": "dataspacemember", + "name": "DataspaceMember", + "suffix": "json", + "directoryName": "dataspaceMembers", + "inFolder": false, + "excludeFromManifest": true, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "datamodelobjectmapping": { + "id": "datamodelobjectmapping", + "name": "DataModelObjectMapping", + "suffix": "json", + "directoryName": "dataModelObjectMappings", + "inFolder": false, + "excludeFromManifest": true, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "secondaryindex": { + "id": "secondaryindex", + "name": "SecondaryIndex", + "suffix": "json", + "directoryName": "secondaryIndexes", + "inFolder": false, + "excludeFromManifest": true, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "datastreambundle": { + "id": "datastreambundle", + "name": "DataStreamBundle", + "suffix": "json", + "directoryName": "dataStreamBundles", + "inFolder": false, + "strictDirectoryName": true, + "strategies": { + "adapter": "d360", + "transformer": "d360" + } + }, + "dataconnection": { + "id": "dataconnection", + "name": "DataConnection", + "suffix": "json", + "directoryName": "dataConnections", "inFolder": false, "strictDirectoryName": true, "strategies": { diff --git a/src/registry/types.ts b/src/registry/types.ts index f88ed69b87..e553b7cf99 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -116,6 +116,11 @@ export type MetadataType = { * Whether the component requires the parent to be present when deploying/retrieving */ unaddressableWithoutParent?: boolean; + /** + * Leave the component out of package.xml while still sending its files in the deploy/retrieve payload. + * For types the Metadata API only accepts as part of a parent component, not as a manifest entry of their own. + */ + excludeFromManifest?: boolean; /** * Whether or not components of the same type can be can be specified with the wildcard character, and by name in a manifest diff --git a/test/collections/componentSet.test.ts b/test/collections/componentSet.test.ts index 0a33929fc5..d3845b61a2 100644 --- a/test/collections/componentSet.test.ts +++ b/test/collections/componentSet.test.ts @@ -751,6 +751,34 @@ describe('ComponentSet', () => { expect(getCurrentApiVersionStub.calledOnce).to.be.true; }); + it('should leave types marked excludeFromManifest out of the manifest', async () => { + const set = new ComponentSet( + [ + { fullName: 'default.myDlo', type: registry.types.datalakeobject }, + { fullName: 'default.myDlo', type: registry.types.dataspacemember }, + { fullName: 'default.myDlo_map', type: registry.types.datamodelobjectmapping }, + { fullName: 'default.myIndex', type: registry.types.secondaryindex }, + ], + registryAccess + ); + expect((await set.getObject()).Package.types).to.deep.equal([ + { name: registry.types.datalakeobject.name, members: ['default.myDlo'] }, + ]); + }); + + it('should leave components added without a manifest entry out of the manifest', async () => { + const set = new ComponentSet([], registryAccess); + const dlo = new SourceComponent({ name: 'default.myDlo', type: registry.types.datalakeobject }); + const dmo = new SourceComponent({ name: 'default.myDmo', type: registry.types.datamodelobject }); + set.add(dlo); + set.addWithoutManifestEntry(dmo); + + expect(set.has(dmo)).to.be.true; + expect((await set.getObject()).Package.types).to.deep.equal([ + { name: registry.types.datalakeobject.name, members: ['default.myDlo'] }, + ]); + }); + it('should allow the componentSet to set the apiVersion', async () => { const resolveSpy = $$.SANDBOX.spy(SfProject.prototype, 'resolveProjectConfig'); const set = ComponentSet.fromSource({ From 22053ae1d79d161b54fc8f2187c570e8beecec40 Mon Sep 17 00:00:00 2001 From: Aparna Goyal Date: Wed, 21 Oct 2026 14:10:44 +0530 Subject: [PATCH 4/4] feat: deploy Data Cloud components for a single dataspace Accept . on deploy for components stored outside any dataspace (e.g. DataLakeObject:default.myDlo__dll, DataStreamBundle:default.myStream), the same name retrieve accepts. The local component is resolved by its bare name, package.xml keeps the requested name, and the retrieveWith closure is limited to that dataspace. Match retrieveWith entries by type and name, so components of different types that share a name are told apart, and honour the dataspace a reference names. --- src/collections/componentSetBuilder.ts | 2 + src/collections/d360Dependencies.ts | 236 ++++++++++++++++------ src/resolve/adapters/d360SourceAdapter.ts | 26 ++- test/collections/d360Dependencies.test.ts | 206 +++++++++++++++++++ 4 files changed, 403 insertions(+), 67 deletions(-) create mode 100644 test/collections/d360Dependencies.test.ts diff --git a/src/collections/componentSetBuilder.ts b/src/collections/componentSetBuilder.ts index f639e0f9db..5bbc284803 100644 --- a/src/collections/componentSetBuilder.ts +++ b/src/collections/componentSetBuilder.ts @@ -32,6 +32,7 @@ import { parseBotVersionFullName, } from '../resolve/pseudoTypes/agentResolver'; import { DestructiveChangesType, FromConnectionOptions } from './types'; +import { resolveDataspaceQualifiedComponents } from './d360Dependencies'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr'); @@ -230,6 +231,7 @@ export class ComponentSetBuilder { } resolvedComponents.toArray().map(addToComponentSet(componentSet)); + resolveDataspaceQualifiedComponents(componentSet, directoryPaths, registry); } // Resolve metadata entries with an org connection diff --git a/src/collections/d360Dependencies.ts b/src/collections/d360Dependencies.ts index b24ad57912..f44fc38a20 100644 --- a/src/collections/d360Dependencies.ts +++ b/src/collections/d360Dependencies.ts @@ -15,40 +15,43 @@ */ import { RegistryAccess } from '../registry/registryAccess'; import { SourceComponent } from '../resolve/sourceComponent'; +import { MetadataComponent } from '../resolve/types'; +import { + METADATA_SIDECAR_SUFFIX, + parseDataspaceQualifiedName, + toDataspaceQualifiedName, +} from '../resolve/adapters/d360SourceAdapter'; 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. + * Every Data Cloud 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, requires, retrieveWith, dependsOn }`. This module is + * type-agnostic — it works off the sidecar convention alone, so it covers every Data Cloud 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. + * The sidecar declares TWO distinct relationships, and they mean different things. + * `retrieveWith` is the list 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 and is NOT used to build the 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). + * Each `retrieveWith` entry names its target as `{ componentType, name, dataspaceName? }`, where + * `dataspaceName` is present only for a target inside a dataspace. Resolution matches it against the + * target's own sidecar, which every type carries regardless of how its payload is shaped. Some types + * (e.g. Data Lake Objects) live outside any dataspace, under `d360//`, and are named without one. * - * 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. + * A component outside any dataspace can still be requested for one dataspace as `.`, + * the same name retrieve accepts. Its closure is then limited to that dataspace. */ -/** The adapter strategy id shared by every dataspace-scoped type. */ +/** The adapter strategy id shared by every Data Cloud type. */ const D360_ADAPTER = 'd360'; -/** Filename suffix of the per-component metadata sidecar (`.meta.json`). */ -const METADATA_SIDECAR_SUFFIX = '.meta.json'; +/** A `retrieveWith` entry. `dataspaceName` is absent when the target is outside every dataspace. */ +type ComponentRef = { componentType?: string; name?: string; dataspaceName?: string }; -type ComponentRef = { componentType?: string; componentName?: string }; +/** One `dependsOn` entry. */ +type DependencyRef = { componentType?: string; componentApiName?: string }; type ComponentSidecar = { componentType?: string; @@ -57,10 +60,13 @@ type ComponentSidecar = { /** 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[]; + dependsOn?: { derived?: DependencyRef[]; inbound?: DependencyRef[] }; }; -const isD360 = (component: SourceComponent): boolean => component.type.strategies?.adapter === D360_ADAPTER; +const isD360 = (component: MetadataComponent): boolean => component.type.strategies?.adapter === D360_ADAPTER; + +/** A requested name with no local source behind it, e.g. one that still has to be resolved. */ +const isNameOnly = (component: MetadataComponent): boolean => !(component instanceof SourceComponent); const readJson = (component: SourceComponent, path: string | undefined): T | undefined => { if (!path || !component.tree.exists(path)) { @@ -77,68 +83,172 @@ const readJson = (component: SourceComponent, path: string | undefined): T | 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; +const readSidecar = (component: SourceComponent): ComponentSidecar | undefined => + readJson(component, getSidecarPath(component)); -/** 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)); +/** + * Lookup key for a component reference. The type is part of the key because different types can share + * a name: a Data Lake Object and its DataspaceMember are both named after the DLO. + */ +const refKey = (componentType: string, name: string): string => `${componentType}|${name}`; + +/** Identifies a component across sets; the same component resolved into two sets is two objects. */ +const memberKey = (component: MetadataComponent): string => `${component.type.name}|${component.fullName}`; /** - * Build the minimal deploy closure for dataspace-scoped components. + * The dataspaces a component outside any dataspace was requested for. A request of + * `DataLakeObject:default.myDlo__dll` for the local `myDlo__dll` yields `default`. + */ +const getRequestedDataspaces = (requested: ComponentSet, component: SourceComponent): string[] => + [...requested] + .filter((member) => isNameOnly(member) && member.type.name === component.type.name) + .map((member) => parseDataspaceQualifiedName(member.fullName)) + .filter((parsed) => parsed?.name === component.fullName) + .map((parsed) => parsed!.dataspace); + +/** + * Resolve `.` requests for components stored outside any dataspace. * - * 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. + * Retrieve accepts such a name because the org resolves it. Locally the component is stored once, + * under its bare ``, so the requested name matches no source. This adds the local component for + * each such request, without a manifest entry of its own, so the manifest keeps the requested name. * - * @param full A ComponentSet with every candidate component (the whole project); only its dataspace-scoped members are indexed for lookup. + * @param componentSet The set being built; it already holds the requested names. + * @param fsPaths Package directories to resolve from. + * @param registry RegistryAccess used to resolve the components. + */ +export const resolveDataspaceQualifiedComponents = ( + componentSet: ComponentSet, + fsPaths: string[], + registry: RegistryAccess +): void => { + const include = new ComponentSet(undefined, registry); + for (const member of componentSet) { + const parsed = isD360(member) && isNameOnly(member) ? parseDataspaceQualifiedName(member.fullName) : undefined; + if (parsed && componentSet.getSourceComponents(member).toArray().length === 0) { + include.add({ type: member.type, fullName: parsed.name }); + } + } + if (include.size === 0) { + return; + } + for (const component of ComponentSet.fromSource({ fsPaths, include, registry }).getSourceComponents()) { + componentSet.addWithoutManifestEntry(component); + } +}; + +/** + * Index every Data Cloud component in `full` by its sidecar type + componentName, the key space + * `retrieveWith` references. A key can match several components, one per dataspace. + */ +const indexCandidates = ( + full: ComponentSet +): { + findPeers: (ref: ComponentRef, dataspace: string | undefined) => SourceComponent[]; + getDataspace: (component: SourceComponent) => string | undefined; +} => { + const byRef = new Map(); + const dataspaceOf = new Map(); + for (const component of full.getSourceComponents()) { + const sidecar = isD360(component) ? readSidecar(component) : undefined; + if (sidecar?.componentType && sidecar.componentName) { + const key = refKey(sidecar.componentType, sidecar.componentName); + byRef.set(key, [...(byRef.get(key) ?? []), component]); + dataspaceOf.set(memberKey(component), sidecar.dataspaceName); + } + } + const getDataspace = (component: SourceComponent): string | undefined => dataspaceOf.get(memberKey(component)); + + // A reference that names its dataspace matches only there. Otherwise, while walking a dataspace, only + // peers in it or outside every dataspace qualify; with no dataspace to walk, all of them do. + const findPeers = (ref: ComponentRef, dataspace: string | undefined): SourceComponent[] => { + if (!ref.componentType || !ref.name) { + return []; + } + const candidates = byRef.get(refKey(ref.componentType, ref.name)) ?? []; + if (ref.dataspaceName) { + return candidates.filter((candidate) => getDataspace(candidate) === ref.dataspaceName); + } + return dataspace + ? candidates.filter((candidate) => [dataspace, undefined].includes(getDataspace(candidate))) + : candidates; + }; + + return { findPeers, getDataspace }; +}; + +/** + * Build the minimal deploy closure for Data Cloud components. + * + * The result is the requested components plus the transitive closure of their `retrieveWith` peers + * (resolved via `retrieveWith` -> sidecar `componentType` + `componentName`), and nothing else. A peer + * named with a dataspace is pulled from that dataspace. Otherwise a peer that exists in several + * dataspaces is pulled from the requester's dataspace. A component outside any + * dataspace pulls peers from the dataspaces it was requested for, or from all of them when requested + * by its bare name. Other 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 Data Cloud 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. + * @returns A new ComponentSet containing the requested components plus their retrieveWith closure. + * Only the requested components get package.xml entries; the closure peers are sent as files. */ 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 { findPeers, getDataspace } = indexCandidates(full); const result = new ComponentSet([], registry); - const seen = new Set(); - const worklist: SourceComponent[] = []; + const added = new Set(); + // A component is walked once per dataspace, since it can be requested for several of them. + const visited = new Set(); + const worklist: Array<{ component: SourceComponent; dataspace: string | undefined }> = []; + const visit = (component: SourceComponent, dataspace: string | undefined): void => { + const key = `${memberKey(component)}|${dataspace ?? ''}`; + if (!visited.has(key)) { + visited.add(key); + worklist.push({ component, dataspace }); + } + }; - // Seed with everything the user requested; non-dataspace-scoped members are kept as-is. + // Seed with everything the user requested; non-Data Cloud members are kept as-is. for (const component of requested.getSourceComponents()) { - if (!seen.has(component)) { - seen.add(component); + if (added.has(memberKey(component))) { + continue; + } + added.add(memberKey(component)); + const dataspaces = + isD360(component) && !getDataspace(component) ? getRequestedDataspaces(requested, component) : []; + if (dataspaces.length) { + // Requested as `.`: keep that name in the manifest and walk only those dataspaces. + result.addWithoutManifestEntry(component); + for (const dataspace of dataspaces) { + result.add({ type: component.type, fullName: toDataspaceQualifiedName(dataspace, component.fullName) }); + visit(component, dataspace); + } + } else { result.add(component); if (isD360(component)) { - worklist.push(component); + visit(component, getDataspace(component)); } } } - // Walk retrieveWith transitively, pulling in only the referenced dataspace-scoped components. + // Walk retrieveWith transitively, pulling in only the referenced Data Cloud 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); + const { component, dataspace } = worklist.pop()!; + for (const ref of readSidecar(component)?.retrieveWith ?? []) { + for (const peer of findPeers(ref, dataspace)) { + if (!added.has(memberKey(peer))) { + added.add(memberKey(peer)); + // Peers travel as files only. The Metadata API deploys them as part of the requested + // component, so package.xml lists just what the user asked for, the same as on retrieve. + result.addWithoutManifestEntry(peer); + } + // A peer outside every dataspace keeps walking in the dataspace it was reached from. + visit(peer, getDataspace(peer) ?? dataspace); } } } diff --git a/src/resolve/adapters/d360SourceAdapter.ts b/src/resolve/adapters/d360SourceAdapter.ts index ec7d8b5f79..51740f6c98 100644 --- a/src/resolve/adapters/d360SourceAdapter.ts +++ b/src/resolve/adapters/d360SourceAdapter.ts @@ -28,6 +28,24 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd /** Filename suffix of the per-component metadata sidecar (`.meta.json`). */ export const METADATA_SIDECAR_SUFFIX = '.meta.json'; +/** Folder that wraps dataspace-scoped components: `d360/dataspace///`. */ +const DATASPACE_FOLDER = 'dataspace'; + +/** Joins the dataspace and the component name in a dataspace-scoped fullName: `.`. */ +const DATASPACE_NAME_SEPARATOR = '.'; + +/** The fullName of a component in a dataspace: `.`. */ +export const toDataspaceQualifiedName = (dataspace: string, name: string): string => + `${dataspace}${DATASPACE_NAME_SEPARATOR}${name}`; + +/** Split `.` into its parts, or return undefined when the name has no dataspace. */ +export const parseDataspaceQualifiedName = (fullName: string): { dataspace: string; name: string } | undefined => { + const index = fullName.indexOf(DATASPACE_NAME_SEPARATOR); + return index > 0 && index < fullName.length - 1 + ? { dataspace: fullName.slice(0, index), name: fullName.slice(index + 1) } + : undefined; +}; + /** * 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: @@ -109,14 +127,14 @@ export class D360SourceAdapter extends MixedContentSourceAdapter { /** * Build `.` from a path shaped like - * `.../dataSpaces///.json`. The dataspace is the path segment - * immediately preceding the type's directory. + * `.../d360/dataspace///.json`. Types that aren't dataspace-scoped + * sit directly under `d360//` and keep their bare ``. */ 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 isDataspaceScoped = typeFolderIndex > 1 && pathParts[typeFolderIndex - 2] === DATASPACE_FOLDER; const shortName = baseName(contentPath); - return dataspace ? `${dataspace}.${shortName}` : shortName; + return isDataspaceScoped ? toDataspaceQualifiedName(pathParts[typeFolderIndex - 1], shortName) : shortName; } } diff --git a/test/collections/d360Dependencies.test.ts b/test/collections/d360Dependencies.test.ts new file mode 100644 index 0000000000..a1e3e2f980 --- /dev/null +++ b/test/collections/d360Dependencies.test.ts @@ -0,0 +1,206 @@ +/* + * 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 { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect } from 'chai'; +import { ComponentSet, RegistryAccess, VirtualTreeContainer } from '../../src'; +import { expandD360ComponentSet, resolveDataspaceQualifiedComponents } from '../../src/collections/d360Dependencies'; + +type Ref = { componentType: string; name: string; dataspaceName?: string }; + +const D360_DIR = join('force-app', 'd360'); + +const component = ( + componentType: string, + componentName: string, + dataspaceName?: string, + retrieveWith: Ref[] = [] +): Array<{ name: string; data: Buffer }> => [ + { name: `${componentName}.json`, data: Buffer.from('{}') }, + { + name: `${componentName}.meta.json`, + data: Buffer.from(JSON.stringify({ componentType, componentName, dataspaceName, retrieveWith })), + }, +]; + +const members = (set: ComponentSet): string[] => + set + .getSourceComponents() + .toArray() + .map((c) => `${c.type.name}:${c.fullName}`) + .sort(); + +describe('expandD360ComponentSet', () => { + const registry = new RegistryAccess(); + + // A Data Lake Object outside any dataspace, a member of two dataspaces. Its DataspaceMember shares its name. + const tree = new VirtualTreeContainer([ + { dirPath: 'force-app', children: ['d360'] }, + { dirPath: D360_DIR, children: ['dataLakeObjects', 'dataspace'] }, + { + dirPath: join(D360_DIR, 'dataLakeObjects'), + children: [ + ...component('DataLakeObject', 'myDlo__dll', undefined, [ + { componentType: 'DataspaceMember', name: 'myDlo__dll' }, + { componentType: 'DataModelObject', name: 'myDmo__dlm' }, + ]), + ...component('DataLakeObject', 'pinnedDlo__dll', undefined, [ + { componentType: 'DataModelObject', name: 'myDmo__dlm', dataspaceName: 'other' }, + ]), + ], + }, + { dirPath: join(D360_DIR, 'dataspace'), children: ['default', 'other'] }, + ...['default', 'other'].flatMap((ds) => [ + { dirPath: join(D360_DIR, 'dataspace', ds), children: ['dataspaceMembers', 'dataModelObjects'] }, + { + dirPath: join(D360_DIR, 'dataspace', ds, 'dataspaceMembers'), + children: component('DataspaceMember', 'myDlo__dll', ds), + }, + { + dirPath: join(D360_DIR, 'dataspace', ds, 'dataModelObjects'), + children: component('DataModelObject', 'myDmo__dlm', ds), + }, + ]), + ]); + const full = ComponentSet.fromSource({ fsPaths: ['force-app'], tree, registry }); + + const expand = (member: string): ComponentSet => { + const [type, fullName] = member.split(':'); + const requested = new ComponentSet([], registry); + requested.add(full.getSourceComponents({ fullName, type: registry.getTypeByName(type) }).first()!); + return expandD360ComponentSet(full, requested, registry); + }; + + // Mirrors what the builder produces for `DataLakeObject:.myDlo__dll`: the requested name, + // plus the local component without a manifest entry of its own. + const expandInDataspaces = (...dataspaces: string[]): ComponentSet => { + const type = registry.getTypeByName('DataLakeObject'); + const requested = new ComponentSet([], registry); + dataspaces.forEach((ds) => requested.add({ type, fullName: `${ds}.myDlo__dll` })); + requested.addWithoutManifestEntry(full.getSourceComponents({ type, fullName: 'myDlo__dll' }).first()!); + return expandD360ComponentSet(full, requested, registry); + }; + + it('should tell apart components of different types that share a name', () => { + expect(members(expand('DataLakeObject:myDlo__dll'))).to.include.members([ + 'DataLakeObject:myDlo__dll', + 'DataspaceMember:default.myDlo__dll', + ]); + }); + + it('should pull peers from every dataspace for a component outside any dataspace', () => { + expect(members(expand('DataLakeObject:myDlo__dll'))).to.deep.equal([ + 'DataLakeObject:myDlo__dll', + 'DataModelObject:default.myDmo__dlm', + 'DataModelObject:other.myDmo__dlm', + 'DataspaceMember:default.myDlo__dll', + 'DataspaceMember:other.myDlo__dll', + ]); + }); + + it('should list only the requested component in the manifest', async () => { + expect((await expand('DataLakeObject:myDlo__dll').getObject()).Package.types).to.deep.equal([ + { name: 'DataLakeObject', members: ['myDlo__dll'] }, + ]); + }); + + it('should pull a peer only from the dataspace its reference names', () => { + expect(members(expand('DataLakeObject:pinnedDlo__dll'))).to.deep.equal([ + 'DataLakeObject:pinnedDlo__dll', + 'DataModelObject:other.myDmo__dlm', + ]); + }); + + it('should pull peers only from the requested dataspace', () => { + expect(members(expandInDataspaces('default'))).to.deep.equal([ + 'DataLakeObject:myDlo__dll', + 'DataModelObject:default.myDmo__dlm', + 'DataspaceMember:default.myDlo__dll', + ]); + }); + + it('should pull peers from each requested dataspace', () => { + expect(members(expandInDataspaces('default', 'other'))).to.have.length(5); + }); + + it('should list the dataspace-qualified name in the manifest', async () => { + expect((await expandInDataspaces('default').getObject()).Package.types).to.deep.equal([ + { name: 'DataLakeObject', members: ['default.myDlo__dll'] }, + ]); + }); + + it('should pull the Data Lake Object of a data stream bundle and its closure', () => { + const bundles = ComponentSet.fromSource({ + fsPaths: ['force-app'], + tree: new VirtualTreeContainer([ + { dirPath: 'force-app', children: ['d360'] }, + { dirPath: D360_DIR, children: ['dataStreamBundles'] }, + { + dirPath: join(D360_DIR, 'dataStreamBundles'), + children: component('DataStreamBundle', 'myStream', undefined, [ + { componentType: 'DataLakeObject', name: 'myDlo__dll' }, + ]), + }, + ]), + registry, + }); + const project = new ComponentSet([...full.getSourceComponents(), ...bundles.getSourceComponents()], registry); + const type = registry.getTypeByName('DataStreamBundle'); + const requested = new ComponentSet([{ type, fullName: 'default.myStream' }], registry); + requested.addWithoutManifestEntry(bundles.getSourceComponents({ type, fullName: 'myStream' }).first()!); + expect(members(expandD360ComponentSet(project, requested, registry))).to.deep.equal([ + 'DataLakeObject:myDlo__dll', + 'DataModelObject:default.myDmo__dlm', + 'DataStreamBundle:myStream', + 'DataspaceMember:default.myDlo__dll', + ]); + }); +}); + +describe('resolveDataspaceQualifiedComponents', () => { + const registry = new RegistryAccess(); + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'd360-')); + const typeDir = join(projectDir, 'd360', 'dataLakeObjects'); + mkdirSync(typeDir, { recursive: true }); + component('DataLakeObject', 'myDlo__dll').forEach(({ name, data }) => writeFileSync(join(typeDir, name), data)); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + const resolve = (fullName: string): ComponentSet => { + const set = new ComponentSet([{ type: registry.getTypeByName('DataLakeObject'), fullName }], registry); + resolveDataspaceQualifiedComponents(set, [projectDir], registry); + return set; + }; + + it('should resolve a dataspace-qualified name to the local component', () => { + expect(members(resolve('default.myDlo__dll'))).to.deep.equal(['DataLakeObject:myDlo__dll']); + }); + + it('should keep the requested name in the manifest', async () => { + expect((await resolve('default.myDlo__dll').getObject()).Package.types).to.deep.equal([ + { name: 'DataLakeObject', members: ['default.myDlo__dll'] }, + ]); + }); + + it('should leave a name without a dataspace alone', () => { + expect(members(resolve('unknown__dll'))).to.be.empty; + }); +});