feat(core): Resolve variable requirements on package import with do-nothing policy (no-changelog) (#34438)

This commit is contained in:
Nour Alhadi Mahmoud
2026-07-21 19:31:37 +02:00
committed by GitHub
parent babc0b7344
commit 54a95fea6c
28 changed files with 728 additions and 8 deletions
@@ -16,6 +16,7 @@ describe('ImportPackageRequestDto', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
});
}
});
@@ -39,6 +40,7 @@ describe('ImportPackageRequestDto', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
});
}
});
@@ -64,6 +66,7 @@ describe('ImportPackageRequestDto', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
});
}
});
@@ -88,6 +91,7 @@ describe('ImportPackageRequestDto', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
});
}
});
@@ -279,4 +283,34 @@ describe('ImportPackageRequestDto', () => {
])('rejects $name', ({ request }) => {
expect(ImportPackageRequestDto.safeParse(request).success).toBe(false);
});
describe('variableMissingPolicy', () => {
it('defaults variableMissingPolicy to do-nothing when omitted', () => {
const result = ImportPackageRequestDto.safeParse({ workflowConflictPolicy: 'fail' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.variableMissingPolicy).toBe('do-nothing');
}
});
it('accepts do-nothing as a variableMissingPolicy value', () => {
const result = ImportPackageRequestDto.safeParse({
variableMissingPolicy: 'do-nothing',
workflowConflictPolicy: 'fail',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.variableMissingPolicy).toBe('do-nothing');
}
});
it('rejects unsupported variableMissingPolicy values', () => {
expect(
ImportPackageRequestDto.safeParse({
variableMissingPolicy: 'create-stub',
workflowConflictPolicy: 'fail',
}).success,
).toBe(false);
});
});
});
@@ -16,6 +16,7 @@ export const IMPORT_PACKAGE_REQUEST_FORM_FIELDS = [
'dataTableMatchingMode',
'dataTableMissingMode',
'dataTableSchemaConflictPolicy',
'variableMissingPolicy',
] as const;
/** Multipart text fields: empty / whitespace-only values become `undefined`. */
@@ -86,4 +87,5 @@ export class ImportPackageRequestDto extends Z.class({
.enum(['keep-existing', 'fail'])
.optional()
.default('keep-existing'),
variableMissingPolicy: z.enum(['do-nothing']).optional().default('do-nothing'),
}) {}
@@ -63,6 +63,7 @@ n8n-cli package import --file=export.n8np --conflict-policy=fail --bindings='{"c
| `--data-table-matching-mode` | How data tables referenced by the package's workflows are matched on the target instance: `by-id` (default and only mode) matches the target-project table with the same id — imported tables keep their source id — and never falls back to name matching. |
| `--data-table-missing-mode` | What to do when a referenced data table is absent in the target project. `create` (instance default) creates it from the package schema — keeping the source id, with no rows; `must-preexist` requires it to already exist; `do-nothing` skips creation. Matched tables are always used as-is and schema-validated (all package columns present with the same name and type), even under `do-nothing`. |
| `--data-table-schema-conflict-policy` | How strictly a matched data table's schema is compared. Every package column must exist on the matched target table with the same name and type — a missing column or a type mismatch always rejects. `keep-existing` (instance default) ignores additional columns the target table has of its own; `fail` is the strict drift-detection choice and rejects those too. Neither policy alters the matched target table — package columns are never added to it. |
| `--variable-missing-policy` | What to do when a variable referenced by the package's workflows is absent from both the target project and the global scope: `do-nothing` (instance default and only policy) imports the workflows anyway and lists the unresolved variable names in the result, without creating anything. Matched variables are used as-is — the import never creates or overwrites variables. |
| `--bindings` | Explicit source→target id bindings as a JSON object keyed by entity type, e.g. `{"credentials":{"<sourceId>":"<targetId>"}}`. Only `credentials` is honoured today; these bindings are applied before `--credential-matching-mode` resolution runs. |
Requires the API key to hold the `workflow:import` scope, plus `dataTable:create`
@@ -20,6 +20,7 @@ interface ImportFlags {
dataTableMatchingMode?: string;
dataTableMissingMode?: string;
dataTableSchemaConflictPolicy?: string;
variableMissingPolicy?: string;
bindings?: string;
}
@@ -64,6 +65,7 @@ describe('package import command', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
bindings: '{}',
});
@@ -86,6 +88,7 @@ describe('package import command', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
bindings: '{}',
});
});
+1
View File
@@ -22,6 +22,7 @@ export interface ImportPackageFields {
dataTableMatchingMode?: string;
dataTableMissingMode?: string;
dataTableSchemaConflictPolicy?: string;
variableMissingPolicy?: string;
}
export interface ExportPackageFields {
@@ -74,6 +74,12 @@ export default class PackageImport extends BaseCommand {
options: ['keep-existing', 'fail'],
aliases: ['data-table-schema-conflict-policy'],
}),
variableMissingPolicy: Flags.string({
description:
'What to do when a referenced variable is absent from the target project and the global scope (default on the instance: do-nothing). do-nothing imports the workflows and lists unresolved names as warnings without creating anything',
options: ['do-nothing'],
aliases: ['variable-missing-policy'],
}),
bindings: Flags.string({
description:
'Explicit source→target id bindings as a JSON object keyed by entity type, e.g. \'{"credentials":{"<sourceId>":"<targetId>"}}\'. Applied before credential-matching-mode resolution.',
@@ -106,6 +112,7 @@ export default class PackageImport extends BaseCommand {
dataTableMatchingMode: flags.dataTableMatchingMode,
dataTableMissingMode: flags.dataTableMissingMode,
dataTableSchemaConflictPolicy: flags.dataTableSchemaConflictPolicy,
variableMissingPolicy: flags.variableMissingPolicy,
bindings: flags.bindings,
},
);
@@ -75,6 +75,7 @@ describe('LogStreamingEventRelay', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
},
packageSourceId: 'source-instance-1',
packageVersion: '1',
@@ -100,6 +101,11 @@ describe('LogStreamingEventRelay', () => {
created: 1,
requirements: 1,
},
variables: {
matched: 0,
missing: 1,
requirements: 1,
},
},
};
@@ -125,6 +131,7 @@ describe('LogStreamingEventRelay', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
},
packageSourceId: 'source-instance-1',
packageVersion: '1',
@@ -2235,6 +2235,7 @@ describe('TelemetryEventRelay', () => {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
},
packageSourceId: 'source-instance-1',
packageVersion: '1',
@@ -2259,6 +2260,11 @@ describe('TelemetryEventRelay', () => {
created: 1,
requirements: 2,
},
variables: {
matched: 1,
missing: 1,
requirements: 2,
},
},
};
@@ -2274,6 +2280,7 @@ describe('TelemetryEventRelay', () => {
data_table_matching_mode: 'by-id',
data_table_missing_mode: 'create',
data_table_schema_conflict_policy: 'keep-existing',
variable_missing_policy: 'do-nothing',
workflows_created: 2,
workflows_updated: 1,
workflows_skipped: 1,
@@ -2283,6 +2290,9 @@ describe('TelemetryEventRelay', () => {
data_tables_matched: 1,
data_tables_created: 1,
data_tables_required: 2,
variables_matched: 1,
variables_missing: 1,
variables_required: 2,
});
});
@@ -1060,6 +1060,7 @@ export class TelemetryEventRelay extends EventRelay {
data_table_matching_mode: options.dataTableMatchingMode,
data_table_missing_mode: options.dataTableMissingMode,
data_table_schema_conflict_policy: options.dataTableSchemaConflictPolicy,
variable_missing_policy: options.variableMissingPolicy,
workflows_created: counts.workflows.created,
workflows_updated: counts.workflows.updated,
workflows_skipped: counts.workflows.skipped,
@@ -1069,6 +1070,9 @@ export class TelemetryEventRelay extends EventRelay {
data_tables_matched: counts.dataTables.matched,
data_tables_created: counts.dataTables.created,
data_tables_required: counts.dataTables.requirements,
variables_matched: counts.variables.matched,
variables_missing: counts.variables.missing,
variables_required: counts.variables.requirements,
});
}
@@ -65,6 +65,7 @@ async function importPackage(params: ImportParams) {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
...params,
});
}
@@ -46,6 +46,7 @@ async function importFolders(params: FolderImportParams) {
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
};
return await Container.get(N8nPackagesService).importPackage(request);
}
@@ -69,6 +69,7 @@ type ImportPackageParams = Omit<
| 'dataTableMatchingMode'
| 'dataTableMissingMode'
| 'dataTableSchemaConflictPolicy'
| 'variableMissingPolicy'
> &
Partial<
Pick<
@@ -83,6 +84,7 @@ type ImportPackageParams = Omit<
| 'dataTableMatchingMode'
| 'dataTableMissingMode'
| 'dataTableSchemaConflictPolicy'
| 'variableMissingPolicy'
>
>;
@@ -97,6 +99,7 @@ async function importPackage(params: ImportPackageParams) {
dataTableMatchingMode: DataTableMatchingMode.ById,
dataTableMissingMode: DataTableMissingMode.Create,
dataTableSchemaConflictPolicy: DataTableSchemaConflictPolicy.KeepExisting,
variableMissingPolicy: 'do-nothing',
...params,
});
}
@@ -1198,6 +1201,11 @@ describe('Package import event emission', () => {
created: 0,
requirements: 0,
},
variables: {
matched: 0,
missing: 0,
requirements: 0,
},
});
} finally {
emitSpy.mockRestore();
@@ -1277,6 +1285,11 @@ describe('Package import event emission', () => {
created: 0,
requirements: 0,
},
variables: {
matched: 0,
missing: 0,
requirements: 0,
},
});
} finally {
emitSpy.mockRestore();
@@ -1325,6 +1338,11 @@ describe('Package import event emission', () => {
created: 0,
requirements: 0,
},
variables: {
matched: 0,
missing: 0,
requirements: 0,
},
});
} finally {
emitSpy.mockRestore();
@@ -1375,6 +1393,11 @@ describe('Package import event emission', () => {
created: 0,
requirements: 0,
},
variables: {
matched: 0,
missing: 0,
requirements: 0,
},
});
} finally {
emitSpy.mockRestore();
@@ -6,15 +6,18 @@ import {
ProjectRelationRepository,
ProjectRepository,
SharedWorkflowRepository,
VariablesRepository,
WorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { EventService } from '@/events/event.service';
import type { RelayEventMap } from '@/events/maps/relay.event-map';
import { createOwner } from '@test-integration/db/users';
import { createVariable } from '@test-integration/db/variables';
import { LicenseMocker } from '@test-integration/license';
import { N8nPackagesService } from '../n8n-packages.service';
@@ -47,6 +50,7 @@ async function importProjects(
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
...overrides,
};
return await Container.get(N8nPackagesService).importPackage(request);
@@ -466,6 +470,52 @@ describe('project shell import', () => {
expect(await findProject('P1')).not.toBeNull();
});
describe('variable resolution', () => {
afterEach(async () => {
const seeded = await Container.get(VariablesRepository).find();
if (seeded.length > 0) {
await Container.get(VariablesService).deleteByIds(seeded.map(({ id }) => id));
}
});
it('reports variable resolution across projects, deduplicating shared names', async () => {
await createVariable('GLOBAL_URL', 'https://global.example.com');
const packageBuffer = await buildEntityPackageBuffer({
projects: [
{ target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) },
{
target: 'projects/stilton',
project: serializedProject({ id: 'P2', name: 'stilton' }),
},
],
workflows: [
{
target: 'projects/brie/workflows/wfa',
workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }),
},
{
target: 'projects/stilton/workflows/wfb',
workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }),
},
],
manifestExtras: {
requirements: {
variables: [
{ name: 'GLOBAL_URL', usedByWorkflows: ['WFA', 'WFB'] },
{ name: 'ABSENT_VAR', usedByWorkflows: ['WFA', 'WFB'] },
],
},
},
});
const result = await importProjects(owner, packageBuffer);
expect(result.variables).toEqual({ matched: ['GLOBAL_URL'], missing: ['ABSENT_VAR'] });
// do-nothing policy does not create variables
expect(await Container.get(VariablesRepository).count()).toBe(1);
});
});
it('emits a single n8n-package-imported event aggregating every project in the package', async () => {
const packageBuffer = await buildEntityPackageBuffer({
projects: [
@@ -0,0 +1,192 @@
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { VariablesRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { createOwner } from '@test-integration/db/users';
import { createProjectVariable, createVariable } from '@test-integration/db/variables';
import { LicenseMocker } from '@test-integration/license';
import { N8nPackagesService } from '../n8n-packages.service';
import type { ImportPackageRequest } from '../n8n-packages.types';
import { streamToBuffer } from './utils/tar-support';
import { buildWorkflowReferencingVariables } from './utils/test-builders';
let service: N8nPackagesService;
let variablesRepository: VariablesRepository;
let workflowRepository: WorkflowRepository;
let variablesService: VariablesService;
const licenseMocker = new LicenseMocker();
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
licenseMocker.mockLicenseState(Container.get(LicenseState));
service = Container.get(N8nPackagesService);
variablesRepository = Container.get(VariablesRepository);
workflowRepository = Container.get(WorkflowRepository);
variablesService = Container.get(VariablesService);
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowEntity',
'SharedWorkflow',
'Variables',
'ProjectRelation',
'Project',
]);
await variablesService.updateCache();
});
type ImportParams = { user: User; projectId: string; packageBuffer: Buffer } & Partial<
Omit<ImportPackageRequest, 'user' | 'projectId' | 'packageBuffer'>
>;
async function importPackage(params: ImportParams) {
return await service.importPackage({
credentialMatchingMode: 'id-only',
credentialMissingMode: 'must-preexist',
workflowConflictPolicy: 'fail',
workflowPublishingPolicy: 'preserve-published-state',
workflowIdPolicy: 'new',
folderConflictPolicy: 'merge',
dataTableMatchingMode: 'by-id',
dataTableMissingMode: 'create',
dataTableSchemaConflictPolicy: 'keep-existing',
variableMissingPolicy: 'do-nothing',
...params,
});
}
async function exportWorkflowPackage(user: User, workflowId: string): Promise<Buffer> {
const stream = await service.exportPackage({
user,
workflowIds: [workflowId],
includeVariableValues: true,
});
return await streamToBuffer(stream);
}
async function variablesInProject(projectId: string) {
return await variablesRepository.find({
where: { project: { id: projectId } },
relations: { project: true },
});
}
describe('workflow package import — with variables', () => {
describe('do-nothing import policy', () => {
it('imports the workflow, reports the missing name as a warning, and creates no variable', async () => {
const owner = await createOwner();
const sourceProject = await createTeamProject('Source', owner);
const targetProject = await createTeamProject('Target', owner);
await createProjectVariable('API_URL', 'https://source.example.com', sourceProject);
const workflow = await buildWorkflowReferencingVariables({
name: 'Workflow with vars',
project: sourceProject,
variableNames: ['API_URL'],
});
const packageBuffer = await exportWorkflowPackage(owner, workflow.id);
const variablesBefore = await variablesRepository.count();
const result = await importPackage({
user: owner,
projectId: targetProject.id,
packageBuffer,
variableMissingPolicy: 'do-nothing',
});
expect(result.workflows).toHaveLength(1);
expect(result.workflows[0].status).toBe('created');
expect(result.variables).toEqual({ matched: [], missing: ['API_URL'] });
expect(await variablesRepository.count()).toBe(variablesBefore);
expect(await variablesInProject(targetProject.id)).toEqual([]);
expect(await workflowRepository.count()).toBe(2);
});
it('matches a variable that already exists in the target project', async () => {
const owner = await createOwner();
const sourceProject = await createTeamProject('Source', owner);
const targetProject = await createTeamProject('Target', owner);
await createProjectVariable('API_URL', 'https://source.example.com', sourceProject);
await createProjectVariable('API_URL', 'https://target.example.com', targetProject);
const workflow = await buildWorkflowReferencingVariables({
name: 'Workflow with vars',
project: sourceProject,
variableNames: ['API_URL'],
});
const packageBuffer = await exportWorkflowPackage(owner, workflow.id);
const variablesBefore = await variablesRepository.count();
const result = await importPackage({
user: owner,
projectId: targetProject.id,
packageBuffer,
});
expect(result.variables).toEqual({ matched: ['API_URL'], missing: [] });
expect(await variablesRepository.count()).toBe(variablesBefore);
const targetVars = await variablesInProject(targetProject.id);
expect(targetVars).toHaveLength(1);
expect(targetVars[0].value).toBe('https://target.example.com');
});
it('matches via a global variable when none exists in the target project', async () => {
const owner = await createOwner();
const sourceProject = await createTeamProject('Source', owner);
const targetProject = await createTeamProject('Target', owner);
await createVariable('API_URL', 'https://global.example.com');
const workflow = await buildWorkflowReferencingVariables({
name: 'Workflow with vars',
project: sourceProject,
variableNames: ['API_URL'],
});
const packageBuffer = await exportWorkflowPackage(owner, workflow.id);
const variablesBefore = await variablesRepository.count();
const result = await importPackage({
user: owner,
projectId: targetProject.id,
packageBuffer,
});
expect(result.variables).toEqual({ matched: ['API_URL'], missing: [] });
expect(await variablesInProject(targetProject.id)).toEqual([]);
expect(await variablesRepository.count()).toBe(variablesBefore);
});
it('defaults to do-nothing when the caller does not override the policy', async () => {
const owner = await createOwner();
const sourceProject = await createTeamProject('Source', owner);
const targetProject = await createTeamProject('Target', owner);
await createProjectVariable('API_URL', 'https://source.example.com', sourceProject);
const workflow = await buildWorkflowReferencingVariables({
name: 'Workflow with vars',
project: sourceProject,
variableNames: ['API_URL'],
});
const packageBuffer = await exportWorkflowPackage(owner, workflow.id);
const result = await importPackage({
user: owner,
projectId: targetProject.id,
packageBuffer,
});
expect(result.variables).toEqual({ matched: [], missing: ['API_URL'] });
expect(await variablesInProject(targetProject.id)).toEqual([]);
});
});
});
@@ -38,6 +38,7 @@ const scope = (input: {
credentialResult: CredentialApplyResult;
requirements?: PackageCredentialRequirement[];
dataTable?: { matched: number; created: number; requirements: number };
variables?: { matched: number; missing: number; requirements: number };
}): PackageImportScope => {
const context: ImportContext = {
user: mock(),
@@ -45,12 +46,14 @@ const scope = (input: {
folderId: input.folderId ?? null,
};
const dt = input.dataTable ?? { matched: 0, created: 0, requirements: 0 };
const vars = input.variables ?? { matched: 0, missing: 0, requirements: 0 };
const imported: ImportOrchestrationResult = {
workflowOutcomes: input.outcomes,
folderSummaries: [],
bindings: { workflows: new Map(), credentials: new Map() },
credentialResult: input.credentialResult,
dataTablePlan: { creations: new Array(dt.created), failures: [], matchedCount: dt.matched },
variablePlan: { matched: new Array(vars.matched), missing: new Array(vars.missing) },
};
return {
context,
@@ -64,6 +67,10 @@ const scope = (input: {
dataTableRequest: mock<DataTableImportRequest>({
requirements: dt.requirements === 0 ? undefined : new Array(dt.requirements),
}),
variableRequest: {
requirements: vars.requirements === 0 ? undefined : new Array(vars.requirements),
missingPolicy: 'do-nothing',
},
};
};
@@ -106,6 +113,7 @@ describe('emitPackageImportedEvent', () => {
},
requirements: [requirement('credA')],
dataTable: { matched: 1, created: 0, requirements: 1 },
variables: { matched: 1, missing: 0, requirements: 1 },
}),
scope({
projectId: 'P2',
@@ -117,6 +125,7 @@ describe('emitPackageImportedEvent', () => {
},
requirements: [requirement('credB')],
dataTable: { matched: 0, created: 2, requirements: 2 },
variables: { matched: 0, missing: 2, requirements: 2 },
}),
],
});
@@ -137,6 +146,7 @@ describe('emitPackageImportedEvent', () => {
workflows: { created: 1, updated: 1, skipped: 1 },
credentials: { matched: 1, created: 1, requirements: 2 },
dataTables: { matched: 1, created: 2, requirements: 3 },
variables: { matched: 1, missing: 2, requirements: 3 },
});
expect(payload.packageSourceId).toBe('src-1');
});
@@ -20,6 +20,11 @@ import type {
PreparedFolder,
} from '../entities/folder/folder-import.types';
import { FolderImporter } from '../entities/folder/folder-importer';
import { VariableImporter } from '../entities/variable/variable-importer';
import type {
VariableImportPlan,
VariableImportRequest,
} from '../entities/variable/variable.types';
import type {
PreparedWorkflow,
WorkflowImportOutcome,
@@ -43,6 +48,7 @@ export interface ImportOrchestrationInput {
workflows: PreparedWorkflow[];
credentialRequest: CredentialBindingRequest;
dataTableRequest: DataTableImportRequest;
variableRequest: VariableImportRequest;
options: ImportWorkflowProperties & ImportFolderProperties;
/** The target project does not exist yet and will be created by this import (project packages). */
projectPendingCreation?: boolean;
@@ -54,6 +60,7 @@ export interface ImportOrchestrationResult {
bindings: PackageImportBindings;
credentialResult: CredentialApplyResult;
dataTablePlan: DataTableImportPlan;
variablePlan: VariableImportPlan;
}
export interface ImportPlan {
@@ -63,6 +70,7 @@ export interface ImportPlan {
workflowPlan: WorkflowImportPlan;
folderPlan: FolderImportPlan;
dataTablePlan: DataTableImportPlan;
variablePlan: VariableImportPlan;
blockingIssues: BlockingIssue[];
}
@@ -75,6 +83,7 @@ export class ImportOrchestrator {
constructor(
private readonly credentialImporter: CredentialImporter,
private readonly dataTableImporter: DataTableImporter,
private readonly variableImporter: VariableImporter,
private readonly folderImporter: FolderImporter,
private readonly workflowImporter: WorkflowImporter,
private readonly workflowPublisher: WorkflowPublisher,
@@ -89,7 +98,15 @@ export class ImportOrchestrator {
}
async plan(input: ImportOrchestrationInput): Promise<ImportPlan> {
const { context, folders, workflows, credentialRequest, dataTableRequest, options } = input;
const {
context,
folders,
workflows,
credentialRequest,
dataTableRequest,
variableRequest,
options,
} = input;
await this.workflowPublisher.assertCanPublish(
context.user,
@@ -100,6 +117,7 @@ export class ImportOrchestrator {
const credentialPlan = await this.credentialImporter.plan(context, credentialRequest);
const dataTablePlan = await this.dataTableImporter.plan(context, dataTableRequest);
const variablePlan = await this.variableImporter.plan(context, variableRequest);
const workflowPlan = await this.workflowImporter.plan(context, workflows, options);
const folderContext = { ...context, folderConflictPolicy: options.folderConflictPolicy };
const folderPlan = await this.folderImporter.plan(folderContext, folders);
@@ -119,12 +137,21 @@ export class ImportOrchestrator {
workflowPlan,
folderPlan,
dataTablePlan,
variablePlan,
blockingIssues,
};
}
async apply(plan: ImportPlan): Promise<ImportOrchestrationResult> {
const { input, folderContext, credentialPlan, workflowPlan, folderPlan, dataTablePlan } = plan;
const {
input,
folderContext,
credentialPlan,
workflowPlan,
folderPlan,
dataTablePlan,
variablePlan,
} = plan;
const { context, credentialRequest, options } = input;
const folderSummaries = await this.folderImporter.apply(folderContext, folderPlan);
@@ -157,6 +184,7 @@ export class ImportOrchestrator {
bindings,
credentialResult,
dataTablePlan,
variablePlan,
};
}
@@ -13,6 +13,7 @@ import type {
ImportedWorkflowSummary,
ImportPackageSummary,
ImportResult,
ImportVariableSummary,
PackageImportBindings,
} from '../n8n-packages.types';
import type { PackageManifest } from '../spec/manifest.schema';
@@ -48,7 +49,8 @@ export function buildImportResult(input: {
folders: ImportedFolderSummary[];
projects: ImportedProjectSummary[];
bindings: PackageImportBindings;
credentials?: ImportCredentialSummary;
credentials: ImportCredentialSummary;
variables: ImportVariableSummary;
}): ImportResult {
return {
package: input.package,
@@ -56,7 +58,8 @@ export function buildImportResult(input: {
folders: input.folders,
projects: input.projects,
bindings: serializeBindings(input.bindings),
credentials: input.credentials ?? { matched: [], stubbed: [] },
credentials: input.credentials,
variables: input.variables,
};
}
@@ -2,6 +2,7 @@ import type { EventService } from '@/events/event.service';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import type { VariableImportRequest } from '../entities/variable/variable.types';
import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types';
import type { ImportContext, ImportPackageRequest } from '../n8n-packages.types';
import type { ImportOrchestrationResult } from './import-orchestrator';
@@ -12,6 +13,7 @@ export interface PackageImportScope {
imported: ImportOrchestrationResult;
credentialRequest: CredentialBindingRequest;
dataTableRequest: DataTableImportRequest;
variableRequest: VariableImportRequest;
}
export function emitPackageImportedEvent(
@@ -52,6 +54,14 @@ export function emitPackageImportedEvent(
0,
);
const variablePlans = scopes.map(({ imported }) => imported.variablePlan);
const variableRequirements = scopes.reduce(
(total, { variableRequest }) => total + (variableRequest.requirements?.length ?? 0),
0,
);
const variablesMatched = variablePlans.reduce((total, plan) => total + plan.matched.length, 0);
const variablesMissing = variablePlans.reduce((total, plan) => total + plan.missing.length, 0);
const folderId = scopes.length === 1 ? scopes[0].context.folderId : null;
eventService.emit('n8n-package-imported', {
@@ -68,6 +78,7 @@ export function emitPackageImportedEvent(
dataTableMatchingMode: request.dataTableMatchingMode,
dataTableMissingMode: request.dataTableMissingMode,
dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy,
variableMissingPolicy: request.variableMissingPolicy,
},
packageSourceId: manifest.sourceId,
packageVersion: manifest.packageFormatVersion,
@@ -92,6 +103,11 @@ export function emitPackageImportedEvent(
created: dataTablesCreated,
requirements: dataTableRequirements,
},
variables: {
matched: variablesMatched,
missing: variablesMissing,
requirements: variableRequirements,
},
},
});
}
@@ -7,6 +7,7 @@ import { EventService } from '@/events/event.service';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import { ProjectImporter } from '../entities/project/project-importer';
import type { VariableImportRequest } from '../entities/variable/variable.types';
import type { PackageReader } from '../io/package-reader';
import type {
BlockingIssue,
@@ -87,6 +88,8 @@ export class ProjectPackageImporter {
const scopedBindings: PackageImportBindings[] = [];
const matched: string[] = [];
const stubbed: string[] = [];
const variablesMatched = new Set<string>();
const variablesMissing = new Set<string>();
const scopes: PackageImportScope[] = [];
for (const { project, plan } of planned) {
@@ -96,11 +99,14 @@ export class ProjectPackageImporter {
scopedBindings.push(imported.bindings);
matched.push(...imported.credentialResult.matched);
stubbed.push(...imported.credentialResult.stubbed);
imported.variablePlan.matched.forEach((name) => variablesMatched.add(name));
imported.variablePlan.missing.forEach((name) => variablesMissing.add(name));
scopes.push({
context: plan.input.context,
imported,
credentialRequest: plan.input.credentialRequest,
dataTableRequest: plan.input.dataTableRequest,
variableRequest: plan.input.variableRequest,
});
}
@@ -113,6 +119,7 @@ export class ProjectPackageImporter {
projects: projectSummaries,
bindings: mergeBindings(...scopedBindings),
credentials: { matched, stubbed },
variables: { matched: [...variablesMatched], missing: [...variablesMissing] },
});
}
@@ -148,6 +155,11 @@ export class ProjectPackageImporter {
schemaConflictPolicy: request.dataTableSchemaConflictPolicy,
};
const variableRequest: VariableImportRequest = {
requirements: identifyRequirements(manifest.requirements?.variables, workflows),
missingPolicy: request.variableMissingPolicy,
};
return {
context: {
user: request.user,
@@ -158,6 +170,7 @@ export class ProjectPackageImporter {
workflows,
credentialRequest,
dataTableRequest,
variableRequest,
options: request,
projectPendingCreation,
};
@@ -12,6 +12,7 @@ import { ProjectService } from '@/services/project.service.ee';
import type { CredentialBindingRequest } from '../entities/credential/credential.types';
import type { DataTableImportRequest } from '../entities/data-table/data-table.types';
import type { VariableImportRequest } from '../entities/variable/variable.types';
import type { PackageReader } from '../io/package-reader';
import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types';
import { ImportOrchestrator } from './import-orchestrator';
@@ -82,19 +83,25 @@ export class WorkflowPackageImporter {
schemaConflictPolicy: request.dataTableSchemaConflictPolicy,
};
const variableRequest: VariableImportRequest = {
requirements: identifyRequirements(manifest.requirements?.variables, workflows),
missingPolicy: request.variableMissingPolicy,
};
const imported = await this.importOrchestrator.import({
context,
folders,
workflows,
credentialRequest,
dataTableRequest,
variableRequest,
options: request,
});
emitPackageImportedEvent(this.eventService, {
request,
manifest,
scopes: [{ context, imported, credentialRequest, dataTableRequest }],
scopes: [{ context, imported, credentialRequest, dataTableRequest, variableRequest }],
});
return buildImportResult({
@@ -107,6 +114,10 @@ export class WorkflowPackageImporter {
matched: imported.credentialResult.matched,
stubbed: imported.credentialResult.stubbed,
},
variables: {
matched: imported.variablePlan.matched,
missing: imported.variablePlan.missing,
},
});
}
@@ -0,0 +1,152 @@
import type { Variables } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { VariableImporter } from '../variable-importer';
import type { ImportContext } from '../../../n8n-packages.types';
const context: ImportContext = {
user: mock(),
projectId: 'proj-target',
folderId: null,
};
function makeVariable(overrides: Partial<Variables> = {}): Variables {
return {
id: 'var-1',
key: 'API_URL',
type: 'string',
value: 'https://api.example.com',
project: null,
...overrides,
} as unknown as Variables;
}
function makeImporter() {
const variablesService = mock<VariablesService>();
const importer = new VariableImporter(variablesService);
return { importer, variablesService };
}
describe('VariableImporter', () => {
describe('plan', () => {
it('returns an empty plan and skips the service when there are no requirements', async () => {
const { importer, variablesService } = makeImporter();
const plan = await importer.plan(context, {
requirements: undefined,
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: [], missing: [] });
expect(variablesService.getAllCached).not.toHaveBeenCalled();
});
it('returns an empty plan for an empty requirements list', async () => {
const { importer, variablesService } = makeImporter();
const plan = await importer.plan(context, {
requirements: [],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: [], missing: [] });
expect(variablesService.getAllCached).not.toHaveBeenCalled();
});
it('reports a missing name when no variable resolves in the target project or globally', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([]);
const plan = await importer.plan(context, {
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: [], missing: ['API_URL'] });
});
it('matches a project-scoped variable in the target project', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([
makeVariable({
id: 'var-project',
project: { id: 'proj-target' } as Variables['project'],
}),
]);
const plan = await importer.plan(context, {
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: ['API_URL'], missing: [] });
});
it('falls back to a global variable when none exists in the target project', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([makeVariable({ id: 'var-global' })]);
const plan = await importer.plan(context, {
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: ['API_URL'], missing: [] });
});
it('matches the project-scoped variable when it shadows a same-key global', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([
makeVariable({ id: 'var-global', value: 'https://global.example.com' }),
makeVariable({
id: 'var-project',
value: 'https://project.example.com',
project: { id: 'proj-target' } as Variables['project'],
}),
]);
const plan = await importer.plan(context, {
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: ['API_URL'], missing: [] });
});
it('does not match a project-scoped variable from a different project', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([
makeVariable({
id: 'var-other',
project: { id: 'proj-other' } as Variables['project'],
}),
]);
const plan = await importer.plan(context, {
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: [], missing: ['API_URL'] });
});
it('classifies each requirement independently', async () => {
const { importer, variablesService } = makeImporter();
variablesService.getAllCached.mockResolvedValue([
makeVariable({ id: 'var-url', key: 'API_URL' }),
]);
const plan = await importer.plan(context, {
requirements: [
{ name: 'API_URL', usedByWorkflows: ['wf-1'] },
{ name: 'API_KEY', usedByWorkflows: ['wf-1'] },
],
missingPolicy: 'do-nothing',
});
expect(plan).toEqual({ matched: ['API_URL'], missing: ['API_KEY'] });
});
});
});
@@ -0,0 +1,46 @@
import { Service } from '@n8n/di';
import { pickVariableForProject } from 'n8n-workflow';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import type { VariableImportPlan, VariableImportRequest } from './variable.types';
import type { ImportContext } from '../../n8n-packages.types';
@Service()
export class VariableImporter {
constructor(private readonly variablesService: VariablesService) {}
/**
* Resolves the package's variable requirements against the target project
* (then global), mirroring runtime `$vars` precedence. Read-only for
* `do-nothing`: matched names and unresolved names are reported, nothing
* is created.
*/
async plan(context: ImportContext, request: VariableImportRequest): Promise<VariableImportPlan> {
const requirements = request.requirements ?? [];
if (requirements.length === 0) return { matched: [], missing: [] };
const allVariables = await this.variablesService.getAllCached();
const variablesByKey = new Map<string, typeof allVariables>();
for (const variable of allVariables) {
const bucket = variablesByKey.get(variable.key);
if (bucket) bucket.push(variable);
else variablesByKey.set(variable.key, [variable]);
}
const matched: string[] = [];
const missing: string[] = [];
for (const { name } of requirements) {
const picked = pickVariableForProject(
variablesByKey.get(name) ?? [],
name,
context.projectId,
);
if (picked) matched.push(name);
else missing.push(name);
}
return { matched, missing };
}
}
@@ -3,6 +3,7 @@ import type { User } from '@n8n/db';
import type { PackageWriter } from '../../io/package-writer';
import type { ManifestEntry } from '../../spec/manifest.schema';
import type { PackageVariableRequirement } from '../../spec/requirements.schema';
import type { VariableMissingPolicy } from '../../n8n-packages.types';
export interface WorkflowVariableRequirement {
workflowId: string;
@@ -21,3 +22,15 @@ export interface VariableExportResult {
entries: ManifestEntry[];
requirements: PackageVariableRequirement[];
}
export interface VariableImportRequest {
requirements: PackageVariableRequirement[] | undefined;
missingPolicy: VariableMissingPolicy;
}
export interface VariableImportPlan {
/** Requirement names that resolve in the target project or at the global level. */
matched: string[];
/** Requirement names with no match in the lookup scope. Reported as warnings under `do-nothing`. */
missing: string[];
}
@@ -73,6 +73,11 @@ export const DataTableSchemaConflictPolicy = {
/** Strict drift detection: fails the import on any schema difference, including target-only columns. */
Fail: 'fail',
} as const;
export const VariableMissingPolicy = {
/** Imports workflows even when referenced variables are absent. Nothing is created; unresolved names are reported as warnings in the response. */
DoNothing: 'do-nothing',
} as const;
/* eslint-enable @typescript-eslint/naming-convention */
export type WorkflowConflictPolicy =
@@ -93,6 +98,9 @@ export type DataTableMissingMode = (typeof DataTableMissingMode)[keyof typeof Da
export type DataTableSchemaConflictPolicy =
(typeof DataTableSchemaConflictPolicy)[keyof typeof DataTableSchemaConflictPolicy];
export type VariableMissingPolicy =
(typeof VariableMissingPolicy)[keyof typeof VariableMissingPolicy];
export interface ExportPackageRequest {
user: User;
workflowIds?: string[];
@@ -113,7 +121,8 @@ export type ImportPackageRequest = {
} & ImportCredentialProperties &
ImportWorkflowProperties &
ImportFolderProperties &
ImportDataTableProperties;
ImportDataTableProperties &
ImportVariableProperties;
export type ImportCredentialProperties = {
credentialMatchingMode: CredentialMatchingMode;
@@ -136,6 +145,10 @@ export type ImportDataTableProperties = {
dataTableSchemaConflictPolicy: DataTableSchemaConflictPolicy;
};
export type ImportVariableProperties = {
variableMissingPolicy: VariableMissingPolicy;
};
/**
* The actor and resolved destination an import writes into. Threaded through
* each entity importer so they share one resolved target instead of re-deriving
@@ -151,7 +164,8 @@ export interface ImportContext {
export type ImportPackageEventOptions = ImportCredentialProperties &
ImportWorkflowProperties &
ImportDataTableProperties;
ImportDataTableProperties &
ImportVariableProperties;
/** Credential ids involved in a package import, shaped for forward-compatible audit events. */
export type ImportAuditCredentialIds = {
@@ -180,6 +194,11 @@ export type ImportPackageEventCounts = {
created: number;
requirements: number;
};
variables: {
matched: number;
missing: number;
requirements: number;
};
};
/** Per-entity counts for an export, carried on `n8n-package-exported` for telemetry. */
@@ -299,6 +318,11 @@ export interface ImportCredentialSummary {
stubbed: string[];
}
export interface ImportVariableSummary {
matched: string[];
missing: string[];
}
export interface ImportResult {
package: ImportPackageSummary;
workflows: ImportedWorkflowSummary[];
@@ -306,4 +330,5 @@ export interface ImportResult {
projects: ImportedProjectSummary[];
bindings: SerializedBindings;
credentials: ImportCredentialSummary;
variables: ImportVariableSummary;
}
@@ -583,9 +583,31 @@ describe('n8n-packages handler', () => {
expect(caught).toBeUndefined();
expect(mockService.importPackage).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'proj-brie', workflowConflictPolicy: 'fail' }),
expect.objectContaining({
projectId: 'proj-brie',
workflowConflictPolicy: 'fail',
variableMissingPolicy: 'do-nothing',
}),
);
expect(mockEventService.emit).not.toHaveBeenCalled();
});
it('forwards variableMissingPolicy when provided', async () => {
const result = { package: {}, workflows: [], bindings: {}, credentials: {} };
mockService.importPackage.mockResolvedValue(result as never);
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
const caught = await runImport(
makeImportRequest({ projectId: 'proj-brie', variableMissingPolicy: 'do-nothing' }, [
'workflow:import',
]),
res,
);
expect(caught).toBeUndefined();
expect(mockService.importPackage).toHaveBeenCalledWith(
expect.objectContaining({ variableMissingPolicy: 'do-nothing' }),
);
});
});
});
@@ -197,6 +197,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
dataTableMatchingMode: payload.data.dataTableMatchingMode,
dataTableMissingMode: payload.data.dataTableMissingMode,
dataTableSchemaConflictPolicy: payload.data.dataTableSchemaConflictPolicy,
variableMissingPolicy: payload.data.variableMissingPolicy,
packageBuffer: packageFile.buffer,
});
return res.status(200).json(result);
@@ -185,6 +185,19 @@ post:
column or a type mismatch always rejects. Both policies are
non-destructive — the matched target table is never altered, and
package columns are never added to it.
variableMissingPolicy:
type: string
enum:
- do-nothing
default: do-nothing
description: >
Controls what happens when a variable referenced by the package's
workflows is absent from the target project and the global scope
(lookup order: project, then global). `do-nothing` (default)
imports the workflows without creating the missing variable and
lists its name under `variables.missing` in the response so the
caller can fill it in afterwards. Additional policies will be
added in follow-up work.
responses:
'200':
description: Import succeeded; the listed workflows were written to the target project.
@@ -199,6 +212,7 @@ post:
- projects
- bindings
- credentials
- variables
properties:
package:
type: object
@@ -382,6 +396,31 @@ post:
description: >
Source credential ids for which empty placeholder
credentials were created in the target project.
variables:
type: object
description: >
Variable names from the package requirements, grouped by whether
they resolved in the target project (or globally) or remain
unresolved. Names only — values never travel in the response.
required:
- matched
- missing
properties:
matched:
type: array
items:
type: string
description: >
Variable names that already exist in the target project or
at the global level (project first, then global).
missing:
type: array
items:
type: string
description: >
Variable names with no match in the lookup scope. Under
`variableMissingPolicy=do-nothing` these are warnings —
the import still succeeds and nothing is created.
bindings:
type: object
description: >
@@ -228,6 +228,10 @@ describe('POST /n8n-packages/import', () => {
matched: [],
stubbed: [],
},
variables: {
matched: [],
missing: [],
},
});
expect(response.body.workflows[0].localId).not.toBe('wf-http-source');
@@ -248,6 +252,7 @@ describe('POST /n8n-packages/import', () => {
.field('dataTableMatchingMode', 'by-id')
.field('dataTableMissingMode', 'must-preexist')
.field('dataTableSchemaConflictPolicy', 'fail')
.field('variableMissingPolicy', 'do-nothing')
.attach('package', tarBuffer, 'import.n8np');
expect(response.statusCode).toBe(200);