feat(core): Handle project variables sync on source control (#21001)

This commit is contained in:
Guillaume Jacquart 2025-10-22 10:26:22 +02:00 committed by GitHub
parent 5d431aabeb
commit 832774db80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 276 additions and 53 deletions

View File

@ -4,6 +4,7 @@ import { WithTimestampsAndStringId } from './abstract-entity';
import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
import type { Variables } from './variables';
@Entity()
export class Project extends WithTimestampsAndStringId {
@ -27,4 +28,7 @@ export class Project extends WithTimestampsAndStringId {
@OneToMany('SharedWorkflow', 'project')
sharedWorkflows: SharedWorkflow[];
@OneToMany('Variables', 'project')
variables: Variables[];
}

View File

@ -1,7 +1,7 @@
import { Column, Entity, ManyToOne } from '@n8n/typeorm';
import { WithStringId } from './abstract-entity';
import { Project } from './project';
import type { Project } from './project';
@Entity()
export class Variables extends WithStringId {

View File

@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import { DataSource, Repository } from '@n8n/typeorm';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { Variables } from '../entities';
@ -8,4 +8,8 @@ export class VariablesRepository extends Repository<Variables> {
constructor(dataSource: DataSource) {
super(Variables, dataSource.manager);
}
async deleteByIds(ids: string[]): Promise<void> {
await this.delete({ id: In(ids) });
}
}

View File

@ -13,6 +13,7 @@ import type {
WorkflowRepository,
WorkflowTagMapping,
WorkflowTagMappingRepository,
Variables,
} from '@n8n/db';
import { GLOBAL_ADMIN_ROLE, In, PROJECT_OWNER_ROLE, User } from '@n8n/db';
import { Container } from '@n8n/di';
@ -340,7 +341,7 @@ describe('SourceControlExportService', () => {
variablesService.getAllCached.mockResolvedValue([mock()]);
// Act
const result = await service.exportVariablesToWorkFolder();
const result = await service.exportGlobalVariablesToWorkFolder();
// Assert
expect(result.count).toBe(1);
@ -352,7 +353,7 @@ describe('SourceControlExportService', () => {
variablesService.getAllCached.mockResolvedValue([]);
// Act
const result = await service.exportVariablesToWorkFolder();
const result = await service.exportGlobalVariablesToWorkFolder();
// Assert
expect(result.count).toBe(0);
@ -418,6 +419,7 @@ describe('SourceControlExportService', () => {
icon: { type: 'icon', value: 'icon.png' },
description: 'Project 1',
type: 'team',
variables: [],
});
const project2 = mock<Project>({
id: 'project-id-2',
@ -425,6 +427,7 @@ describe('SourceControlExportService', () => {
icon: null,
description: 'Team Project',
type: 'team',
variables: [mock<Variables>({ key: 'VAR1', value: 'value1' })],
});
const expectedProject1Json = JSON.stringify(
@ -439,6 +442,7 @@ describe('SourceControlExportService', () => {
teamId: project1.id,
teamName: project1.name,
},
variableStubs: [],
},
null,
2,
@ -455,6 +459,12 @@ describe('SourceControlExportService', () => {
teamId: project2.id,
teamName: project2.name,
},
variableStubs: [
{
key: 'VAR1',
value: '',
},
],
},
null,
2,
@ -468,6 +478,7 @@ describe('SourceControlExportService', () => {
// Assert
expect(projectRepository.find).toHaveBeenCalledWith({
where: { id: In([project1.id, project2.id]), type: 'team' },
relations: ['variables'],
});
expect(fsWriteFile).toHaveBeenCalledWith(
'/mock/n8n/git/projects/project-id-1.json',

View File

@ -1,6 +1,8 @@
import type { SourceControlledFile } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import {
type Variables,
type VariablesRepository,
type FolderRepository,
GLOBAL_ADMIN_ROLE,
GLOBAL_MEMBER_ROLE,
@ -17,6 +19,8 @@ import { mock } from 'jest-mock-extended';
import { type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { SourceControlImportService } from '../source-control-import.service.ee';
import type { SourceControlScopedService } from '../source-control-scoped.service';
import type { ExportableFolder } from '../types/exportable-folders';
@ -44,10 +48,12 @@ describe('SourceControlImportService', () => {
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const mockLogger = mock<Logger>();
const sourceControlScopedService = mock<SourceControlScopedService>();
const variableService = mock<VariablesService>();
const variablesRepository = mock<VariablesRepository>();
const service = new SourceControlImportService(
mockLogger,
mock(),
mock(),
variableService,
mock(),
mock(),
projectRepository,
@ -55,7 +61,7 @@ describe('SourceControlImportService', () => {
sharedWorkflowRepository,
mock(),
mock(),
mock(),
variablesRepository,
workflowRepository,
mock(),
mock(),
@ -570,6 +576,7 @@ describe('SourceControlImportService', () => {
type: 'team',
teamId: 'project2',
},
variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1' }],
};
const candidates = [
mock<SourceControlledFile>({ file: mockProjectFile1, id: mockProjectData1.id }),
@ -580,6 +587,8 @@ describe('SourceControlImportService', () => {
.mockResolvedValueOnce(JSON.stringify(mockProjectData1))
.mockResolvedValueOnce(JSON.stringify(mockProjectData2));
variableService.getAllCached.mockResolvedValue([]);
// Act
const result = await service.importTeamProjectsFromWorkFolder(candidates);
@ -606,6 +615,14 @@ describe('SourceControlImportService', () => {
}),
['id'],
);
expect(variablesRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: 'var1',
key: 'VAR1',
value: 'value1',
}),
['id'],
);
expect(result).toEqual([
{
@ -699,6 +716,44 @@ describe('SourceControlImportService', () => {
},
]);
});
it('should delete project variables not in the imported stubs', async () => {
// Arrange
const mockProjectFile = '/mock/team-project.json';
const mockProjectData = {
id: 'project1',
name: 'Team Project 1',
icon: 'icon1.png',
description: 'First team project',
type: 'team',
owner: {
type: 'team',
teamId: 'project1',
},
variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1' }],
};
const candidates = [
mock<SourceControlledFile>({ file: mockProjectFile, id: mockProjectData.id }),
];
fsReadFile.mockResolvedValueOnce(JSON.stringify(mockProjectData));
variableService.getAllCached.mockResolvedValue([
{
id: 'var2',
key: 'VAR2',
value: 'value2',
type: 'string',
project: { id: 'project1' } as Project,
} as Variables,
]);
// Act
await service.importTeamProjectsFromWorkFolder(candidates);
// Assert
expect(variableService.deleteByIds).toHaveBeenCalledWith(['var2']);
});
});
describe('getRemoteProjectsFromFiles', () => {
@ -725,6 +780,7 @@ describe('SourceControlImportService', () => {
teamId: 'project2',
teamName: 'Team Project 2',
},
variableStubs: [{ id: 'var1', key: 'VAR1', value: 'value1', type: 'string' }],
};
it('should return all projects if the user has access to all projects', async () => {
@ -795,6 +851,7 @@ describe('SourceControlImportService', () => {
type: 'team',
createdAt: new Date(),
updatedAt: new Date(),
variables: [],
});
const mockProjectData2: Project = mock<Project>({
id: 'project2',
@ -804,6 +861,7 @@ describe('SourceControlImportService', () => {
type: 'team',
createdAt: new Date(),
updatedAt: new Date(),
variables: [],
});
const mockFilter = { id: 'test' };
@ -820,6 +878,7 @@ describe('SourceControlImportService', () => {
// making sure the correct filter is used
expect(projectRepository.find).toHaveBeenCalledWith({
select: ['id', 'name', 'description', 'icon', 'type'],
relations: ['variables'],
where: {
type: 'team',
...mockFilter,
@ -865,6 +924,7 @@ describe('SourceControlImportService', () => {
type: 'team',
createdAt: new Date(),
updatedAt: new Date(),
variables: [{ id: 'var1', key: 'VAR1', value: 'value1', type: 'string' }],
});
projectRepository.find.mockResolvedValue([mockProjectData1]);
@ -877,6 +937,7 @@ describe('SourceControlImportService', () => {
// making sure the correct filter is used
expect(projectRepository.find).toHaveBeenCalledWith({
select: ['id', 'name', 'description', 'icon', 'type'],
relations: ['variables'],
where: { type: 'team' },
});

View File

@ -62,7 +62,7 @@ describe('getStatus', () => {
// variables
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([]);
sourceControlImportService.getLocalGlobalVariablesFromDb.mockResolvedValue([]);
// folders
// Define a folder that does only exist remotely.
@ -201,7 +201,7 @@ describe('getStatus', () => {
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
sourceControlImportService.getLocalGlobalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
// Define a tag that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
@ -539,6 +539,48 @@ describe('getStatus', () => {
});
});
it('should identify projects with modified variables', async () => {
// ARRANGE
const user = mockUsers.globalAdmin;
const localProject = mockProjects.basic;
const remoteProject: ExportableProjectWithFileName = {
...mockProjects.basic,
variableStubs: [{ id: 'var1', key: 'VAR1', value: '', type: 'string' }],
};
// both projects exist but are different
setupProjectMocks({
remote: [remoteProject],
local: [localProject],
});
// ACT
const result = await sourceControlStatusService.getStatus(user, {
direction: 'push',
verbose: true,
preferLocalVersion: false,
});
// ASSERT
if (Array.isArray(result)) {
fail('Expected result to be an object.');
}
expect(result).toMatchObject({
projectsRemote: [remoteProject],
projectsLocal: [localProject],
projectsMissingInLocal: [],
projectsMissingInRemote: [],
projectsModifiedInEither: [remoteProject],
sourceControlledFiles: [
expect.objectContaining({
id: remoteProject.id,
conflict: true,
}),
],
});
});
it('should prevent out of scope projects from being deleted for non-global users', async () => {
// ARRANGE
const user = mockUsers.limitedUser;

View File

@ -181,11 +181,11 @@ describe('SourceControlService', () => {
);
sourceControlExportService.exportTagsToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce(
sourceControlExportService.exportGlobalVariablesToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
sourceControlExportService.exportFoldersToWorkFolder.mockResolvedValueOnce(mockExportResult);
sourceControlExportService.exportVariablesToWorkFolder.mockResolvedValueOnce(
sourceControlExportService.exportGlobalVariablesToWorkFolder.mockResolvedValueOnce(
mockExportResult,
);
@ -223,7 +223,7 @@ describe('SourceControlService', () => {
);
expect(sourceControlExportService.exportTagsToWorkFolder).toHaveBeenCalled();
expect(sourceControlExportService.exportFoldersToWorkFolder).toHaveBeenCalled();
expect(sourceControlExportService.exportVariablesToWorkFolder).toHaveBeenCalled();
expect(sourceControlExportService.exportGlobalVariablesToWorkFolder).toHaveBeenCalled();
// Deleted resources should be passed to rmFilesFromExportFolder
expect(sourceControlExportService.rmFilesFromExportFolder).toHaveBeenCalledWith(

View File

@ -20,6 +20,8 @@ import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workfl
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
import path from 'path';
import { formatWorkflow } from '@/workflows/workflow.formatter';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
@ -46,8 +48,7 @@ import { ExportableProject } from './types/exportable-project';
import type { ExportableWorkflow } from './types/exportable-workflow';
import type { RemoteResourceOwner } from './types/resource-owner';
import type { SourceControlContext } from './types/source-control-context';
import { formatWorkflow } from '@/workflows/workflow.formatter';
import { ExportableVariable } from './types/exportable-variable';
@Service()
export class SourceControlExportService {
@ -198,10 +199,10 @@ export class SourceControlExportService {
}
}
async exportVariablesToWorkFolder(): Promise<ExportResult> {
async exportGlobalVariablesToWorkFolder(): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.gitFolder]);
const variables = await this.variablesService.getAllCached();
const variables = await this.variablesService.getAllCached({ globalOnly: true });
// do not export empty variables
if (variables.length === 0) {
return {
@ -211,7 +212,12 @@ export class SourceControlExportService {
};
}
const fileName = getVariablesPath(this.gitFolder);
const sanitizedVariables = variables.map((e) => ({ ...e, value: '' }));
const sanitizedVariables: ExportableVariable[] = variables.map((e) => ({
id: e.id,
key: e.key,
type: e.type,
value: '',
}));
await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2));
return {
count: sanitizedVariables.length,
@ -487,6 +493,7 @@ export class SourceControlExportService {
const projectIds = candidates.map((e) => e.id);
const projects = await this.projectRepository.find({
where: { id: In(projectIds), type: 'team' },
relations: ['variables'],
});
await Promise.all(
@ -504,6 +511,12 @@ export class SourceControlExportService {
teamId: project.id,
teamName: project.name,
},
variableStubs: project.variables.map((variable) => ({
id: variable.id,
key: variable.key,
type: variable.type,
value: '',
})),
};
this.logger.debug(`Writing project ${project.id} to ${fileName}`);

View File

@ -39,7 +39,6 @@ import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { WorkflowService } from '@/workflows/workflow.service';
import { VariablesService } from '../variables/variables.service.ee';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_FOLDERS_EXPORT_FILE,
@ -55,6 +54,7 @@ import {
getWorkflowExportPath,
} from './source-control-helper.ee';
import { SourceControlScopedService } from './source-control-scoped.service';
import { VariablesService } from '../variables/variables.service.ee';
import type {
ExportableCredential,
StatusExportableCredential,
@ -62,6 +62,7 @@ import type {
import type { ExportableFolder } from './types/exportable-folders';
import type { ExportableProject, ExportableProjectWithFileName } from './types/exportable-project';
import type { ExportableTags } from './types/exportable-tags';
import { ExportableVariable } from './types/exportable-variable';
import type {
RemoteResourceOwner,
StatusResourceOwner,
@ -421,22 +422,25 @@ export class SourceControlImportService {
}) as StatusExportableCredential[];
}
async getRemoteVariablesFromFile(): Promise<Variables[]> {
async getRemoteVariablesFromFile(): Promise<ExportableVariable[]> {
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
if (variablesFile.length > 0) {
this.logger.debug(`Importing variables from file ${variablesFile[0]}`);
return jsonParse<Variables[]>(await fsReadFile(variablesFile[0], { encoding: 'utf8' }), {
fallbackValue: [],
});
return jsonParse<ExportableVariable[]>(
await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
{
fallbackValue: [],
},
);
}
return [];
}
async getLocalVariablesFromDb(): Promise<Variables[]> {
return await this.variablesService.getAllCached();
async getLocalGlobalVariablesFromDb(): Promise<Variables[]> {
return await this.variablesService.getAllCached({ globalOnly: true });
}
async getRemoteFoldersAndMappingsFromFile(context: SourceControlContext): Promise<{
@ -593,6 +597,7 @@ export class SourceControlImportService {
const localProjects = await this.projectRepository.find({
select: ['id', 'name', 'description', 'icon', 'type'],
relations: ['variables'],
where,
});
@ -616,6 +621,12 @@ export class SourceControlImportService {
teamId: project.id,
teamName: project.name,
},
variableStubs: project.variables.map((variable) => ({
id: variable.id,
key: variable.key,
type: variable.type,
value: '',
})),
};
}
@ -923,41 +934,33 @@ export class SourceControlImportService {
return mappedFolders;
}
async importVariablesFromWorkFolder(
candidate: SourceControlledFile,
async importVariables(
variables: ExportableVariable[],
valueOverrides?: {
[key: string]: string;
},
) {
const result: { imported: string[] } = { imported: [] };
let importedVariables;
try {
this.logger.debug(`Importing variables from file ${candidate.file}`);
importedVariables = jsonParse<Array<Partial<Variables>>>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
{ fallbackValue: [] },
);
} catch (e) {
this.logger.error(`Failed to import tags from file ${candidate.file}`, { error: e });
return;
}
const overriddenKeys = Object.keys(valueOverrides ?? {});
for (const variable of importedVariables) {
for (const variable of variables) {
if (!variable.key) {
continue;
}
// by default no value is stored remotely, so an empty string is returned
// it must be changed to undefined so as to not overwrite existing values!
if (variable.value === '') {
variable.value = undefined;
}
if (overriddenKeys.includes(variable.key) && valueOverrides) {
variable.value = valueOverrides[variable.key];
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
}
try {
await this.variablesRepository.upsert({ ...variable }, ['id']);
// by default no value is stored remotely, so an empty string is returned
// it must be changed to undefined so as to not overwrite existing values!
const variableToUpsert = {
...variable,
value: variable.value === '' ? undefined : variable.value,
project: variable.projectId ? { id: variable.projectId } : null,
};
await this.variablesRepository.upsert(variableToUpsert, ['id']);
} catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) {
this.logger.debug(`Variable ${variable.key} already exists, updating instead`);
@ -990,6 +993,27 @@ export class SourceControlImportService {
return result;
}
async importVariablesFromWorkFolder(
candidate: SourceControlledFile,
valueOverrides?: {
[key: string]: string;
},
) {
let importedVariables;
try {
this.logger.debug(`Importing variables from file ${candidate.file}`);
importedVariables = jsonParse<ExportableVariable[]>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
{ fallbackValue: [] },
);
} catch (e) {
this.logger.error(`Failed to import tags from file ${candidate.file}`, { error: e });
return;
}
return await this.importVariables(importedVariables, valueOverrides);
}
/**
* Reads project files candidates from the work folder and imports them into the database.
*
@ -999,6 +1023,9 @@ export class SourceControlImportService {
*/
async importTeamProjectsFromWorkFolder(candidates: SourceControlledFile[]) {
const importResults = [];
const existingProjectVariables = (await this.variablesService.getAllCached()).filter(
(v) => v.project,
);
for (const candidate of candidates) {
try {
@ -1030,6 +1057,17 @@ export class SourceControlImportService {
['id'],
);
await this.importVariables(
project.variableStubs?.map((v) => ({ ...v, projectId: project.id })) ?? [],
);
// Delete variables that existed before but are no longer present in the imported project
const deletedVariables = existingProjectVariables.filter(
(v) =>
v.project!.id === project.id && !project.variableStubs?.some((vs) => vs.id === v.id),
);
await this.variablesService.deleteByIds(deletedVariables.map((v) => v.id));
this.logger.info(`Imported team project: ${project.name}`);
importResults.push({
id: project.id,

View File

@ -1,6 +1,6 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { FolderRepository, type TagEntity, TagRepository, type User, Variables } from '@n8n/db';
import { FolderRepository, type TagEntity, TagRepository, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { UserError } from 'n8n-workflow';
@ -23,6 +23,7 @@ import { SourceControlPreferencesService } from './source-control-preferences.se
import type { StatusExportableCredential } from './types/exportable-credential';
import type { ExportableFolder } from './types/exportable-folders';
import type { ExportableProjectWithFileName } from './types/exportable-project';
import { ExportableVariable } from './types/exportable-variable';
import { SourceControlContext } from './types/source-control-context';
import type { SourceControlGetStatus } from './types/source-control-get-status';
import type { SourceControlWorkflowVersionId } from './types/source-control-workflow-version-id';
@ -378,7 +379,7 @@ export class SourceControlStatusService {
sourceControlledFiles: SourceControlledFile[],
) {
const varRemoteIds = await this.sourceControlImportService.getRemoteVariablesFromFile();
const varLocalIds = await this.sourceControlImportService.getLocalVariablesFromDb();
const varLocalIds = await this.sourceControlImportService.getLocalGlobalVariablesFromDb();
const varMissingInLocal = varRemoteIds.filter(
(remote) => varLocalIds.findIndex((local) => local.id === remote.id) === -1,
@ -388,7 +389,7 @@ export class SourceControlStatusService {
(local) => varRemoteIds.findIndex((remote) => remote.id === local.id) === -1,
);
const varModifiedInEither: Variables[] = [];
const varModifiedInEither: ExportableVariable[] = [];
varLocalIds.forEach((local) => {
const mismatchingIds = varRemoteIds.find(
(remote) =>
@ -737,6 +738,9 @@ export class SourceControlStatusService {
? localProject.description
: remoteProjectWithSameId.description,
icon: options.preferLocalVersion ? localProject.icon : remoteProjectWithSameId.icon,
variableStubs: options.preferLocalVersion
? localProject.variableStubs
: remoteProjectWithSameId.variableStubs,
});
}
});
@ -806,6 +810,29 @@ export class SourceControlStatusService {
};
}
private areVariablesEqual(
localVariables: ExportableProjectWithFileName['variableStubs'],
remoteVariables: ExportableProjectWithFileName['variableStubs'],
): boolean {
if (Array.isArray(localVariables) !== Array.isArray(remoteVariables)) {
return false;
}
if (localVariables?.length !== remoteVariables?.length) {
return false;
}
const sortedLocalVars = [...(localVariables ?? [])].sort((a, b) => a.key.localeCompare(b.key));
const sortedRemoteVars = [...(remoteVariables ?? [])].sort((a, b) =>
a.key.localeCompare(b.key),
);
return sortedLocalVars.every((localVar, index) => {
const remoteVar = sortedRemoteVars[index];
return localVar.key === remoteVar.key && localVar.type === remoteVar.type;
});
}
private isProjectModified(
local: ExportableProjectWithFileName,
remote: ExportableProjectWithFileName,
@ -819,7 +846,8 @@ export class SourceControlStatusService {
isIconModified ||
remote.type !== local.type ||
remote.name !== local.name ||
remote.description !== local.description
remote.description !== local.description ||
!this.areVariablesEqual(local.variableStubs, remote.variableStubs)
);
}

View File

@ -337,7 +337,7 @@ export class SourceControlService {
const variablesChanges = filterByType(filesToPush, 'variables')[0];
if (variablesChanges) {
filesToBePushed.add(variablesChanges.file);
await this.sourceControlExportService.exportVariablesToWorkFolder();
await this.sourceControlExportService.exportGlobalVariablesToWorkFolder();
}
await this.gitService.stage(filesToBePushed, filesToBeDeleted);

View File

@ -1,3 +1,4 @@
import type { ExportableVariable } from './exportable-variable';
import type { TeamResourceOwner } from './resource-owner';
export interface ExportableProject {
@ -10,6 +11,7 @@ export interface ExportableProject {
*/
type: 'team';
owner: TeamResourceOwner;
variableStubs?: ExportableVariable[];
}
export type ExportableProjectWithFileName = ExportableProject & {

View File

@ -0,0 +1,7 @@
export interface ExportableVariable {
id: string;
key: string;
type: string;
value: string;
projectId?: string;
}

View File

@ -61,11 +61,19 @@ export class VariablesService {
return !!project;
}
async getAllCached(): Promise<Variables[]> {
const variables = await this.cacheService.get('variables', {
refreshFn: async () => await this.findAll(),
});
return variables ?? [];
async getAllCached(
filters: { globalOnly: boolean } = { globalOnly: false },
): Promise<Variables[]> {
const variables =
(await this.cacheService.get('variables', {
refreshFn: async () => await this.findAll(),
})) ?? [];
if (filters.globalOnly) {
return variables.filter((v) => !v.project);
}
return variables;
}
async getCached(id: string): Promise<Variables | null> {
@ -150,6 +158,11 @@ export class VariablesService {
await this.updateCache();
}
async deleteByIds(ids: string[]): Promise<void> {
await this.variablesRepository.deleteByIds(ids);
await this.updateCache();
}
private async canCreateNewVariable() {
if (!this.licenseState.isVariablesLicensed()) {
throw new FeatureNotLicensedError('feat:variables');