mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Resolve variable requirements on package import with do-nothing policy (no-changelog) (#34438)
This commit is contained in:
committed by
GitHub
parent
babc0b7344
commit
54a95fea6c
@@ -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: '{}',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+192
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -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;
|
||||
}
|
||||
|
||||
+23
-1
@@ -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);
|
||||
|
||||
+39
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user