feat(core): Add the migration owner suggestion service (no-changelog) (#40224)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mfsiega 2026-10-09 08:28:25 +00:00 committed by GitHub
parent 01da88958d
commit 2efdd3e021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 830 additions and 3 deletions

View File

@ -1,10 +1,21 @@
import { Service } from '@n8n/di';
import { And, DataSource, In, LessThan, LessThanOrEqual, MoreThan, Repository } from '@n8n/typeorm';
import {
And,
DataSource,
In,
IsNull,
LessThan,
LessThanOrEqual,
MoreThan,
Not,
Repository,
} from '@n8n/typeorm';
import type { FindOperator, FindOptionsWhere } from '@n8n/typeorm';
import type { IDataObject } from 'n8n-workflow';
import { ActivityEvent } from '../entities';
import type { ActivityResourceType } from '../entities';
import { chunkIds } from '../utils/chunk-ids';
/** Long enough for any name a list needs to show, short enough that a row stays a pointer. */
export const activityResourceNameMaxLength = 128;
@ -78,6 +89,15 @@ function projectScopeWhere(scope: ActivityProjectScope): FindOptionsWhere<Activi
return scope === 'all-projects' ? {} : { projectId: In(scope) };
}
/** How many rows one page of the attributed-activity scan reads. */
const ATTRIBUTED_PAGE_SIZE = 500;
/** A user and when they acted on a resource. */
export interface AttributedActivity {
userId: string;
at: Date;
}
@Service()
export class ActivityEventRepository extends Repository<ActivityEvent> {
constructor(dataSource: DataSource) {
@ -181,6 +201,51 @@ export class ActivityEventRepository extends Repository<ActivityEvent> {
});
}
/**
* The newest entries per resource that name a user, newest first and at most
* `perResource` each, keyed by resource id. Reduced here, not in SQL: `DISTINCT ON`
* is Postgres-only. Pages by id so an uncapped table is never loaded whole; the
* page stops as soon as every resource of the chunk has its share. Resources
* without such an entry are absent from the result.
*/
async findRecentAttributedByResource(
resourceType: ActivityResourceType,
resourceIds: string[],
perResource: number,
): Promise<Map<string, AttributedActivity[]>> {
const recent = new Map<string, AttributedActivity[]>();
if (resourceIds.length === 0 || perResource <= 0) return recent;
for (const chunk of chunkIds([...new Set(resourceIds)])) {
const pending = new Set(chunk);
let beforeId: number | undefined;
while (pending.size > 0) {
const rows = await this.find({
select: ['id', 'resourceId', 'userId', 'createdAt'],
where: {
resourceType,
resourceId: In([...pending]),
userId: Not(IsNull()),
...(beforeId !== undefined ? { id: LessThan(beforeId) } : {}),
},
order: { id: 'DESC' },
take: ATTRIBUTED_PAGE_SIZE,
});
for (const row of rows) {
if (row.resourceId === null || row.userId === null) continue;
const entries = recent.get(row.resourceId) ?? [];
if (entries.length >= perResource) continue;
entries.push({ userId: row.userId, at: row.createdAt });
recent.set(row.resourceId, entries);
if (entries.length >= perResource) pending.delete(row.resourceId);
}
if (rows.length < ATTRIBUTED_PAGE_SIZE) break;
beforeId = rows[rows.length - 1].id;
}
}
return recent;
}
/** Retention by age. Returns how many entries went, so a caller can log a sweep worth noticing. */
async deleteOlderThan(cutoff: Date, signal?: AbortSignal): Promise<number> {
return await this.deleteInBatches({ createdAt: LessThan(cutoff) }, undefined, signal);

View File

@ -1,5 +1,9 @@
import { Service } from '@n8n/di';
import { PROJECT_OWNER_ROLE_SLUG, type ProjectRole } from '@n8n/permissions';
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
type ProjectRole,
} from '@n8n/permissions';
import { DataSource, In, Repository } from '@n8n/typeorm';
import { ProjectRelation, Role } from '../entities';
@ -40,6 +44,26 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
});
}
/** Admin user ids per project, sorted, keyed by project id. Projects without an admin are absent. */
async findAdminUserIdsByProjectIds(projectIds: string[]): Promise<Map<string, string[]>> {
const admins = new Map<string, string[]>();
if (projectIds.length === 0) return admins;
for (const chunk of chunkIds([...new Set(projectIds)])) {
const rows = await this.find({
select: ['projectId', 'userId'],
where: { projectId: In(chunk), role: { slug: PROJECT_ADMIN_ROLE_SLUG } },
order: { projectId: 'ASC', userId: 'ASC' },
});
for (const row of rows) {
const ids = admins.get(row.projectId) ?? [];
ids.push(row.userId);
admins.set(row.projectId, ids);
}
}
return admins;
}
async getPersonalProjectsForUsers(userIds: string[]) {
const projectRelations = await this.find({
where: {

View File

@ -48,6 +48,15 @@ export class UserRepository extends BaseRepository<User> {
});
}
/**
* Every user with the relations `isPending` needs: the role and the auth
* identities. Without the identities, a user who signs in through SSO or LDAP
* and so has no password would read as pending.
*/
async findAllWithRoleAndAuthIdentities(): Promise<User[]> {
return await this.find({ relations: ['role', 'authIdentities'], order: { id: 'ASC' } });
}
async findByIdWithRole(id: string, ctx: OperationContext = {}): Promise<User | null> {
return await this.managerFor(ctx).findOne(User, {
where: { id },

View File

@ -9,6 +9,7 @@ import {
WorkflowPublishHistory,
} from '../entities';
import { BaseRepository } from './base-repository';
import { chunkIds } from '../utils/chunk-ids';
import { WorkflowReviewRequestWorkflow } from '../entities/workflow-review-request-workflow.ee';
import { WorkflowReviewRequest } from '../entities/workflow-review-request.ee';
import type { OperationContext } from '../services/transaction';
@ -30,6 +31,34 @@ export class WorkflowHistoryRepository extends BaseRepository<WorkflowHistory> {
await this.managerFor(ctx).insert(WorkflowHistory, version);
}
/**
* The authors of the newest versions per workflow, newest first and at most
* `perWorkflow` each, keyed by workflow id. `authors` is the display name
* recorded at save time, not a user id. Workflows without a version are absent.
*/
async findRecentAuthorsByWorkflowIds(
workflowIds: string[],
perWorkflow: number,
): Promise<Map<string, Array<{ authors: string; at: Date }>>> {
const recent = new Map<string, Array<{ authors: string; at: Date }>>();
if (workflowIds.length === 0 || perWorkflow <= 0) return recent;
for (const chunk of chunkIds([...new Set(workflowIds)])) {
const rows = await this.find({
select: ['versionId', 'workflowId', 'authors', 'createdAt'],
where: { workflowId: In(chunk) },
order: { createdAt: 'DESC' },
});
for (const row of rows) {
const entries = recent.get(row.workflowId) ?? [];
if (entries.length >= perWorkflow) continue;
entries.push({ authors: row.authors, at: row.createdAt });
recent.set(row.workflowId, entries);
}
}
return recent;
}
async deleteEarlierThan(date: Date) {
return await this.delete({ createdAt: LessThan(date) });
}

View File

@ -1,5 +1,5 @@
import { Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { DataSource, In, IsNull, Not } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
import { BaseRepository } from './base-repository';
@ -62,6 +62,35 @@ export class WorkflowPublishHistoryRepository extends BaseRepository<WorkflowPub
return await repository.find({ where: { workflowId, versionId }, order: { id: 'ASC' } });
}
/**
* The newest publish and unpublish events per workflow that still name their user,
* newest first and at most `perWorkflow` each, keyed by workflow id. Workflows
* without one are absent from the result.
*/
async findRecentAttributedByWorkflowIds(
workflowIds: string[],
perWorkflow: number,
): Promise<Map<string, Array<{ userId: string; at: Date }>>> {
const recent = new Map<string, Array<{ userId: string; at: Date }>>();
if (workflowIds.length === 0 || perWorkflow <= 0) return recent;
for (const chunk of chunkIds([...new Set(workflowIds)])) {
const rows = await this.find({
select: ['id', 'workflowId', 'userId', 'createdAt'],
where: { workflowId: In(chunk), userId: Not(IsNull()) },
order: { createdAt: 'DESC', id: 'DESC' },
});
for (const row of rows) {
if (row.userId === null) continue;
const entries = recent.get(row.workflowId) ?? [];
if (entries.length >= perWorkflow) continue;
entries.push({ userId: row.userId, at: row.createdAt });
recent.set(row.workflowId, entries);
}
}
return recent;
}
async findLatestActivations(workflowId: string, versionIds: string[]) {
if (versionIds.length === 0) return [];

View File

@ -24,6 +24,8 @@ breaking-changes/
database/
entities/ # migration_finding, migration_finding_sync, migration_workflow_owner tables
repositories/ # Use-case-named DB access (BaseRepository + OperationContext)
owners/
migration-owner-suggestion.service.ts # Proposes who should fix a workflow's findings
sync/
migration-finding-diff.ts # Pure diff of scan hits against stored findings
migration-finding-sync.service.ts # Runs a scan and writes the diff, one transaction per batch

View File

@ -0,0 +1,285 @@
import type {
ActivityEventRepository,
Project,
ProjectRelation,
ProjectRelationRepository,
SharedWorkflowRepository,
User,
UserRepository,
WorkflowHistoryRepository,
WorkflowPublishHistoryRepository,
} from '@n8n/db';
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import {
MigrationOwnerSuggestionService,
RECENT_ACTIONS_PER_WORKFLOW,
} from '../migration-owner-suggestion.service';
const at = (iso: string) => new Date(iso);
function user(id: string, firstName: string, lastName: string, flags: Partial<User> = {}): User {
return { id, firstName, lastName, disabled: false, isPending: false, ...flags } as User;
}
describe('MigrationOwnerSuggestionService', () => {
let activityEventRepository: MockProxy<ActivityEventRepository>;
let workflowPublishHistoryRepository: MockProxy<WorkflowPublishHistoryRepository>;
let workflowHistoryRepository: MockProxy<WorkflowHistoryRepository>;
let sharedWorkflowRepository: MockProxy<SharedWorkflowRepository>;
let projectRelationRepository: MockProxy<ProjectRelationRepository>;
let userRepository: MockProxy<UserRepository>;
let service: MigrationOwnerSuggestionService;
const alice = user('alice', 'Alice', 'Adams');
const bob = user('bob', 'Bob', 'Brown');
const carol = user('carol', 'Carol', 'Clark');
function givenUsers(...users: User[]) {
userRepository.findAllWithRoleAndAuthIdentities.mockResolvedValue(users);
}
/** Each workflow's recent entries, newest first, as the repositories return them. */
type Recent<T> = Record<string, T | T[]>;
const asLists = <T>(entries: Recent<T>) =>
new Map(
Object.entries(entries).map(([id, value]) => [id, Array.isArray(value) ? value : [value]]),
);
function givenActivity(entries: Recent<{ userId: string; at: Date }>) {
activityEventRepository.findRecentAttributedByResource.mockResolvedValue(asLists(entries));
}
function givenPublishes(entries: Recent<{ userId: string; at: Date }>) {
workflowPublishHistoryRepository.findRecentAttributedByWorkflowIds.mockResolvedValue(
asLists(entries),
);
}
function givenVersions(entries: Recent<{ authors: string; at: Date }>) {
workflowHistoryRepository.findRecentAuthorsByWorkflowIds.mockResolvedValue(asLists(entries));
}
function givenProjects(
byWorkflow: Record<string, { id: string; type: 'personal' | 'team' }>,
personalOwners: Record<string, string> = {},
admins: Record<string, string[]> = {},
) {
sharedWorkflowRepository.findOwnerProjectsByWorkflowIds.mockResolvedValue(
new Map(Object.entries(byWorkflow).map(([id, project]) => [id, project as Project])),
);
projectRelationRepository.getPersonalProjectOwners.mockResolvedValue(
Object.entries(personalOwners).map(
([projectId, userId]) => ({ projectId, userId }) as ProjectRelation,
),
);
projectRelationRepository.findAdminUserIdsByProjectIds.mockResolvedValue(
new Map(Object.entries(admins)),
);
}
beforeEach(() => {
activityEventRepository = mock<ActivityEventRepository>();
workflowPublishHistoryRepository = mock<WorkflowPublishHistoryRepository>();
workflowHistoryRepository = mock<WorkflowHistoryRepository>();
sharedWorkflowRepository = mock<SharedWorkflowRepository>();
projectRelationRepository = mock<ProjectRelationRepository>();
userRepository = mock<UserRepository>();
givenUsers(alice, bob, carol);
givenActivity({});
givenPublishes({});
givenVersions({});
givenProjects({});
service = new MigrationOwnerSuggestionService(
activityEventRepository,
workflowPublishHistoryRepository,
workflowHistoryRepository,
sharedWorkflowRepository,
projectRelationRepository,
userRepository,
);
});
it('returns nothing for no workflows without touching the database', async () => {
expect(await service.suggestOwners([])).toEqual([]);
expect(userRepository.findAllWithRoleAndAuthIdentities).not.toHaveBeenCalled();
});
it('asks each source for the same bounded number of recent actions per workflow', async () => {
await service.suggestOwners(['wf-1']);
expect(activityEventRepository.findRecentAttributedByResource).toHaveBeenCalledWith(
'workflow',
['wf-1'],
RECENT_ACTIONS_PER_WORKFLOW,
);
expect(workflowPublishHistoryRepository.findRecentAttributedByWorkflowIds).toHaveBeenCalledWith(
['wf-1'],
RECENT_ACTIONS_PER_WORKFLOW,
);
expect(workflowHistoryRepository.findRecentAuthorsByWorkflowIds).toHaveBeenCalledWith(
['wf-1'],
RECENT_ACTIONS_PER_WORKFLOW,
);
expect(RECENT_ACTIONS_PER_WORKFLOW).toBe(10);
});
describe('most recent attributable activity', () => {
it('picks the user of the newest action across the activity feed and publish history', async () => {
givenActivity({ 'wf-1': { userId: 'alice', at: at('2026-09-01T10:00:00Z') } });
givenPublishes({ 'wf-1': { userId: 'bob', at: at('2026-09-02T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'bob' },
]);
});
it('prefers a newer activity entry over an older publish', async () => {
givenActivity({ 'wf-1': { userId: 'alice', at: at('2026-09-03T10:00:00Z') } });
givenPublishes({ 'wf-1': { userId: 'bob', at: at('2026-09-02T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
]);
});
it('resolves the author name of the newest version to the one user with that name', async () => {
givenVersions({ 'wf-1': { authors: 'Carol Clark', at: at('2026-09-05T10:00:00Z') } });
givenPublishes({ 'wf-1': { userId: 'bob', at: at('2026-09-02T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'carol' },
]);
});
it('strips the MCP marker from an author name before matching', async () => {
givenVersions({
'wf-1': { authors: 'Carol Clark (via MCP)', at: at('2026-09-05T10:00:00Z') },
});
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'carol' },
]);
});
it('ignores an author name that matches two users', async () => {
givenUsers(alice, bob, user('bob-2', 'Bob', 'Brown'));
givenVersions({ 'wf-1': { authors: 'Bob Brown', at: at('2026-09-05T10:00:00Z') } });
givenPublishes({ 'wf-1': { userId: 'alice', at: at('2026-09-01T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
]);
});
it('ignores an author name that matches no user', async () => {
givenVersions({ 'wf-1': { authors: 'eval-snapshot', at: at('2026-09-05T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([]);
});
it('skips a disabled or pending user and falls through to the next signal', async () => {
givenUsers(
user('dan', 'Dan', 'Dole', { disabled: true }),
user('eve', 'Eve', 'Evans', { isPending: true }),
bob,
);
givenActivity({ 'wf-1': { userId: 'dan', at: at('2026-09-09T10:00:00Z') } });
givenPublishes({ 'wf-1': { userId: 'eve', at: at('2026-09-08T10:00:00Z') } });
givenVersions({ 'wf-1': { authors: 'Bob Brown', at: at('2026-09-01T10:00:00Z') } });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'bob' },
]);
});
it('falls back to an older attributable action when the newest one is by a user who cannot own', async () => {
givenUsers(
user('dan', 'Dan', 'Dole', { disabled: true }),
alice,
bob,
user('bob-2', 'Bob', 'Brown'),
);
givenActivity({
'wf-1': [
{ userId: 'dan', at: at('2026-09-09T10:00:00Z') },
{ userId: 'alice', at: at('2026-09-07T10:00:00Z') },
],
});
givenVersions({
'wf-1': [
{ authors: 'Bob Brown', at: at('2026-09-08T10:00:00Z') },
{ authors: 'Bob Brown', at: at('2026-09-01T10:00:00Z') },
],
});
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
]);
});
it('skips a user that no longer exists', async () => {
givenActivity({ 'wf-1': { userId: 'gone', at: at('2026-09-09T10:00:00Z') } });
givenProjects({ 'wf-1': { id: 'p-1', type: 'personal' } }, { 'p-1': 'alice' });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
]);
});
});
describe('project owner fallback', () => {
it('uses the personal project owner when no activity is attributable', async () => {
givenProjects({ 'wf-1': { id: 'p-1', type: 'personal' } }, { 'p-1': 'alice' });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
]);
});
it('uses one admin of a team project, the same one each time', async () => {
givenProjects({ 'wf-1': { id: 'p-team', type: 'team' } }, {}, { 'p-team': ['bob', 'carol'] });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'bob' },
]);
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'bob' },
]);
});
it('skips an admin who cannot own and takes the next one', async () => {
givenUsers(user('bob', 'Bob', 'Brown', { disabled: true }), carol);
givenProjects({ 'wf-1': { id: 'p-team', type: 'team' } }, {}, { 'p-team': ['bob', 'carol'] });
expect(await service.suggestOwners(['wf-1'])).toEqual([
{ workflowId: 'wf-1', userId: 'carol' },
]);
});
it('suggests nobody for a team project without an eligible admin', async () => {
givenProjects({ 'wf-1': { id: 'p-team', type: 'team' } }, {}, {});
expect(await service.suggestOwners(['wf-1'])).toEqual([]);
});
it('only looks up projects for the workflows that activity did not resolve', async () => {
givenActivity({ 'wf-1': { userId: 'alice', at: at('2026-09-01T10:00:00Z') } });
givenProjects({ 'wf-2': { id: 'p-2', type: 'personal' } }, { 'p-2': 'bob' });
const suggestions = await service.suggestOwners(['wf-1', 'wf-2']);
expect(sharedWorkflowRepository.findOwnerProjectsByWorkflowIds).toHaveBeenCalledWith([
'wf-2',
]);
expect(suggestions).toEqual([
{ workflowId: 'wf-1', userId: 'alice' },
{ workflowId: 'wf-2', userId: 'bob' },
]);
});
});
});

