From 54a95fea6ca20e421a93a8fca6bc5f379516dc2d Mon Sep 17 00:00:00 2001 From: Nour Alhadi Mahmoud Date: Tue, 21 Jul 2026 19:31:37 +0200 Subject: [PATCH] feat(core): Resolve variable requirements on package import with do-nothing policy (no-changelog) (#34438) --- .../import-package-request.dto.test.ts | 34 ++++ .../packages/import-package-request.dto.ts | 2 + packages/@n8n/cli/docs/commands/package.md | 1 + .../cli/src/__tests__/package-import.test.ts | 3 + packages/@n8n/cli/src/client.ts | 1 + .../@n8n/cli/src/commands/package/import.ts | 7 + .../log-streaming-event-relay.test.ts | 7 + .../__tests__/telemetry-event-relay.test.ts | 10 + .../events/relays/telemetry.event-relay.ts | 4 + .../import-data-tables.integration.test.ts | 1 + .../import-folders.integration.test.ts | 1 + .../import-package.integration.test.ts | 23 +++ .../import-projects.integration.test.ts | 50 +++++ ...orkflow-with-variables.integration.test.ts | 192 ++++++++++++++++++ .../engine/__tests__/import-telemetry.test.ts | 10 + .../engine/import-orchestrator.ts | 32 ++- .../n8n-packages/engine/import-result.ts | 7 +- .../n8n-packages/engine/import-telemetry.ts | 16 ++ .../engine/project-package-importer.ts | 13 ++ .../engine/workflow-package-importer.ts | 13 +- .../__tests__/variable-importer.test.ts | 152 ++++++++++++++ .../entities/variable/variable-importer.ts | 46 +++++ .../entities/variable/variable.types.ts | 13 ++ .../n8n-packages/n8n-packages.types.ts | 29 ++- .../__tests__/n8n-packages.handler.test.ts | 24 ++- .../n8n-packages/n8n-packages.handler.ts | 1 + .../spec/paths/n8n-packages.import.yml | 39 ++++ .../public-api/n8n-packages-import.test.ts | 5 + 28 files changed, 728 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts diff --git a/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts index 6b7edc4b910..1b080b925df 100644 --- a/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/packages/__tests__/import-package-request.dto.test.ts @@ -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); + }); + }); }); diff --git a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts index b4b6778d7ca..2a720341c13 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-package-request.dto.ts @@ -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'), }) {} diff --git a/packages/@n8n/cli/docs/commands/package.md b/packages/@n8n/cli/docs/commands/package.md index e10fe9061ff..9c05a1bcad1 100644 --- a/packages/@n8n/cli/docs/commands/package.md +++ b/packages/@n8n/cli/docs/commands/package.md @@ -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":{"":""}}`. 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` diff --git a/packages/@n8n/cli/src/__tests__/package-import.test.ts b/packages/@n8n/cli/src/__tests__/package-import.test.ts index 23d9ea21bdd..d785a6956ac 100644 --- a/packages/@n8n/cli/src/__tests__/package-import.test.ts +++ b/packages/@n8n/cli/src/__tests__/package-import.test.ts @@ -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: '{}', }); }); diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index 934675cdc40..f9c3fb7761e 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -22,6 +22,7 @@ export interface ImportPackageFields { dataTableMatchingMode?: string; dataTableMissingMode?: string; dataTableSchemaConflictPolicy?: string; + variableMissingPolicy?: string; } export interface ExportPackageFields { diff --git a/packages/@n8n/cli/src/commands/package/import.ts b/packages/@n8n/cli/src/commands/package/import.ts index a36d5668823..4e5126d5b2e 100644 --- a/packages/@n8n/cli/src/commands/package/import.ts +++ b/packages/@n8n/cli/src/commands/package/import.ts @@ -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":{"":""}}\'. 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, }, ); diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 9398c34b482..cd3f9451123 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -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', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index bcce8465ac0..58207a46b32 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -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, }); }); diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index ec66076675f..0d703e9e97c 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -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, }); } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts index e3e1dc021e5..4af0c26ae12 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts @@ -65,6 +65,7 @@ async function importPackage(params: ImportParams) { dataTableMatchingMode: 'by-id', dataTableMissingMode: 'create', dataTableSchemaConflictPolicy: 'keep-existing', + variableMissingPolicy: 'do-nothing', ...params, }); } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts index 936961f37ce..59a93ff0d3f 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts @@ -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); } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts index c7f3d6c31b4..b4b67363948 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts @@ -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(); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts index e4c40e56a80..0223699e591 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts @@ -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: [ diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts new file mode 100644 index 00000000000..78188cda015 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts @@ -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 +>; + +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 { + 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([]); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts index ec7ca9541a8..60c2d6cadff 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts @@ -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({ 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'); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index 142a65fa889..b080296fdd0 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -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 { - 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 { - 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, }; } diff --git a/packages/cli/src/modules/n8n-packages/engine/import-result.ts b/packages/cli/src/modules/n8n-packages/engine/import-result.ts index 9883115b862..7ad4652248e 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-result.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-result.ts @@ -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, }; } diff --git a/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts index 128275b1040..cb0308d3c8c 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts @@ -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, + }, }, }); } diff --git a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts index cf5882e732e..73f916d80cc 100644 --- a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts @@ -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(); + const variablesMissing = new Set(); 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, }; diff --git a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts index deb54781d45..f9aaddebf96 100644 --- a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts @@ -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, + }, }); } diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts new file mode 100644 index 00000000000..d5842877fa1 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts @@ -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 { + 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(); + 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'] }); + }); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts new file mode 100644 index 00000000000..c2539e18e14 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts @@ -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 { + const requirements = request.requirements ?? []; + if (requirements.length === 0) return { matched: [], missing: [] }; + + const allVariables = await this.variablesService.getAllCached(); + const variablesByKey = new Map(); + 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 }; + } +} diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts index 405c50e46db..3353c28a188 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts @@ -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[]; +} diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index ae03fde2acc..ae70b6f0c0e 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -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; } diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts index 476937d591b..973121af8dd 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -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' }), + ); + }); }); }); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index b1603bc2183..26cc430da0b 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -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); diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml index 30246e3b131..07cbed3759c 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.import.yml @@ -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: > diff --git a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts index 62e73772860..a687fcb0793 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts @@ -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);