refactor(core): Let modules register favorite resource resolvers (#35909)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Charlie Kolb 2026-08-11 09:08:57 +02:00 committed by GitHub
parent da1d057268
commit f6f293bec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 240 additions and 77 deletions

View File

@ -59,6 +59,9 @@ export class AgentsModule implements ModuleInterface {
agentKnowledgeFileStore: Container.get(AgentKnowledgeFileStore),
});
const { registerFavoriteResolver } = await import('./register-favorite-resolver.js');
registerFavoriteResolver();
const { AgentRuntimeCacheService } = await import('./agent-runtime-cache.service.js');
Container.get(AgentRuntimeCacheService);

View File

@ -0,0 +1,20 @@
import { Container } from '@n8n/di';
import { FavoriteResourceResolverRegistry } from '@/modules/favorites/favorite-resource-resolver.registry';
import { AgentRepository } from './repositories/agent.repository';
export function registerFavoriteResolver() {
const agentRepository = Container.get(AgentRepository);
Container.get(FavoriteResourceResolverRegistry).register('agent', {
globalReadScope: 'agent:read',
findMeta: async (ids) =>
new Map(
(await agentRepository.findSummariesByIds(ids)).map(({ id, name, projectId }) => [
id,
{ name, projectId },
]),
),
exists: async (id) => await agentRepository.existsBy({ id }),
});
}

View File

@ -29,6 +29,9 @@ export class DataTableModule implements ModuleInterface {
},
});
const { registerFavoriteResolver } = await import('./register-favorite-resolver.js');
registerFavoriteResolver();
const { DataTableAggregateService } = await import('./data-table-aggregate.service.js');
await Container.get(DataTableAggregateService).start();

View File