View File

@ -0,0 +1,167 @@
import {
ActivityEventRepository,
ProjectRelationRepository,
SharedWorkflowRepository,
UserRepository,
WorkflowHistoryRepository,
WorkflowPublishHistoryRepository,
type User,
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { OwnerSuggestion } from '../database/repositories/migration-workflow-owner.repository';
/** A user and when they acted on the workflow. */
interface Candidate {
userId: string;
at: Date;
}
/** Workflow history stores the author's display name, with this suffix for saves through MCP. */
const MCP_AUTHOR_SUFFIX = ' (via MCP)';
/**
* How many recent actions per source and workflow are considered. The newest
* action may belong to a user who cannot own, so a few older ones are kept to
* fall back on before the project owner is used.
*/
export const RECENT_ACTIONS_PER_WORKFLOW = 10;
/**
* Proposes who should fix a workflow's migration findings: the user behind the
* most recent activity on the workflow that can be attributed to a single user,
* else one of the owners of the workflow's project. A workflow with neither gets
* no suggestion.
*
* Three records carry attributable activity, each with a gap the others cover:
* the activity feed (user ids, but capped and recent), publish history (user
* ids, but only for published workflows) and workflow history (every save, but
* a display name instead of an id). A disabled, pending or deleted user is
* never suggested.
*/
@Service()
export class MigrationOwnerSuggestionService {
constructor(
private readonly activityEventRepository: ActivityEventRepository,
private readonly workflowPublishHistoryRepository: WorkflowPublishHistoryRepository,
private readonly workflowHistoryRepository: WorkflowHistoryRepository,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly userRepository: UserRepository,
) {}
async suggestOwners(workflowIds: string[]): Promise<OwnerSuggestion[]> {
if (workflowIds.length === 0) return [];
const users = await this.loadEligibleUsers();
const latestByWorkflow = await this.findLatestAttributedActivity(workflowIds, users);
const unresolved = workflowIds.filter((workflowId) => !latestByWorkflow.has(workflowId));
const fallbackByWorkflow = await this.findProjectOwners(unresolved, users);
const suggestions: OwnerSuggestion[] = [];
for (const workflowId of workflowIds) {
const userId = latestByWorkflow.get(workflowId) ?? fallbackByWorkflow.get(workflowId);
if (userId) suggestions.push({ workflowId, userId });
}
return suggestions;
}
/** Every user who can be an owner, by id and by the display name workflow history records. */
private async loadEligibleUsers(): Promise<EligibleUsers> {
const all = await this.userRepository.findAllWithRoleAndAuthIdentities();
const byId = new Map<string, User>();
const idsByName = new Map<string, string[]>();
for (const user of all) {
if (user.disabled || user.isPending) continue;
byId.set(user.id, user);
const name = `${user.firstName} ${user.lastName}`;
idsByName.set(name, [...(idsByName.get(name) ?? []), user.id]);
}
return { byId, idsByName };
}
/**
* Per workflow, the user behind the most recent action that one eligible user
* can be held to. An action by a user who cannot own, or a version whose author
* name is ambiguous, is skipped in favour of the next older one.
*/
private async findLatestAttributedActivity(
workflowIds: string[],
users: EligibleUsers,
): Promise<Map<string, string>> {
const limit = RECENT_ACTIONS_PER_WORKFLOW;
const [activity, publishes, versions] = await Promise.all([
this.activityEventRepository.findRecentAttributedByResource('workflow', workflowIds, limit),
this.workflowPublishHistoryRepository.findRecentAttributedByWorkflowIds(workflowIds, limit),
this.workflowHistoryRepository.findRecentAuthorsByWorkflowIds(workflowIds, limit),
]);
const latest = new Map<string, string>();
for (const workflowId of workflowIds) {
const candidates: Candidate[] = [
...(activity.get(workflowId) ?? []),
...(publishes.get(workflowId) ?? []),
...(versions.get(workflowId) ?? []).flatMap((version) => {
const userId = resolveAuthor(version.authors, users);
return userId ? [{ userId, at: version.at }] : [];
}),
];
const newest = candidates
.filter((candidate) => users.byId.has(candidate.userId))
.sort((a, b) => b.at.getTime() - a.at.getTime())[0];
if (newest) latest.set(workflowId, newest.userId);
}
return latest;
}
/**
* One owner of each workflow's project: the owner of a personal project, or
* any admin of a team project. The pick among admins is stable, not meaningful.
*/
private async findProjectOwners(
workflowIds: string[],
users: EligibleUsers,
): Promise<Map<string, string>> {
const owners = new Map<string, string>();
if (workflowIds.length === 0) return owners;
const projectByWorkflow =
await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(workflowIds);
const projectIds = [...new Set([...projectByWorkflow.values()].map((project) => project.id))];
const [personalOwners, adminsByProject] = await Promise.all([
this.projectRelationRepository.getPersonalProjectOwners(projectIds),
this.projectRelationRepository.findAdminUserIdsByProjectIds(projectIds),
]);
const personalOwnerByProject = new Map(
personalOwners.map((relation) => [relation.projectId, relation.userId]),
);
for (const [workflowId, project] of projectByWorkflow) {
const candidates =
project.type === 'personal'
? [personalOwnerByProject.get(project.id)]
: (adminsByProject.get(project.id) ?? []);
const userId = candidates.find((id): id is string => !!id && users.byId.has(id));
if (userId) owners.set(workflowId, userId);
}
return owners;
}
}
interface EligibleUsers {
byId: Map<string, User>;
idsByName: Map<string, string[]>;
}
/**
* A version names its author, not a user. The name counts only when exactly one
* eligible user carries it.
*/
function resolveAuthor(authors: string, users: EligibleUsers): string | undefined {
const name = authors.endsWith(MCP_AUTHOR_SUFFIX)
? authors.slice(0, -MCP_AUTHOR_SUFFIX.length)
: authors;
const ids = users.idsByName.get(name);
return ids?.length === 1 ? ids[0] : undefined;
}

View File

@ -3,6 +3,8 @@ import type { Project } from '@n8n/db';
import { ActivityEventRepository, activityDataMaxLength } from '@n8n/db';
import { Container } from '@n8n/di';
import { createMember } from './shared/db/users';
describe('ActivityEventRepository', () => {
let repository: ActivityEventRepository;
let project: Project;
@ -305,4 +307,64 @@ describe('ActivityEventRepository', () => {
expect(remaining.map((entry) => entry.action)).toEqual(['third', 'second']);
});
});
describe('findRecentAttributedByResource', () => {
// Real users: the column is a foreign key to the user table.
let alice: string;
let bob: string;
let carol: string;
beforeAll(async () => {
[alice, bob, carol] = (
await Promise.all([createMember(), createMember(), createMember()])
).map((user) => user.id);
});
const entry = async (resourceId: string, userId: string | null) =>
await repository.record({
category: 'workflow',
action: 'saved',
projectId: project.id,
resourceType: 'workflow',
resourceId,
userId,
});
it('returns the newest attributed entries per resource first, newest entry first', async () => {
await entry('wf-1', alice);
await entry('wf-1', bob);
await entry('wf-1', null);
await entry('wf-2', carol);
await entry('wf-3', alice);
const recent = await repository.findRecentAttributedByResource(
'workflow',
['wf-1', 'wf-2'],
10,
);
expect([...recent.keys()].sort()).toEqual(['wf-1', 'wf-2']);
expect(recent.get('wf-1')?.map((e) => e.userId)).toEqual([bob, alice]);
expect(recent.get('wf-2')?.map((e) => e.userId)).toEqual([carol]);
});
it('keeps at most the requested number of entries per resource', async () => {
await entry('wf-1', alice);
await entry('wf-1', bob);
await entry('wf-1', carol);
const recent = await repository.findRecentAttributedByResource('workflow', ['wf-1'], 2);
expect(recent.get('wf-1')?.map((e) => e.userId)).toEqual([carol, bob]);
});
it('omits a resource with no attributed entry and returns nothing for no ids', async () => {
await entry('wf-1', null);
expect(await repository.findRecentAttributedByResource('workflow', ['wf-1'], 10)).toEqual(
new Map(),
);
expect(await repository.findRecentAttributedByResource('workflow', [], 10)).toEqual(
new Map(),
);
});
});
});

