fix: Load only the publish history that the editor shows (#40183)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Nour Alhadi Mahmoud
2026-10-08 07:14:26 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7397073d86
commit b28adf9789
34 changed files with 981 additions and 140 deletions
+4
View File
@@ -527,6 +527,10 @@ export {
workflowHistoryListItemSchema,
} from './workflow-history/workflow-history-list-item.dto';
export { ListWorkflowHistoryQueryDto } from './workflow-history/list-workflow-history-query.dto';
export {
WorkflowPublishTimelineQueryDto,
WORKFLOW_HISTORY_DEFAULT_TAKE,
} from './workflow-history/workflow-publish-timeline-query.dto';
export {
WorkflowVersionHistoryListPublicDto,
workflowVersionListItemPublicSchema,
@@ -0,0 +1,11 @@
import {
PaginationDto,
MAX_ITEMS_PER_PAGE,
createTakeValidator,
} from '../pagination/pagination.dto';
export const WORKFLOW_HISTORY_DEFAULT_TAKE = 20;
export class WorkflowPublishTimelineQueryDto extends PaginationDto.extend({
take: createTakeValidator(MAX_ITEMS_PER_PAGE, false, WORKFLOW_HISTORY_DEFAULT_TAKE),
}) {}
@@ -300,11 +300,44 @@ describe('SharedWorkflowRepository', () => {
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
'workflow-1',
'version-1',
'all',
entityManager,
);
expect(result?.workflow.activeVersion?.workflowPublishHistory).toBe(events);
});
it('passes the requested publish history scope to the query', async () => {
entityManager.findOne.mockResolvedValueOnce(
sharedWorkflowWith(mock<WorkflowHistory>({ versionId: 'version-1' })),
);
workflowPublishHistoryRepository.findByVersion.mockResolvedValueOnce([]);
await sharedWorkflowRepository.findWorkflowWithOptions('workflow-1', {
includeActiveVersion: true,
publishHistory: 'latestActivation',
});
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
'workflow-1',
'version-1',
'latestActivation',
entityManager,
);
});
it('does not load publish history when the scope is none', async () => {
entityManager.findOne.mockResolvedValueOnce(
sharedWorkflowWith(mock<WorkflowHistory>({ versionId: 'version-1' })),
);
await sharedWorkflowRepository.findWorkflowWithOptions('workflow-1', {
includeActiveVersion: true,
publishHistory: 'none',
});
expect(workflowPublishHistoryRepository.findByVersion).not.toHaveBeenCalled();
});
it('loads the publish history with the entity manager of the caller', async () => {
const trx = mock<EntityManager>();
trx.findOne.mockResolvedValueOnce(
@@ -320,6 +353,7 @@ describe('SharedWorkflowRepository', () => {
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
'workflow-1',
'version-1',
'all',
trx,
);
});
+4 -1
View File
@@ -115,7 +115,10 @@ export {
WorkflowPublishedVersionRepository,
type PublishedWorkflowDataForExecution,
} from './workflow-published-version.repository';
export { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
export {
WorkflowPublishHistoryRepository,
type PublishHistoryScope,
} from './workflow-publish-history.repository';
export {
WorkflowReviewRequestRepository,
type WorkflowReviewRequestForWorkflowRow,
@@ -14,7 +14,10 @@ import type {
} from '@n8n/typeorm';
import { BaseRepository } from './base-repository';
import { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
import {
type PublishHistoryScope,
WorkflowPublishHistoryRepository,
} from './workflow-publish-history.repository';
import type { User } from '../entities';
import { Project, ProjectRelation, SharedWorkflow } from '../entities';
import { type OperationContext, TransactionRunner } from '../services/transaction';
@@ -349,6 +352,7 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
includeTags?: boolean;
includeParentFolder?: boolean;
includeActiveVersion?: boolean;
publishHistory?: PublishHistoryScope;
} & ({ em?: EntityManager; ctx?: never } | { ctx?: OperationContext; em?: never }) = {},
) {
const {
@@ -356,6 +360,7 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
includeTags = false,
includeParentFolder = false,
includeActiveVersion = false,
publishHistory = 'all',
ctx = {},
em = this.managerFor(ctx),
} = options;
@@ -376,11 +381,12 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
});
const activeVersion = sharedWorkflow?.workflow.activeVersion;
if (activeVersion) {
if (activeVersion && publishHistory !== 'none') {
activeVersion.workflowPublishHistory =
await this.workflowPublishHistoryRepository.findByVersion(
workflowId,
activeVersion.versionId,
publishHistory,
em,
);
}
@@ -5,6 +5,9 @@ import type { EntityManager } from '@n8n/typeorm';
import { BaseRepository } from './base-repository';
import { WorkflowPublishHistory } from '../entities';
import { type OperationContext, TransactionRunner } from '../services/transaction';
import { chunkIds } from '../utils/chunk-ids';
export type PublishHistoryScope = 'all' | 'latestActivation' | 'none';
@Service()
export class WorkflowPublishHistoryRepository extends BaseRepository<WorkflowPublishHistory> {
@@ -39,15 +42,64 @@ export class WorkflowPublishHistoryRepository extends BaseRepository<WorkflowPub
}
/**
* Returns the events of one version, oldest first. Use this method, not a
* join on the `workflowPublishHistory` relation. A join repeats the nodes
* JSON of the version for each event, and a version can have many events.
* Use this method, not a join on the `workflowPublishHistory` relation.
* A join repeats the nodes JSON of the version for each event.
*/
async findByVersion(workflowId: string, versionId: string, trx?: EntityManager) {
async findByVersion(
workflowId: string,
versionId: string,
scope: Exclude<PublishHistoryScope, 'none'> = 'all',
trx?: EntityManager,
) {
const repository = trx ? trx.getRepository(WorkflowPublishHistory) : this;
if (scope === 'latestActivation') {
return await repository.find({
where: { workflowId, versionId, event: 'activated' },
order: { id: 'DESC' },
take: 1,
});
}
return await repository.find({ where: { workflowId, versionId }, order: { id: 'ASC' } });
}
async findLatestActivations(workflowId: string, versionIds: string[]) {
if (versionIds.length === 0) return [];
const activations: WorkflowPublishHistory[] = [];
for (const batch of chunkIds([...new Set(versionIds)])) {
const latestIds = this.createQueryBuilder('latest')
.select('MAX(latest.id)')
.where('latest.workflowId = :workflowId', { workflowId })
.andWhere('latest.versionId IN (:...versionIds)', { versionIds: batch })
.andWhere('latest.event = :event', { event: 'activated' })
.groupBy('latest.versionId');
const batchActivations = await this.createQueryBuilder('wph')
.where(`wph.id IN (${latestIds.getQuery()})`)
.setParameters(latestIds.getParameters())
.getMany();
activations.push(...batchActivations);
}
return activations;
}
async findTimelinePage(workflowId: string, { offset, limit }: { offset: number; limit: number }) {
// TypeORM omits the SQL limit when it is zero.
if (limit <= 0) return [];
// The joins are many-to-one, so `offset` and `limit` page the events.
return await this.createQueryBuilder('wph')
.leftJoinAndSelect('wph.user', 'user')
.leftJoin('wph.workflowHistory', 'wh')
.addSelect('wh.name')
.where('wph.workflowId = :workflowId', { workflowId })
.orderBy('wph.createdAt', 'DESC')
.addOrderBy('wph.id', 'DESC')
.offset(offset)
.limit(limit)
.getMany();
}
async findActivatedByUserId(workflowId: string): Promise<string | undefined> {
const record = await this.findOne({
select: ['userId'],
@@ -163,6 +163,12 @@ describe('execute-workflow MCP tool', () => {
expect(result.status).toBe('started');
expect(result.executionId).toBe('execution-id');
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith(
'unpublished-workflow',
user,
['workflow:execute'],
{ includeActiveVersion: true, publishHistory: 'none' },
);
});
test('allows manual mode for workflows with only manual trigger', async () => {
@@ -220,7 +220,11 @@ describe('get-workflow-details MCP tool', () => {
'wf-1',
user,
['workflow:read'],
expect.objectContaining({ includeActiveVersion: true, includeParentFolder: true }),
expect.objectContaining({
includeActiveVersion: true,
includeParentFolder: true,
publishHistory: 'none',
}),
);
expect(payload.workflow.nodes).toBeUndefined();
@@ -138,6 +138,7 @@ describe('publish-workflow MCP tool', () => {
expect(workflowService.activateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
versionId: undefined,
source: 'n8n-mcp',
publishHistory: 'none',
});
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith('wf-1', user.id);
@@ -173,6 +174,7 @@ describe('publish-workflow MCP tool', () => {
expect(workflowService.activateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
versionId,
source: 'n8n-mcp',
publishHistory: 'none',
});
});
});
@@ -198,7 +198,7 @@ export const executeWorkflow = async (
user,
['workflow:execute'],
workflowFinderService,
{ includeActiveVersion: true },
{ includeActiveVersion: true, publishHistory: 'none' },
);
const runData = await buildRunData(
workflow,
@@ -182,7 +182,12 @@ export async function getWorkflowDetails(
user,
['workflow:read'],
workflowFinderService,
{ includeActiveVersion: true, includeTags: true, includeParentFolder: true },
{
includeActiveVersion: true,
includeTags: true,
includeParentFolder: true,
publishHistory: 'none',
},
);
// Compute user scopes for this workflow
@@ -94,6 +94,7 @@ export const createPublishWorkflowTool = (
const activatedWorkflow = await workflowService.activateWorkflow(user, workflowId, {
versionId,
source: 'n8n-mcp',
publishHistory: 'none',
});
void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {});
@@ -18,7 +18,7 @@ export async function getMcpWorkflowVersion(
): Promise<WorkflowHistory> {
try {
return await workflowHistoryService.getVersion(user, workflowId, versionId, {
includePublishHistory: false,
publishHistory: 'none',
});
} catch (error) {
if (error instanceof WorkflowHistoryVersionNotFoundError) {
@@ -1,5 +1,5 @@
import { UrlService } from '@n8n/backend-services';
import type { User } from '@n8n/db';
import type { PublishHistoryScope, User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
@@ -43,6 +43,7 @@ export type GetMcpWorkflowOptions = {
includeActiveVersion?: boolean;
includeTags?: boolean;
includeParentFolder?: boolean;
publishHistory?: PublishHistoryScope;
};
/** The editor opens the workflow settings modal when the `settings` query parameter is set. */
@@ -762,7 +762,7 @@ export class WorkflowsPublicController {
workflowId,
workflowVersionId,
{
includePublishHistory: false,
publishHistory: 'none',
},
);
} catch (error) {
@@ -860,7 +860,7 @@ export class WorkflowsPublicController {
req.user,
workflowId,
workflowVersionId,
{ includePublishHistory: false },
{ publishHistory: 'none' },
);
this.eventService.emit('user-retrieved-workflow-version', {
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method -- vi mocks */
import type {
FolderRepository,
OperationContext,
RoleRepository,
SharedWorkflow,
SharedWorkflowRepository,
@@ -43,23 +44,35 @@ function makeService(rows?: FolderRow[]) {
}
describe('WorkflowFinderService', () => {
it('loads roles through the supplied transaction manager', async () => {
const { service, roleService, roleRepository } = makeService();
const em = mock<EntityManager>();
roleService.rolesWithScope.mockResolvedValue([]);
roleRepository.findAll.mockResolvedValue([]);
it.each(['manager', 'context'])(
'keeps the history scope with the transaction %s',
async (kind) => {
const { service, roleService, roleRepository, sharedWorkflowRepository } = makeService();
const transaction =
kind === 'manager' ? { em: mock<EntityManager>() } : { ctx: mock<OperationContext>() };
roleService.rolesWithScope.mockResolvedValue([]);
roleRepository.findAll.mockResolvedValue([]);
await service.findWorkflowForUser(
'workflow-1',
{ id: 'user-1', role: { slug: 'global:member', scopes: [] } } as never,
['workflow:read'],
{ em },
);
await service.findWorkflowForUser(
'workflow-1',
{ id: 'user-1', role: { slug: 'global:member', scopes: [] } } as never,
['workflow:read'],
{ ...transaction, includeActiveVersion: true, publishHistory: 'latestActivation' },
);
const loadRoles = roleService.rolesWithScope.mock.calls[0][2];
await loadRoles?.();
expect(roleRepository.findAll).toHaveBeenCalledWith(em);
});
const loadRoles = roleService.rolesWithScope.mock.calls[0][2];
await loadRoles?.();
expect(roleRepository.findAll).toHaveBeenCalledWith(transaction.em ?? transaction.ctx);
expect(sharedWorkflowRepository.findWorkflowWithOptions).toHaveBeenCalledWith(
'workflow-1',
expect.objectContaining({
...transaction,
includeActiveVersion: true,
publishHistory: 'latestActivation',
}),
);
},
);
describe('findWorkflowIdsByFolder', () => {
it('returns an empty map without querying when no folder ids are given', async () => {
@@ -1,5 +1,12 @@
import { RoleService } from '@n8n/backend-services';
import type { SharedWorkflow, User, WorkflowEntity, ListQuery, OperationContext } from '@n8n/db';
import type {
SharedWorkflow,
User,
WorkflowEntity,
ListQuery,
PublishHistoryScope,
OperationContext,
} from '@n8n/db';
import {
SharedWorkflowRepository,
FolderRepository,
@@ -48,6 +55,7 @@ export class WorkflowFinderService {
includeTags?: boolean;
includeParentFolder?: boolean;
includeActiveVersion?: boolean;
publishHistory?: PublishHistoryScope;
} & ({ em?: EntityManager; ctx?: never } | { ctx?: OperationContext; em?: never }) = {},
) {
const where = await this.buildSingleWorkflowReadWhere(user, scopes, options.em ?? options.ctx);
@@ -57,6 +65,7 @@ export class WorkflowFinderService {
includeTags: options.includeTags,
includeParentFolder: options.includeParentFolder,
includeActiveVersion: options.includeActiveVersion,
publishHistory: options.publishHistory,
...(options.em ? { em: options.em } : { ctx: options.ctx }),
});
@@ -352,7 +352,7 @@ describe('WorkflowHistoryService', () => {
});
describe('getVersion', () => {
it('should not load publish history when includePublishHistory is false', async () => {
it('should not load publish history when the scope is none', async () => {
// Arrange
const workflow = getWorkflow({ addNodeWithoutCreds: true });
workflow.id = '123';
@@ -362,7 +362,7 @@ describe('WorkflowHistoryService', () => {
// Act
const result = await workflowHistoryService.getVersion(testUser, workflow.id, 'version1', {
includePublishHistory: false,
publishHistory: 'none',
});
// Assert
@@ -558,9 +558,9 @@ describe('WorkflowHistoryService', () => {
const workflowId = '123';
workflowFinderService.findWorkflowForUser.mockResolvedValueOnce(null);
await expect(workflowHistoryService.getPublishTimeline(testUser, workflowId)).rejects.toThrow(
SharedWorkflowNotFoundError,
);
await expect(
workflowHistoryService.getPublishTimeline(testUser, workflowId, { offset: 0, limit: 10 }),
).rejects.toThrow(SharedWorkflowNotFoundError);
});
it('should return publish timeline events with version names', async () => {
@@ -593,17 +593,12 @@ describe('WorkflowHistoryService', () => {
},
];
const qb = {
leftJoinAndSelect: vi.fn().mockReturnThis(),
leftJoin: vi.fn().mockReturnThis(),
addSelect: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
getMany: vi.fn().mockResolvedValueOnce(mockEvents),
};
workflowPublishHistoryRepository.createQueryBuilder.mockReturnValueOnce(qb as never);
workflowPublishHistoryRepository.findTimelinePage.mockResolvedValueOnce(mockEvents as never);
const result = await workflowHistoryService.getPublishTimeline(testUser, workflowId);
const result = await workflowHistoryService.getPublishTimeline(testUser, workflowId, {
offset: 20,
limit: 10,
});
expect(result).toEqual([
{
@@ -625,8 +620,10 @@ describe('WorkflowHistoryService', () => {
versionName: null,
},
]);
expect(qb.leftJoin).toHaveBeenCalledWith('wph.workflowHistory', 'wh');
expect(qb.addSelect).toHaveBeenCalledWith('wh.name');
expect(workflowPublishHistoryRepository.findTimelinePage).toHaveBeenCalledWith(workflowId, {
offset: 20,
limit: 10,
});
});
});
@@ -2,6 +2,8 @@ import {
PaginationDto,
WorkflowHistoryVersionsByIdsDto,
UpdateWorkflowHistoryVersionDto,
WorkflowPublishTimelineQueryDto,
WORKFLOW_HISTORY_DEFAULT_TAKE,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { RestController, Get, Post, Query, Body, Patch, Param, Licensed } from '@n8n/decorators';
@@ -13,8 +15,6 @@ import { WorkflowHistoryRequest } from '@/requests';
import { WorkflowHistoryService } from './workflow-history.service';
const DEFAULT_TAKE = 20;
@RestController('/workflow-history')
export class WorkflowHistoryController {
constructor(private readonly historyService: WorkflowHistoryService) {}
@@ -25,7 +25,7 @@ export class WorkflowHistoryController {
return await this.historyService.getList(
req.user,
req.params.workflowId,
query.take ?? DEFAULT_TAKE,
query.take ?? WORKFLOW_HISTORY_DEFAULT_TAKE,
query.skip ?? 0,
);
} catch (e) {
@@ -43,6 +43,7 @@ export class WorkflowHistoryController {
req.user,
req.params.workflowId,
req.params.versionId,
{ publishHistory: 'latestActivation' },
);
} catch (e) {
if (e instanceof SharedWorkflowNotFoundError) {
@@ -76,9 +77,16 @@ export class WorkflowHistoryController {
}
@Get('/workflow/:workflowId/publish-timeline')
async getPublishTimeline(req: WorkflowHistoryRequest.GetList) {
async getPublishTimeline(
req: WorkflowHistoryRequest.GetList,
_res: Response,
@Query query: WorkflowPublishTimelineQueryDto,
) {
try {
return await this.historyService.getPublishTimeline(req.user, req.params.workflowId);
return await this.historyService.getPublishTimeline(req.user, req.params.workflowId, {
offset: query.skip,
limit: query.take,
});
} catch (e) {
if (e instanceof SharedWorkflowNotFoundError) {
throw new NotFoundError('Could not find workflow');
@@ -1,7 +1,7 @@
import { UpdateWorkflowHistoryVersionDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { EventService } from '@n8n/backend-services';
import type { OperationContext, User } from '@n8n/db';
import type { OperationContext, PublishHistoryScope, User } from '@n8n/db';
import {
WorkflowHistory,
WorkflowHistoryRepository,
@@ -60,7 +60,7 @@ export class WorkflowHistoryService {
throw new SharedWorkflowNotFoundError('');
}
return await this.workflowHistoryRepository.find({
const versions = await this.workflowHistoryRepository.find({
where: {
workflowId: workflow.id,
},
@@ -76,16 +76,30 @@ export class WorkflowHistoryService {
'description',
'autosaved',
],
relations: ['workflowPublishHistory'],
order: { createdAt: 'DESC' },
order: { createdAt: 'DESC', versionId: 'ASC' },
});
// Callers only show the latest publish of each version.
const activations = await this.workflowPublishHistoryRepository.findLatestActivations(
workflow.id,
versions.map(({ versionId }) => versionId),
);
const activationsByVersion = new Map(
activations.map((activation) => [activation.versionId, activation]),
);
for (const version of versions) {
const activation = activationsByVersion.get(version.versionId);
version.workflowPublishHistory = activation ? [activation] : [];
}
return versions;
}
async getVersion(
user: User,
workflowId: string,
versionId: string,
settings?: { includePublishHistory?: boolean },
settings?: { publishHistory?: PublishHistoryScope },
): Promise<WorkflowHistory> {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read',
@@ -105,10 +119,12 @@ export class WorkflowHistoryService {
throw new WorkflowHistoryVersionNotFoundError('');
}
if (settings?.includePublishHistory ?? true) {
const publishHistory = settings?.publishHistory ?? 'all';
if (publishHistory !== 'none') {
hist.workflowPublishHistory = await this.workflowPublishHistoryRepository.findByVersion(
workflow.id,
versionId,
publishHistory,
);
}
return hist;
@@ -346,7 +362,11 @@ export class WorkflowHistoryService {
return versions.map((v) => ({ versionId: v.versionId, createdAt: v.createdAt }));
}
async getPublishTimeline(user: User, workflowId: string) {
async getPublishTimeline(
user: User,
workflowId: string,
page: { offset: number; limit: number },
) {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:read',
]);
@@ -355,14 +375,7 @@ export class WorkflowHistoryService {
throw new SharedWorkflowNotFoundError('');
}
const events = await this.workflowPublishHistoryRepository
.createQueryBuilder('wph')
.leftJoinAndSelect('wph.user', 'user')
.leftJoin('wph.workflowHistory', 'wh')
.addSelect('wh.name')
.where('wph.workflowId = :workflowId', { workflowId: workflow.id })
.orderBy('wph.createdAt', 'ASC')
.getMany();
const events = await this.workflowPublishHistoryRepository.findTimelinePage(workflow.id, page);
return events.map((e) => ({
id: e.id,
@@ -7,6 +7,7 @@ import type {
User,
ListQueryDb,
Project,
PublishHistoryScope,
WorkflowFolderUnionFull,
WorkflowHistory,
OperationContext,
@@ -918,6 +919,7 @@ export class WorkflowService {
await this.activateWorkflow(user, workflowId, {
versionId: workflow.activeVersionId,
source,
publishHistory: 'none',
});
}
return updatedWorkflow;
@@ -1094,6 +1096,7 @@ export class WorkflowService {
description?: string;
expectedChecksum?: string;
source?: WorkflowActionSource;
publishHistory?: PublishHistoryScope;
},
): Promise<WorkflowEntity> {
const source = options?.source ?? 'ui';
@@ -1151,7 +1154,7 @@ export class WorkflowService {
workflow.id,
versionIdToActivate,
{
includePublishHistory: false,
publishHistory: 'none',
},
);
} catch (error) {
@@ -1341,11 +1344,13 @@ export class WorkflowService {
throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`);
}
if (updatedWorkflow.activeVersion) {
const publishHistory = options?.publishHistory ?? 'all';
if (updatedWorkflow.activeVersion && publishHistory !== 'none') {
updatedWorkflow.activeVersion.workflowPublishHistory =
await this.workflowPublishHistoryRepository.findByVersion(
workflowId,
updatedWorkflow.activeVersion.versionId,
publishHistory,
);
}
@@ -1373,7 +1378,7 @@ export class WorkflowService {
workflowId,
user,
['workflow:unpublish'],
{ includeActiveVersion: true },
{ includeActiveVersion: true, publishHistory: 'none' },
);
if (!workflow) {
@@ -230,6 +230,7 @@ export class WorkflowsController {
includeTags: !this.globalConfig.tags.disabled,
includeParentFolder: true,
includeActiveVersion: true,
publishHistory: 'latestActivation',
},
);
@@ -263,6 +264,7 @@ export class WorkflowsController {
includeTags: !this.globalConfig.tags.disabled,
includeParentFolder: true,
includeActiveVersion: true,
publishHistory: 'latestActivation',
},
);
@@ -463,6 +465,7 @@ export class WorkflowsController {
name,
description,
expectedChecksum,
publishHistory: 'latestActivation',
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
@@ -15,6 +15,7 @@ import { sleep } from '@n8n/utils/sleep';
import { v4 as uuid } from 'uuid';
import { createUser } from '../../shared/db/users';
import { createWorkflowPublishHistoryItem } from '../../shared/db/workflow-publish-history';
describe('WorkflowPublishHistoryRepository', () => {
beforeAll(async () => {
@@ -29,6 +30,74 @@ describe('WorkflowPublishHistoryRepository', () => {
await testDb.terminate();
});
describe('findLatestActivations', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should not query events when no version IDs are provided', async () => {
const repository = Container.get(WorkflowPublishHistoryRepository);
const createQueryBuilder = vi.spyOn(repository, 'createQueryBuilder');
await expect(repository.findLatestActivations('workflow-1', [])).resolves.toEqual([]);
expect(createQueryBuilder).not.toHaveBeenCalled();
});
it('should batch version IDs and return each latest activation once', async () => {
const repository = Container.get(WorkflowPublishHistoryRepository);
const workflow = await createWorkflow();
const versionIds = Array.from(
{ length: 32_768 },
(_, i) => `00000000-0000-4000-8000-${i.toString().padStart(12, '0')}`,
);
const storedVersionIds = [versionIds[0], versionIds[versionIds.length - 1]];
const latestActivations = [];
for (const versionId of storedVersionIds) {
await createWorkflowHistory({ ...workflow, versionId });
const version = { workflowId: workflow.id, versionId };
await createWorkflowPublishHistoryItem(version, {
createdAt: new Date('2026-01-03T00:00:00Z'),
});
latestActivations.push(
await createWorkflowPublishHistoryItem(version, {
createdAt: new Date('2026-01-01T00:00:00Z'),
}),
);
await createWorkflowPublishHistoryItem(version, { event: 'deactivated' });
}
const logQuery = vi.spyOn(repository.manager.connection.logger, 'logQuery');
const activations = await repository.findLatestActivations(workflow.id, [
...versionIds,
...storedVersionIds,
]);
expect(activations).toHaveLength(2);
expect(activations).toEqual(expect.arrayContaining(latestActivations));
expect(logQuery.mock.calls.map(([, parameters]) => parameters?.length)).toEqual([
10_002, 10_002, 10_002, 2_770,
]);
});
});
describe('findTimelinePage', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([0, -1])('should not query events when the limit is %i', async (limit) => {
const repository = Container.get(WorkflowPublishHistoryRepository);
const createQueryBuilder = vi.spyOn(repository, 'createQueryBuilder');
await expect(
repository.findTimelinePage('workflow-1', { offset: 0, limit }),
).resolves.toEqual([]);
expect(createQueryBuilder).not.toHaveBeenCalled();
});
});
describe('addRecord', () => {
it('should create a publish history record with all fields', async () => {
const id1 = uuid();
@@ -1056,6 +1056,22 @@ describe('GET /workflows/:id', () => {
}),
]);
});
test('should return every publish event of the active version in order', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
expect(response.statusCode).toBe(200);
const events = (
response.body.activeVersion.workflowPublishHistory as Array<{ event: string }>
).map(({ event }) => event);
expect(events).toEqual(['activated', 'deactivated', 'activated']);
});
});
describe('GET /workflows/:id/:versionId', () => {
@@ -1,3 +1,4 @@
import { WORKFLOW_HISTORY_DEFAULT_TAKE } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { User, WorkflowHistory } from '@n8n/db';
@@ -7,7 +8,10 @@ import type { IConnections, INode } from 'n8n-workflow';
import { ProjectService } from '@/services/project.service.ee';
import { createOwner, createUser } from '@test-integration/db/users';
import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history';
import { createWorkflowPublishHistoryItem } from '@test-integration/db/workflow-publish-history';
import {
createManyWorkflowPublishHistoryItems,
createWorkflowPublishHistoryItem,
} from '@test-integration/db/workflow-publish-history';
import type { SuperAgentTest } from './shared/types';
import * as utils from './shared/utils/';
@@ -155,6 +159,59 @@ describe('GET /workflow-history/:workflowId', () => {
expect(resp.body.data[0]).toEqual(expected);
});
test('should return all versions when take is zero', async () => {
const workflow = await createWorkflow(undefined, owner);
const versions = [];
for (let i = 0; i < 3; i++) {
versions.push(await createWorkflowHistoryItem(workflow.id));
}
const latest = await createWorkflowPublishHistoryItem(versions[0]);
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}`)
.query({ take: 0 })
.expect(200);
const page = response.body.data as WorkflowHistory[];
expect(page).toHaveLength(versions.length);
expect(page.map(({ versionId }) => versionId).sort()).toEqual(
versions.map(({ versionId }) => versionId).sort(),
);
for (const version of page) {
expect(version.workflowPublishHistory).toEqual(
version.versionId === latest.versionId
? [{ ...latest, createdAt: latest.createdAt.toISOString() }]
: [],
);
}
});
test('should page versions with equal timestamps in ascending version ID order', async () => {
const workflow = await createWorkflow(undefined, owner);
const createdAt = new Date('2026-01-01T00:00:00Z');
const versionIds = Array.from(
{ length: 5 },
(_, i) => `00000000-0000-4000-8000-${i.toString().padStart(12, '0')}`,
);
for (const versionId of versionIds.toReversed()) {
await createWorkflowHistoryItem(workflow.id, { versionId, createdAt });
}
const returnedIds: string[] = [];
for (const skip of [0, 2, 4]) {
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}`)
.query({ skip, take: 2 })
.expect(200);
const page = response.body.data as WorkflowHistory[];
expect(page.map(({ versionId }) => versionId)).toEqual(versionIds.slice(skip, skip + 2));
returnedIds.push(...page.map(({ versionId }) => versionId));
}
expect(returnedIds).toEqual(versionIds);
expect(new Set(returnedIds).size).toBe(versionIds.length);
});
test('should work with skip parameter', async () => {
const workflow = await createWorkflow(undefined, owner);
const versions = await Promise.all(
@@ -188,51 +245,29 @@ describe('GET /workflow-history/:workflowId', () => {
expect(resp.body.data[0]).toEqual(expected);
});
test('should include workflowPublishHistory records related to each history item', async () => {
test('should include only the latest activation of each history item', async () => {
const workflow = await createWorkflow(undefined, owner);
const v1 = await createWorkflowHistoryItem(workflow.id);
const v2 = await createWorkflowHistoryItem(workflow.id);
const wph1 = await createWorkflowPublishHistoryItem(v1);
const wph2 = await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
const wph3 = await createWorkflowPublishHistoryItem(v2);
await createWorkflowPublishHistoryItem(v1);
const latestOfV1 = await createWorkflowPublishHistoryItem(v1);
await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
const latestOfV2 = await createWorkflowPublishHistoryItem(v2);
const response = await authOwnerAgent.get(`/workflow-history/workflow/${workflow.id}`);
expect(response.status).toBe(200);
const body = response.body as { data: WorkflowHistory[] };
const publishHistoryOf = (versionId: string) =>
body.data.find((history) => history.versionId === versionId)?.workflowPublishHistory;
expect(body.data).toHaveLength(2);
const publishHistories = body.data.map((history) => history.workflowPublishHistory);
expect(publishHistories).toEqual(
expect.arrayContaining([expect.any(Array), expect.any(Array)]),
);
const publishHistory1 = publishHistories.find((ph) => ph.length === 1)!;
const publishHistory2 = publishHistories.find((ph) => ph.length === 2)!;
expect(publishHistory1).toEqual(
expect.arrayContaining([
expect.objectContaining({
...wph3,
createdAt: wph3.createdAt.toISOString(),
}),
]),
);
expect(publishHistory2).toEqual(
expect.arrayContaining([
expect.objectContaining({
...wph1,
createdAt: wph1.createdAt.toISOString(),
}),
expect.objectContaining({
...wph2,
createdAt: wph2.createdAt.toISOString(),
}),
]),
);
expect(publishHistoryOf(v1.versionId)).toEqual([
{ ...latestOfV1, createdAt: latestOfV1.createdAt.toISOString() },
]);
expect(publishHistoryOf(v2.versionId)).toEqual([
{ ...latestOfV2, createdAt: latestOfV2.createdAt.toISOString() },
]);
});
});
@@ -287,12 +322,13 @@ describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () =>
);
expect(resp.status).toBe(404);
});
test('should include workflowPublishHistory records related to history item', async () => {
test('should include only the latest activation of the history item', async () => {
const workflow = await createWorkflow(undefined, owner);
const v1 = await createWorkflowHistoryItem(workflow.id);
const v2 = await createWorkflowHistoryItem(workflow.id);
const wph1 = await createWorkflowPublishHistoryItem(v1);
const wph2 = await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
await createWorkflowPublishHistoryItem(v1);
const latest = await createWorkflowPublishHistoryItem(v1);
await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
await createWorkflowPublishHistoryItem(v2);
const resp = await authOwnerAgent.get(
@@ -303,10 +339,7 @@ describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () =>
...v1,
createdAt: v1.createdAt.toISOString(),
updatedAt: v1.updatedAt.toISOString(),
workflowPublishHistory: [
{ ...wph1, createdAt: wph1.createdAt.toISOString() },
{ ...wph2, createdAt: wph2.createdAt.toISOString() },
],
workflowPublishHistory: [{ ...latest, createdAt: latest.createdAt.toISOString() }],
});
});
});
@@ -502,3 +535,86 @@ describe('PATCH /workflow-history/workflow/:workflowId/versions/:versionId', ()
expect(getResponse.body.data.connections).toEqual(originalConnections);
});
});
describe('GET /workflow-history/workflow/:workflowId/publish-timeline', () => {
test('should return an empty page when take is zero', async () => {
const workflow = await createWorkflow(undefined, owner);
const version = await createWorkflowHistoryItem(workflow.id);
await createManyWorkflowPublishHistoryItems(version, 5);
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
.query({ take: 0 })
.expect(200);
expect(response.body.data).toEqual([]);
});
test('should use the default page size when take is omitted', async () => {
const workflow = await createWorkflow(undefined, owner);
const version = await createWorkflowHistoryItem(workflow.id);
const events = await createManyWorkflowPublishHistoryItems(
version,
WORKFLOW_HISTORY_DEFAULT_TAKE + 5,
);
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
.expect(200);
const page = response.body.data as Array<{ id: number }>;
expect(page).toHaveLength(WORKFLOW_HISTORY_DEFAULT_TAKE);
expect(page.map(({ id }) => id)).toEqual(
events
.slice(-WORKFLOW_HISTORY_DEFAULT_TAKE)
.toReversed()
.map(({ id }) => id),
);
});
test('should page events with equal timestamps in descending ID order', async () => {
const workflow = await createWorkflow(undefined, owner);
const version = await createWorkflowHistoryItem(workflow.id);
const createdAt = new Date('2026-01-01T00:00:00Z');
const events = [];
for (let i = 0; i < 5; i++) {
events.push(await createWorkflowPublishHistoryItem(version, { createdAt }));
}
const returnedIds: number[] = [];
for (const skip of [0, 2, 4]) {
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
.query({ skip, take: 2 })
.expect(200);
const page = response.body.data as Array<{ id: number }>;
returnedIds.push(...page.map(({ id }) => id));
}
expect(returnedIds).toEqual(events.toReversed().map(({ id }) => id));
});
test('should return one page of events, newest first, with the version name', async () => {
const workflow = await createWorkflow(undefined, owner);
const version = await createWorkflowHistoryItem(workflow.id, { name: 'Release 1' });
const start = new Date('2026-01-01T00:00:00Z').getTime();
const events = [];
for (let i = 0; i < 5; i++) {
events.push(
await createWorkflowPublishHistoryItem(version, {
event: i % 2 === 0 ? 'activated' : 'deactivated',
createdAt: new Date(start + i * 60_000),
}),
);
}
const response = await authOwnerAgent
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
.query({ skip: 1, take: 2 });
expect(response.status).toBe(200);
const page = response.body.data as Array<{ id: number; event: string; versionName: string }>;
expect(page.map(({ id }) => id)).toEqual([events[3].id, events[2].id]);
expect(page[0]).toMatchObject({ event: 'deactivated', versionName: 'Release 1' });
});
});
@@ -371,6 +371,7 @@ describe('update()', () => {
expect(activateSpy).toHaveBeenCalledWith(owner, workflow.id, {
versionId: workflow.activeVersionId,
source: 'ui',
publishHistory: 'none',
});
});
@@ -1087,19 +1087,17 @@ describe('GET /workflows/:workflowId', () => {
});
});
test('should return every publish event of the active version in order', async () => {
test('should return only the latest activation of the active version', async () => {
const workflow = await createActiveWorkflow({}, owner);
const activeVersion = { workflowId: workflow.id, versionId: workflow.activeVersionId! };
await createWorkflowPublishHistoryItem(activeVersion, { event: 'deactivated' });
await createWorkflowPublishHistoryItem(activeVersion);
const latest = await createWorkflowPublishHistoryItem(activeVersion);
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200);
const { data } = response.body as { data: { activeVersion: WorkflowHistory } };
expect(data.activeVersion.workflowPublishHistory.map(({ event }) => event)).toEqual([
'activated',
'deactivated',
'activated',
expect(data.activeVersion.workflowPublishHistory).toEqual([
expect.objectContaining({ id: latest.id, event: 'activated' }),
]);
});
@@ -92,10 +92,12 @@ export type { PublishTimelineEvent } from '@n8n/api-types';
export const getPublishTimeline = async (
context: IRestApiContext,
workflowId: string,
queryParams: WorkflowHistoryRequestParams,
): Promise<PublishTimelineEvent[]> => {
const { data } = await get(
context.baseUrl,
`/workflow-history/workflow/${workflowId}/publish-timeline`,
queryParams,
);
return data;
};
@@ -1,7 +1,12 @@
import { createTestingPinia } from '@pinia/testing';
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import { flushPromises } from '@vue/test-utils';
import { fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { defineComponent, h } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { registerToastNotifier } from '@/app/init/toastNotifier';
import WorkflowPublishTimelineContent from './WorkflowPublishTimelineContent.vue';
import { useWorkflowHistoryStore } from '../workflowHistory.store';
import type { PublishTimelineEvent } from '@n8n/rest-api-client/api/workflowHistory';
@@ -57,7 +62,10 @@ describe('WorkflowPublishTimelineContent', () => {
const { findByText, workflowHistoryStore } = renderWithEvents([]);
await findByText('This workflow has no publish history yet.');
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledWith(workflowId);
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledWith(workflowId, {
take: 100,
skip: 0,
});
});
it('should render a published entry with its version name', async () => {
@@ -228,4 +236,343 @@ describe('WorkflowPublishTimelineContent', () => {
expect(await findByText('Published')).toBeInTheDocument();
});
describe('paging', () => {
const renderKeyedTimeline = createComponentRenderer(
defineComponent({
props: { workflowId: { type: String, required: true } },
setup(props) {
return () =>
h(WorkflowPublishTimelineContent, {
key: props.workflowId,
workflowId: props.workflowId,
});
},
}),
);
let scrollToEnd = () => {};
let scrollAwayFromEnd = () => {};
beforeEach(() => {
scrollToEnd = () => {};
scrollAwayFromEnd = () => {};
vi.stubGlobal(
'IntersectionObserver',
class {
isObserving = false;
isIntersecting = false;
constructor(callback: IntersectionObserverCallback) {
const intersect = (isIntersecting: boolean) => {
if (!this.isObserving || this.isIntersecting === isIntersecting) return;
this.isIntersecting = isIntersecting;
callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
};
scrollToEnd = () => intersect(true);
scrollAwayFromEnd = () => intersect(false);
}
observe = vi.fn(() => {
this.isObserving = true;
this.isIntersecting = false;
});
disconnect = vi.fn(() => {
this.isObserving = false;
});
unobserve = vi.fn();
takeRecords = vi.fn();
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
const buildPage = (count: number, firstId: number, newest: Date) =>
Array.from({ length: count }, (_, i) =>
buildEvent({
id: firstId + i,
createdAt: new Date(newest.getTime() - i * 60_000).toISOString(),
}),
);
const renderWithPages = (...pages: PublishTimelineEvent[][]) => {
const pinia = createTestingPinia({ stubActions: false });
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
for (const page of pages) {
workflowHistoryStore.getPublishTimeline.mockResolvedValueOnce(page);
}
workflowHistoryStore.getVersionFirstAdoptionDate.mockResolvedValue(null);
return { ...renderKeyedTimeline({ pinia, props: { workflowId } }), workflowHistoryStore };
};
it('should request the next page when the end of the list becomes visible', async () => {
const newest = new Date('2026-03-01T10:00:00Z');
const { findAllByText, getAllByText, workflowHistoryStore } = renderWithPages(
buildPage(100, 1, newest),
buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)),
);
expect(await findAllByText('Published')).toHaveLength(100);
scrollToEnd();
await waitFor(() => expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2));
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenLastCalledWith(workflowId, {
take: 100,
skip: 100,
});
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
});
it('should remove overlapping events without shifting the offset or timestamp tie order', async () => {
const events = Array.from({ length: 205 }, (_, i) =>
buildEvent({
id: 205 - i,
versionName: `Version ${205 - i}`,
createdAt: '2026-03-01T10:00:00Z',
}),
);
const { findAllByText, getAllByText, workflowHistoryStore } = renderWithPages(
events.slice(0, 100),
events.slice(99, 199),
events.slice(199),
);
expect(await findAllByText(/^Published Version /)).toHaveLength(100);
scrollToEnd();
await waitFor(() => expect(getAllByText(/^Published Version /)).toHaveLength(199));
expect(getAllByText('Published Version 106')).toHaveLength(1);
scrollToEnd();
await waitFor(() => expect(getAllByText(/^Published Version /)).toHaveLength(205));
expect(workflowHistoryStore.getPublishTimeline.mock.calls).toEqual([
[workflowId, { take: 100, skip: 0 }],
[workflowId, { take: 100, skip: 100 }],
[workflowId, { take: 100, skip: 200 }],
]);
expect(getAllByText(/^Published Version /).map((element) => element.textContent)).toEqual(
events.map(({ versionName }) => `Published ${versionName}`),
);
});
it('should retry a failed page without scrolling or advancing the offset', async () => {
registerToastNotifier();
const newest = new Date('2026-03-01T10:00:00Z');
const {
findByRole,
findByText,
findAllByText,
getAllByText,
queryByRole,
workflowHistoryStore,
} = renderWithPages(buildPage(100, 1, newest));
const error = new Error('Failed to load page');
workflowHistoryStore.getPublishTimeline
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)));
expect(await findAllByText('Published')).toHaveLength(100);
scrollToEnd();
expect(await findByText(error.message)).toBeInTheDocument();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
expect(getAllByText('Published')).toHaveLength(100);
scrollToEnd();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
await userEvent.click(await findByRole('button', { name: 'Retry' }));
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(3);
expect(workflowHistoryStore.getPublishTimeline.mock.calls.slice(1)).toEqual([
[workflowId, { take: 100, skip: 100 }],
[workflowId, { take: 100, skip: 100 }],
]);
expect(queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
});
it('should retain scroll-based retry after leaving and re-entering the sentinel', async () => {
registerToastNotifier();
const newest = new Date('2026-03-01T10:00:00Z');
const { findAllByText, findByText, getAllByText, workflowHistoryStore } = renderWithPages(
buildPage(100, 1, newest),
);
workflowHistoryStore.getPublishTimeline
.mockRejectedValueOnce(new Error('Page request failed'))
.mockResolvedValueOnce(buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)));
await findAllByText('Published');
scrollToEnd();
await findByText('Page request failed');
scrollAwayFromEnd();
scrollToEnd();
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
});
it('should reload from the first page when the workflow changes', async () => {
const { findAllByText, findByText, queryAllByText, rerender, workflowHistoryStore } =
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')), [
buildEvent({ id: 1001, workflowId: 'wf-2', versionName: 'Workflow B' }),
]);
await findAllByText('Published');
await rerender({ workflowId: 'wf-2' });
expect(await findByText('Published Workflow B')).toBeInTheDocument();
expect(queryAllByText('Published')).toHaveLength(0);
expect(workflowHistoryStore.getPublishTimeline.mock.calls).toEqual([
[workflowId, { take: 100, skip: 0 }],
['wf-2', { take: 100, skip: 0 }],
]);
});
it('should ignore an old page that resolves after the workflow changes', async () => {
const pending = createDeferredPromise<PublishTimelineEvent[]>();
const { findAllByText, findByText, queryByText, rerender, workflowHistoryStore } =
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')));
workflowHistoryStore.getPublishTimeline
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce([
buildEvent({ id: 1001, workflowId: 'wf-2', versionName: 'Workflow B' }),
]);
await findAllByText('Published');
scrollToEnd();
await waitFor(() => expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2));
await rerender({ workflowId: 'wf-2' });
await findByText('Published Workflow B');
pending.resolve([buildEvent({ id: 101, versionName: 'Late workflow A' })]);
await flushPromises();
expect(queryByText('Published Late workflow A')).not.toBeInTheDocument();
expect(queryByText('Published')).not.toBeInTheDocument();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenLastCalledWith('wf-2', {
take: 100,
skip: 0,
});
});
it.each(['workflow change', 'unmount'])(
'should ignore a page failure after %s',
async (action) => {
registerToastNotifier();
const pending = createDeferredPromise<PublishTimelineEvent[]>();
const { findAllByText, queryByText, rerender, unmount, workflowHistoryStore } =
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')));
workflowHistoryStore.getPublishTimeline
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce([buildEvent({ id: 1001, workflowId: 'wf-2' })]);
await findAllByText('Published');
scrollToEnd();
await waitFor(() =>
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2),
);
if (action === 'workflow change') await rerender({ workflowId: 'wf-2' });
else unmount();
pending.reject(new Error('Stale page failure'));
await flushPromises();
expect(queryByText('Stale page failure')).not.toBeInTheDocument();
},
);
it('should retry the first page without starting duplicate requests', async () => {
registerToastNotifier();
const pinia = createTestingPinia({ stubActions: false });
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
const pending = createDeferredPromise<PublishTimelineEvent[]>();
workflowHistoryStore.getPublishTimeline
.mockRejectedValueOnce(new Error('Initial page failed'))
.mockReturnValueOnce(pending.promise);
workflowHistoryStore.getVersionFirstAdoptionDate.mockResolvedValue(null);
const { findByRole, findByText, queryByText } = renderComponent({
pinia,
props: { workflowId },
});
const retryButton = await findByRole('button', { name: 'Retry' });
expect(queryByText('This workflow has no publish history yet.')).not.toBeInTheDocument();
fireEvent.click(retryButton);
fireEvent.click(retryButton);
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
pending.resolve([buildEvent()]);
expect(await findByText('Published')).toBeInTheDocument();
});
it('should ignore initial results and adoption data from the previous workflow', async () => {
const pinia = createTestingPinia({ stubActions: false });
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
const oldPage = createDeferredPromise<PublishTimelineEvent[]>();
const newPage = createDeferredPromise<PublishTimelineEvent[]>();
const oldAdoption = createDeferredPromise<string | null>();
workflowHistoryStore.getPublishTimeline
.mockReturnValueOnce(oldPage.promise)
.mockReturnValueOnce(newPage.promise);
workflowHistoryStore.getVersionFirstAdoptionDate
.mockReturnValueOnce(oldAdoption.promise)
.mockResolvedValueOnce('2024-01-01T00:00:00Z');
const { findByText, queryByText, rerender } = renderKeyedTimeline({
pinia,
props: { workflowId },
});
await rerender({ workflowId: 'wf-2' });
oldPage.resolve([buildEvent({ versionName: 'Old workflow A' })]);
oldAdoption.resolve('2026-01-01T00:00:00Z');
await flushPromises();
expect(queryByText('Published Old workflow A')).not.toBeInTheDocument();
expect(queryByText('This workflow has no publish history yet.')).not.toBeInTheDocument();
newPage.resolve([
buildEvent({
id: 1001,
workflowId: 'wf-2',
versionName: 'Workflow B',
createdAt: '2025-01-01T00:00:00Z',
}),
]);
expect(await findByText('Published Workflow B')).toBeInTheDocument();
expect(queryByText(/History before .* may be incomplete/)).not.toBeInTheDocument();
});
it('should not let an old completion clear the new workflow loading guard', async () => {
const oldPage = createDeferredPromise<PublishTimelineEvent[]>();
const newPage = createDeferredPromise<PublishTimelineEvent[]>();
const newest = new Date('2026-03-01T10:00:00Z');
const { findAllByText, getAllByText, rerender, workflowHistoryStore } = renderWithPages(
buildPage(100, 1, newest),
);
workflowHistoryStore.getPublishTimeline
.mockReturnValueOnce(oldPage.promise)
.mockResolvedValueOnce(
buildPage(100, 1001, newest).map((event) => ({ ...event, workflowId: 'wf-2' })),
)
.mockReturnValueOnce(newPage.promise);
await findAllByText('Published');
scrollToEnd();
await rerender({ workflowId: 'wf-2' });
await waitFor(() => expect(getAllByText('Published')).toHaveLength(100));
scrollToEnd();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(4);
oldPage.resolve([buildEvent({ id: 101 })]);
await flushPromises();
scrollAwayFromEnd();
scrollToEnd();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(4);
newPage.resolve([buildEvent({ id: 1101, workflowId: 'wf-2' })]);
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
});
it('should not request another page after a page that is not full', async () => {
const { findAllByText, workflowHistoryStore } = renderWithPages(
buildPage(3, 1, new Date('2026-03-01T10:00:00Z')),
);
expect(await findAllByText('Published')).toHaveLength(3);
scrollToEnd();
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import dateformat from 'dateformat';
import { useI18n } from '@n8n/i18n';
import { N8nText, N8nLoading, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useToast } from '@n8n/composables/useToast';
import { N8nText, N8nLoading, N8nIcon, N8nTooltip, N8nButton } from '@n8n/design-system';
import type { PublishTimelineEvent } from '@n8n/rest-api-client/api/workflowHistory';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import { useWorkflowHistoryStore } from '../workflowHistory.store';
import { formatTimestamp, generateVersionLabelFromId } from '../utils';
import WorkflowHistoryPublishedTooltip from './WorkflowHistoryPublishedTooltip.vue';
@@ -13,6 +15,7 @@ import type { WorkflowHistoryVersionStatus } from '../types';
const TRANSIENT_DEACTIVATION_MS = 2000;
/** Adoption point for publish-timeline tracking — older events may be incomplete */
const ADOPTION_VERSION = { major: 2, minor: 17, patch: 0 } as const;
const PAGE_SIZE = 100;
const props = defineProps<{
workflowId: string;
@@ -24,11 +27,19 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const toast = useToast();
const workflowHistoryStore = useWorkflowHistoryStore();
const isLoading = ref(true);
const isLoadingMore = ref(false);
const hasLoadError = ref(false);
const hasMore = ref(false);
const events = ref<PublishTimelineEvent[]>([]);
const fetchedCount = ref(0);
const adoptionDate = ref<Date | null>(null);
const contentElement = ref<HTMLElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
let disposed = false;
type EntryStatus = 'published' | 'unpublished';
@@ -181,7 +192,7 @@ const entries = computed<TimelineEntry[]>(() =>
);
const showDeletedVersionsDisclaimer = computed(() => {
if (adoptionDate.value === null) return false;
if (adoptionDate.value === null || hasMore.value) return false;
const adoptionTime = adoptionDate.value.getTime();
return events.value.some((e) => new Date(e.createdAt).getTime() < adoptionTime);
});
@@ -196,27 +207,90 @@ const handleSelect = (entry: TimelineEntry) => {
if (entry.versionId) emit('selectVersion', entry.versionId);
};
const oldestFirst = (a: PublishTimelineEvent, b: PublishTimelineEvent) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id - b.id;
const fetchNextPage = async () => {
const page = await workflowHistoryStore.getPublishTimeline(props.workflowId, {
take: PAGE_SIZE,
skip: fetchedCount.value,
});
if (disposed) return;
fetchedCount.value += page.length;
hasMore.value = page.length === PAGE_SIZE;
// A publish while scrolling shifts the pages, so they can overlap.
const loadedIds = new Set(events.value.map(({ id }) => id));
events.value = [...events.value, ...page.filter(({ id }) => !loadedIds.has(id))].sort(
oldestFirst,
);
};
const loadTimeline = async () => {
if (disposed) return;
isLoading.value = true;
hasLoadError.value = false;
try {
const [timelineEvents, firstAdoptionDate] = await Promise.all([
workflowHistoryStore.getPublishTimeline(props.workflowId),
const [, firstAdoptionDate] = await Promise.all([
fetchNextPage(),
workflowHistoryStore.getVersionFirstAdoptionDate(ADOPTION_VERSION).catch(() => null),
]);
adoptionDate.value = firstAdoptionDate ? new Date(firstAdoptionDate) : null;
events.value = timelineEvents;
if (!disposed) {
adoptionDate.value = firstAdoptionDate ? new Date(firstAdoptionDate) : null;
}
} catch (error) {
if (!disposed) {
hasLoadError.value = true;
toast.showError(error, i18n.baseText('workflowHistory.title'));
}
} finally {
isLoading.value = false;
if (!disposed) isLoading.value = false;
}
};
const loadMore = async () => {
if (disposed || isLoadingMore.value || !hasMore.value) return;
isLoadingMore.value = true;
hasLoadError.value = false;
try {
await fetchNextPage();
} catch (error) {
if (!disposed) {
hasLoadError.value = true;
toast.showError(error, i18n.baseText('workflowHistory.title'));
}
} finally {
if (!disposed) isLoadingMore.value = false;
}
};
const retry = async () => {
if (disposed || isLoading.value || isLoadingMore.value) return;
if (fetchedCount.value === 0) await loadTimeline();
else await loadMore();
};
const { observe: observeForLoadMore } = useIntersectionObserver({
root: contentElement,
onIntersect: async () => await loadMore(),
// Keep observing so a later scroll can retry a failed page.
once: false,
});
watch([loadMoreSentinel, hasMore, fetchedCount], ([sentinel, canLoadMore]) => {
if (sentinel && canLoadMore) observeForLoadMore(sentinel);
});
onMounted(loadTimeline);
onBeforeUnmount(() => {
disposed = true;
});
</script>
<template>
<div :class="$style.content">
<div ref="contentElement" :class="$style.content">
<N8nLoading v-if="isLoading" :rows="4" />
<div v-else-if="entries.length === 0" :class="$style.empty">
<div v-else-if="entries.length === 0 && !hasMore && !hasLoadError" :class="$style.empty">
<N8nText size="small" color="text-light">
{{ i18n.baseText('workflowHistory.publishTimeline.empty') }}
</N8nText>
@@ -297,6 +371,17 @@ onMounted(loadTimeline);
</div>
</WorkflowHistoryPublishedTooltip>
</div>
<div v-if="hasMore" ref="loadMoreSentinel" :class="$style.sentinel" aria-hidden="true" />
<N8nLoading v-if="isLoadingMore" :rows="1" />
<N8nButton
v-if="hasLoadError"
variant="subtle"
size="small"
:disabled="isLoading || isLoadingMore"
@click="retry"
>
{{ i18n.baseText('generic.retry') }}
</N8nButton>
<N8nTooltip
v-if="showDeletedVersionsDisclaimer"
placement="top"
@@ -328,6 +413,10 @@ onMounted(loadTimeline);
padding: var(--spacing--2xs);
}
.sentinel {
height: 1px;
}
.empty {
display: flex;
align-items: center;
@@ -20,8 +20,9 @@ import type { IWorkflowDb } from '@/Interface';
import { telemetry } from '@/app/plugins/telemetry';
import { registerToastNotifier } from '@/app/init/toastNotifier';
vi.mock('vue-router', () => {
const params = {};
vi.mock('vue-router', async () => {
const { reactive } = await import('vue');
const params = reactive({});
const query = {};
const push = vi.fn();
const replace = vi.fn();
@@ -279,6 +280,20 @@ describe('WorkflowHistory', () => {
beforeEach(cleanRouteState);
afterEach(cleanRouteState);
it('should remount the timeline only when the workflow route changes', async () => {
route.params.workflowId = workflowId;
route.query.tab = 'publishTimeline';
const { findByTestId, getByTestId } = renderComponent({ pinia });
const oldTimeline = await findByTestId('stub-publish-timeline-select');
route.params.versionId = 'another-version';
await flushPromises();
expect(getByTestId('stub-publish-timeline-select')).toBe(oldTimeline);
route.params.workflowId = 'another-workflow';
await flushPromises();
expect(oldTimeline.isConnected).toBe(false);
expect(getByTestId('stub-publish-timeline-select')).not.toBe(oldTimeline);
});
it('should track telemetry when landing on the publish timeline tab via query param', async () => {
route.params.workflowId = workflowId;
route.query.tab = 'publishTimeline';
@@ -664,7 +664,9 @@ watchEffect(async () => {
@load-more="loadMore"
@upgrade="onUpgrade"
/>
<!-- Keep pagination state scoped to one workflow. -->
<WorkflowPublishTimelineContent
:key="workflowId"
v-if="canRender && activeTab === 'publishTimeline'"
:workflow-id="workflowId"
:selected-version-id="versionId"
@@ -144,7 +144,7 @@ describe('Workflow history store', () => {
});
describe('getPublishTimeline', () => {
it('should call the API with the rest context and workflow id and return its result', async () => {
it('should call the API with the rest context, workflow id and page, and return its result', async () => {
const workflowHistoryStore = useWorkflowHistoryStore();
const rootStore = useRootStore();
const workflowId = 'workflow-123';
@@ -163,9 +163,15 @@ describe('Workflow history store', () => {
vi.mocked(whApi.getPublishTimeline).mockResolvedValue(events);
const result = await workflowHistoryStore.getPublishTimeline(workflowId);
const result = await workflowHistoryStore.getPublishTimeline(workflowId, {
take: 100,
skip: 0,
});
expect(whApi.getPublishTimeline).toHaveBeenCalledWith(rootStore.restApiContext, workflowId);
expect(whApi.getPublishTimeline).toHaveBeenCalledWith(rootStore.restApiContext, workflowId, {
take: 100,
skip: 0,
});
expect(result).toBe(events);
});
@@ -173,9 +179,9 @@ describe('Workflow history store', () => {
const workflowHistoryStore = useWorkflowHistoryStore();
vi.mocked(whApi.getPublishTimeline).mockRejectedValue(new Error('API Error'));
await expect(workflowHistoryStore.getPublishTimeline('workflow-123')).rejects.toThrow(
'API Error',
);
await expect(
workflowHistoryStore.getPublishTimeline('workflow-123', { take: 100, skip: 0 }),
).rejects.toThrow('API Error');
});
});
@@ -136,8 +136,11 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
await whApi.updateWorkflowHistoryVersion(rootStore.restApiContext, workflowId, versionId, data);
};
const getPublishTimeline = async (workflowId: string): Promise<PublishTimelineEvent[]> =>
await whApi.getPublishTimeline(rootStore.restApiContext, workflowId);
const getPublishTimeline = async (
workflowId: string,
queryParams: WorkflowHistoryRequestParams,
): Promise<PublishTimelineEvent[]> =>
await whApi.getPublishTimeline(rootStore.restApiContext, workflowId, queryParams);
const getVersionFirstAdoptionDate = async (version: {
major: number;