@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Project, withTransaction } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, EntityManager, Repository, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, EntityManager, In, Repository, SelectQueryBuilder } from '@n8n/typeorm';
import {
DATA_TABLE_SYSTEM_COLUMNS,
DATA_TABLE_SYSTEM_TESTING_COLUMN,
@ -191,6 +191,17 @@ export class DataTableRepository extends Repository<DataTable> {
});
}
async findSummariesByIds(
ids: string[],
): Promise<Array<Pick<DataTable, 'id' | 'name' | 'projectId'>>> {
if (ids.length === 0) return [];
return await this.find({
select: ['id', 'name', 'projectId'],
where: { id: In(ids) },
});
}
async getManyAndCount(options: Partial<ListDataTableQueryDto>) {
const query = this.getManyQuery(options);
const [data, count] = await query.getManyAndCount();

View File

@ -0,0 +1,20 @@
import { Container } from '@n8n/di';
import { FavoriteResourceResolverRegistry } from '@/modules/favorites/favorite-resource-resolver.registry';
import { DataTableRepository } from './data-table.repository';
export function registerFavoriteResolver() {
const dataTableRepository = Container.get(DataTableRepository);
Container.get(FavoriteResourceResolverRegistry).register('dataTable', {
globalReadScope: 'dataTable:read',
findMeta: async (ids) =>
new Map(
(await dataTableRepository.findSummariesByIds(ids)).map(({ id, name, projectId }) => [
id,
{ name, projectId },
]),
),
exists: async (id) => await dataTableRepository.existsBy({ id }),
});
}

View File

@ -11,11 +11,13 @@ import { mock } from 'vitest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { DataTableRepository } from '@/modules/data-table/data-table.repository';
import type { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import type { UserFavorite } from '../database/entities/user-favorite.entity';
import type { UserFavoriteRepository } from '../database/repositories/user-favorite.repository';
import {
FavoriteResourceResolverRegistry,
type FavoriteResourceResolver,
} from '../favorite-resource-resolver.registry';
import { FavoritesService } from '../favorites.service';
const makeUserFavorite = (overrides: Partial<UserFavorite> = {}): UserFavorite =>
@ -46,17 +48,27 @@ describe('FavoritesService', () => {
const workflowRepository = mock<WorkflowRepository>();
const projectRepository = mock<ProjectRepository>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const dataTableRepository = mock<DataTableRepository>();
const folderRepository = mock<FolderRepository>();
const agentRepository = mock<AgentRepository>();
const dataTableResolver = {
globalReadScope: 'dataTable:read',
findMeta: vi.fn<FavoriteResourceResolver['findMeta']>(),
exists: vi.fn<FavoriteResourceResolver['exists']>(),
} satisfies FavoriteResourceResolver;
const agentResolver = {
globalReadScope: 'agent:read',
findMeta: vi.fn<FavoriteResourceResolver['findMeta']>(),
exists: vi.fn<FavoriteResourceResolver['exists']>(),
} satisfies FavoriteResourceResolver;
const resolverRegistry = new FavoriteResourceResolverRegistry();
resolverRegistry.register('dataTable', dataTableResolver);
resolverRegistry.register('agent', agentResolver);
const service = new FavoritesService(
repo,
workflowRepository,
projectRepository,
sharedWorkflowRepository,
dataTableRepository,
folderRepository,
agentRepository,
resolverRegistry,
);
afterEach(() => vi.clearAllMocks());
@ -143,12 +155,13 @@ describe('FavoritesService', () => {
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'Project 1' } as never,
]);
dataTableRepository.find.mockResolvedValue([
{ id: 'dt1', name: 'My Table', projectId: 'proj1' } as never,
]);
dataTableResolver.findMeta.mockResolvedValue(
new Map([['dt1', { name: 'My Table', projectId: 'proj1' }]]),
);
const result = await service.getEnrichedFavorites(makeUser());
expect(dataTableResolver.findMeta).toHaveBeenCalledWith(['dt1']);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
resourceId: 'dt1',
@ -163,15 +176,35 @@ describe('FavoritesService', () => {
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'Project 1' } as never,
]);
dataTableRepository.find.mockResolvedValue([
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
]);
dataTableResolver.findMeta.mockResolvedValue(
new Map([['dt1', { name: 'My Table', projectId: 'proj-other' }]]),
);
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
it('should exclude favorites of a type that has no registered resolver', async () => {
const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'Project 1' } as never,
]);
const serviceWithoutResolvers = new FavoritesService(
repo,
workflowRepository,
projectRepository,
sharedWorkflowRepository,
folderRepository,
new FavoriteResourceResolverRegistry(),
);
const result = await serviceWithoutResolvers.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
it('should enrich folder favorites with name and projectId for accessible folders', async () => {
const favorite = makeUserFavorite({ resourceId: 'folder1', resourceType: 'folder' });
repo.findByUser.mockResolvedValue([favorite]);
@ -240,12 +273,13 @@ describe('FavoritesService', () => {
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'Project 1' } as never,
]);
agentRepository.find.mockResolvedValue([
{ id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never,
]);
agentResolver.findMeta.mockResolvedValue(
new Map([['agent1', { name: 'My Agent', projectId: 'proj1' }]]),
);
const result = await service.getEnrichedFavorites(makeUser());
expect(agentResolver.findMeta).toHaveBeenCalledWith(['agent1']);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
resourceId: 'agent1',
@ -260,9 +294,9 @@ describe('FavoritesService', () => {
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'Project 1' } as never,
]);
agentRepository.find.mockResolvedValue([
{ id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never,
]);
agentResolver.findMeta.mockResolvedValue(
new Map([['agent1', { name: 'My Agent', projectId: 'proj-other' }]]),
);
const result = await service.getEnrichedFavorites(makeUser());
@ -282,15 +316,15 @@ describe('FavoritesService', () => {
]);
workflowRepository.findByIds.mockResolvedValue([{ id: 'wf1', name: 'My Workflow' } as never]);
sharedWorkflowRepository.find.mockResolvedValue([{ workflowId: 'wf1' } as never]);
dataTableRepository.find.mockResolvedValue([
{ id: 'dt1', name: 'My Table', projectId: 'proj1' } as never,
]);
dataTableResolver.findMeta.mockResolvedValue(
new Map([['dt1', { name: 'My Table', projectId: 'proj1' }]]),
);
folderRepository.find.mockResolvedValue([
{ id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never,
]);
agentRepository.find.mockResolvedValue([
{ id: 'agent1', name: 'My Agent', projectId: 'proj1' } as never,
]);
agentResolver.findMeta.mockResolvedValue(
new Map([['agent1', { name: 'My Agent', projectId: 'proj1' }]]),
);
const result = await service.getEnrichedFavorites(makeUser());
@ -334,9 +368,9 @@ describe('FavoritesService', () => {
const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
dataTableRepository.find.mockResolvedValue([
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
]);
dataTableResolver.findMeta.mockResolvedValue(
new Map([['dt1', { name: 'My Table', projectId: 'proj-other' }]]),
);
const result = await service.getEnrichedFavorites(makeAdmin());
@ -374,9 +408,9 @@ describe('FavoritesService', () => {
const favorite = makeUserFavorite({ resourceId: 'agent1', resourceType: 'agent' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
agentRepository.find.mockResolvedValue([
{ id: 'agent1', name: 'My Agent', projectId: 'proj-other' } as never,
]);
agentResolver.findMeta.mockResolvedValue(
new Map([['agent1', { name: 'My Agent', projectId: 'proj-other' }]]),
);
const result = await service.getEnrichedFavorites(makeAdmin());
@ -475,18 +509,14 @@ describe('FavoritesService', () => {
it.each([
{ resourceType: 'workflow' as const, repository: 'workflowRepository' },
{ resourceType: 'project' as const, repository: 'projectRepository' },
{ resourceType: 'dataTable' as const, repository: 'dataTableRepository' },
{ resourceType: 'folder' as const, repository: 'folderRepository' },
{ resourceType: 'agent' as const, repository: 'agentRepository' },
])(
'should check existence using the correct repository for $resourceType',
async ({ resourceType, repository }) => {
const repositories = {
workflowRepository,
projectRepository,
dataTableRepository,
folderRepository,
agentRepository,
};
repo.findOne.mockResolvedValue(null);
repositories[repository as keyof typeof repositories].existsBy.mockResolvedValue(false);
@ -499,6 +529,65 @@ describe('FavoritesService', () => {
);
},
);
it.each([
{ resourceType: 'dataTable' as const, resolver: dataTableResolver },
{ resourceType: 'agent' as const, resolver: agentResolver },
])(
'should check existence using the registered resolver for $resourceType',
async ({ resourceType, resolver }) => {
repo.findOne.mockResolvedValue(null);
resolver.exists.mockResolvedValue(false);
await expect(service.addFavorite('user1', 'res1', resourceType)).rejects.toThrow(
NotFoundError,
);
expect(resolver.exists).toHaveBeenCalledWith('res1');
},
);
it.each([
{ resourceType: 'dataTable' as const, resolver: dataTableResolver },
{ resourceType: 'agent' as const, resolver: agentResolver },
])(
'should create the favorite when the resolver reports the $resourceType exists',
async ({ resourceType, resolver }) => {
const favorite = mock<UserFavorite>();
repo.findOne.mockResolvedValue(null);
resolver.exists.mockResolvedValue(true);
repo.count.mockResolvedValue(0);
repo.create.mockReturnValue(favorite);
repo.save.mockResolvedValue(favorite);
const result = await service.addFavorite('user1', 'res1', resourceType);
expect(resolver.exists).toHaveBeenCalledWith('res1');
expect(repo.create).toHaveBeenCalledWith({
userId: 'user1',
resourceId: 'res1',
resourceType,
});
expect(repo.save).toHaveBeenCalledWith(favorite);
expect(result).toBe(favorite);
},
);
it('should throw NotFoundError when the resource type has no registered resolver', async () => {
const serviceWithoutResolvers = new FavoritesService(
repo,
workflowRepository,
projectRepository,
sharedWorkflowRepository,
folderRepository,
new FavoriteResourceResolverRegistry(),
);
repo.findOne.mockResolvedValue(null);
await expect(serviceWithoutResolvers.addFavorite('user1', 'res1', 'agent')).rejects.toThrow(
NotFoundError,
);
expect(repo.save).not.toHaveBeenCalled();
});
});
describe('removeFavorite', () => {

View File

@ -0,0 +1,35 @@
import type { FavoriteResourceType } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
export type FavoriteResourceMeta = { name: string; projectId: string };
/** Favorite types resolved via a registered resolver; all others are handled by the favorites module itself. */
export type ResolvedFavoriteResourceType = Extract<FavoriteResourceType, 'dataTable' | 'agent'>;
/**
* Resolves favorited resources owned by another backend module. The owning
* module registers its resolver in its `init()`, so favorites never depends
* on module internals; if the module is inactive, no resolver exists and
* favorites of that type are treated as missing resources.
*/
export interface FavoriteResourceResolver {
/** Scope granting access to the resource regardless of project membership. */
readonly globalReadScope: Scope;
/** Per-id metadata; ids missing from the map are treated as deleted or inaccessible. */
findMeta(ids: string[]): Promise<Map<string, FavoriteResourceMeta>>;
exists(id: string): Promise<boolean>;
}
@Service()
export class FavoriteResourceResolverRegistry {
private readonly resolvers = new Map<ResolvedFavoriteResourceType, FavoriteResourceResolver>();
register(type: ResolvedFavoriteResourceType, resolver: FavoriteResourceResolver) {
this.resolvers.set(type, resolver);
}
get(type: ResolvedFavoriteResourceType): FavoriteResourceResolver | undefined {
return this.resolvers.get(type);
}
}

View File

@ -13,13 +13,13 @@ import { In } from '@n8n/typeorm';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
import { UserFavoriteRepository } from './database/repositories/user-favorite.repository';
import { AgentRepository } from '../agents/repositories/agent.repository';
type ResourceMeta = { name: string; projectId: string };
import type {
FavoriteResourceMeta as ResourceMeta,
ResolvedFavoriteResourceType,
} from './favorite-resource-resolver.registry';
import { FavoriteResourceResolverRegistry } from './favorite-resource-resolver.registry';
type Favorite = { resourceId: string; resourceType: FavoriteResourceType };
@ -48,9 +48,8 @@ export class FavoritesService {
private readonly workflowRepository: WorkflowRepository,
private readonly projectRepository: ProjectRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly dataTableRepository: DataTableRepository,
private readonly folderRepository: FolderRepository,
private readonly agentRepository: AgentRepository,
private readonly resourceResolvers: FavoriteResourceResolverRegistry,
) {}
async getEnrichedFavorites(user: User) {
@ -71,9 +70,9 @@ export class FavoritesService {
const [workflowNames, projectNames, dataTableMeta, folderMeta, agentMeta] = await Promise.all([
this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds),
this.enrichProjectFavorites(user, ids.project, accessibleProjects),
this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds),
this.enrichResolvedFavorites(user, 'dataTable', ids.dataTable, accessibleProjectIds),
this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds),
this.enrichAgentFavorites(user, ids.agent, accessibleProjectIds),
this.enrichResolvedFavorites(user, 'agent', ids.agent, accessibleProjectIds),
]);
return favorites.flatMap((fav) => {
@ -148,20 +147,26 @@ export class FavoritesService {
return result;
}
private async enrichDataTableFavorites(
/** Enriches favorites whose resource type is owned by another module, via its registered resolver. */
private async enrichResolvedFavorites(
user: User,
dataTableIds: string[],
resourceType: ResolvedFavoriteResourceType,
resourceIds: string[],
accessibleProjectIds: Set<string>,
): Promise<Map<string, ResourceMeta>> {
const result = new Map<string, ResourceMeta>();
if (dataTableIds.length === 0) return result;
if (resourceIds.length === 0) return result;
const hasGlobalAccess = hasGlobalScope(user, 'dataTable:read');
const dataTables = await this.dataTableRepository.find({ where: { id: In(dataTableIds) } });
// No resolver means the owning module is inactive: treat its favorites as missing resources
const resolver = this.resourceResolvers.get(resourceType);
if (!resolver) return result;
for (const dt of dataTables) {
if (hasGlobalAccess || accessibleProjectIds.has(dt.projectId)) {
result.set(dt.id, { name: dt.name, projectId: dt.projectId });
const hasGlobalAccess = hasGlobalScope(user, resolver.globalReadScope);
const meta = await resolver.findMeta(resourceIds);
for (const [id, resourceMeta] of meta) {
if (hasGlobalAccess || accessibleProjectIds.has(resourceMeta.projectId)) {
result.set(id, resourceMeta);
}
}
return result;
@ -190,27 +195,6 @@ export class FavoritesService {
return result;
}
private async enrichAgentFavorites(
user: User,
agentIds: string[],
accessibleProjectIds: Set<string>,
): Promise<Map<string, ResourceMeta>> {
const result = new Map<string, ResourceMeta>();
if (agentIds.length === 0) return result;
const hasGlobalAccess = hasGlobalScope(user, 'agent:read');
const agents = await this.agentRepository.find({
where: { id: In(agentIds) },
});
for (const agent of agents) {
if (hasGlobalAccess || accessibleProjectIds.has(agent.projectId)) {
result.set(agent.id, { name: agent.name, projectId: agent.projectId });
}
}
return result;
}
async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) {
const existing = await this.userFavoriteRepository.findOne({
where: { userId, resourceId, resourceType },
@ -242,14 +226,12 @@ export class FavoritesService {
case 'project':
exists = await this.projectRepository.existsBy({ id: resourceId });
break;
case 'dataTable':
exists = await this.dataTableRepository.existsBy({ id: resourceId });
break;
case 'folder':
exists = await this.folderRepository.existsBy({ id: resourceId });
break;
case 'dataTable':
case 'agent':
exists = await this.agentRepository.existsBy({ id: resourceId });
exists = (await this.resourceResolvers.get(resourceType)?.exists(resourceId)) ?? false;
break;
}