View File

@ -0,0 +1,73 @@
import { createWorkflow, createWorkflowHistory, testDb } from '@n8n/backend-test-utils';
import { WorkflowHistoryRepository } from '@n8n/db';
import type { WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { randomUUID } from 'node:crypto';
describe('WorkflowHistoryRepository', () => {
let repository: WorkflowHistoryRepository;
beforeAll(async () => {
await testDb.init();
repository = Container.get(WorkflowHistoryRepository);
});
beforeEach(async () => {
await testDb.truncate(['WorkflowHistory', 'WorkflowEntity']);
});
afterAll(async () => await testDb.terminate());
async function version(workflow: WorkflowEntity, authors: string, createdAt: string) {
await createWorkflowHistory(workflow, undefined, undefined, {
versionId: randomUUID(),
authors,
createdAt: new Date(createdAt),
});
}
describe('findRecentAuthorsByWorkflowIds', () => {
test('returns the newest versions per requested workflow first', async () => {
const [first, second, other] = await Promise.all([
createWorkflow(),
createWorkflow(),
createWorkflow(),
]);
await version(first, 'Alice Adams', '2026-01-01T00:00:00.000Z');
await version(first, 'Bob Brown', '2026-01-03T00:00:00.000Z');
await version(second, 'Carol Clark', '2026-01-02T00:00:00.000Z');
await version(other, 'Dan Dole', '2026-01-04T00:00:00.000Z');
const recent = await repository.findRecentAuthorsByWorkflowIds([first.id, second.id], 10);
expect([...recent.keys()].sort()).toEqual([first.id, second.id].sort());
expect(recent.get(first.id)?.map((entry) => entry.authors)).toEqual([
'Bob Brown',
'Alice Adams',
]);
expect(recent.get(first.id)?.[0].at.getTime()).toBe(Date.parse('2026-01-03T00:00:00.000Z'));
expect(recent.get(second.id)?.map((entry) => entry.authors)).toEqual(['Carol Clark']);
});
test('keeps at most the requested number of versions per workflow', async () => {
const workflow = await createWorkflow();
await version(workflow, 'Alice Adams', '2026-01-01T00:00:00.000Z');
await version(workflow, 'Bob Brown', '2026-01-02T00:00:00.000Z');
await version(workflow, 'Carol Clark', '2026-01-03T00:00:00.000Z');
const recent = await repository.findRecentAuthorsByWorkflowIds([workflow.id], 2);
expect(recent.get(workflow.id)?.map((entry) => entry.authors)).toEqual([
'Carol Clark',
'Bob Brown',
]);
});
test('omits a workflow without versions and returns nothing for no ids', async () => {
const workflow = await createWorkflow();
expect(await repository.findRecentAuthorsByWorkflowIds([workflow.id], 10)).toEqual(new Map());
expect(await repository.findRecentAuthorsByWorkflowIds([], 10)).toEqual(new Map());
});
});
});

View File

@ -0,0 +1,82 @@
import { createWorkflow, testDb } from '@n8n/backend-test-utils';
import { WorkflowPublishHistoryRepository } from '@n8n/db';
import type { User, WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { createMember } from './shared/db/users';
describe('WorkflowPublishHistoryRepository', () => {
let repository: WorkflowPublishHistoryRepository;
let alice: User;
let bob: User;
beforeAll(async () => {
await testDb.init();
repository = Container.get(WorkflowPublishHistoryRepository);
});
beforeEach(async () => {
await testDb.truncate(['WorkflowPublishHistory', 'WorkflowEntity', 'User']);
[alice, bob] = await Promise.all([createMember(), createMember()]);
});
afterAll(async () => await testDb.terminate());
async function publishEvent(
workflow: WorkflowEntity,
userId: string | null,
createdAt: string,
event: 'activated' | 'deactivated' = 'activated',
) {
await repository.insert({
workflowId: workflow.id,
versionId: null,
event,
userId,
createdAt: new Date(createdAt),
});
}
describe('findRecentAttributedByWorkflowIds', () => {
test('returns the newest events per workflow first, without those whose user is gone', async () => {
const [first, second, other] = await Promise.all([
createWorkflow(),
createWorkflow(),
createWorkflow(),
]);
await publishEvent(first, alice.id, '2026-01-01T00:00:00.000Z');
await publishEvent(first, bob.id, '2026-01-03T00:00:00.000Z', 'deactivated');
await publishEvent(first, null, '2026-01-05T00:00:00.000Z');
await publishEvent(second, bob.id, '2026-01-02T00:00:00.000Z');
await publishEvent(other, alice.id, '2026-01-04T00:00:00.000Z');
const recent = await repository.findRecentAttributedByWorkflowIds([first.id, second.id], 10);
expect([...recent.keys()].sort()).toEqual([first.id, second.id].sort());
expect(recent.get(first.id)?.map((entry) => entry.userId)).toEqual([bob.id, alice.id]);
expect(recent.get(first.id)?.[0].at.getTime()).toBe(Date.parse('2026-01-03T00:00:00.000Z'));
expect(recent.get(second.id)?.map((entry) => entry.userId)).toEqual([bob.id]);
});
test('keeps at most the requested number of events per workflow', async () => {
const workflow = await createWorkflow();
await publishEvent(workflow, alice.id, '2026-01-01T00:00:00.000Z');
await publishEvent(workflow, bob.id, '2026-01-02T00:00:00.000Z');
await publishEvent(workflow, alice.id, '2026-01-03T00:00:00.000Z');
const recent = await repository.findRecentAttributedByWorkflowIds([workflow.id], 2);
expect(recent.get(workflow.id)?.map((entry) => entry.userId)).toEqual([alice.id, bob.id]);
});
test('omits a workflow with no attributable event and returns nothing for no ids', async () => {
const workflow = await createWorkflow();
await publishEvent(workflow, null, '2026-01-01T00:00:00.000Z');
expect(await repository.findRecentAttributedByWorkflowIds([workflow.id], 10)).toEqual(
new Map(),
);
expect(await repository.findRecentAttributedByWorkflowIds([], 10)).toEqual(new Map());
});
});
});