From b28adf97895bbdba2e0c777bdfb5cd11d04c0e56 Mon Sep 17 00:00:00 2001 From: Nour Alhadi Mahmoud Date: Thu, 8 Oct 2026 07:14:26 +0000 Subject: [PATCH] fix: Load only the publish history that the editor shows (#40183) Co-authored-by: Claude Opus 5.5 --- packages/@n8n/api-types/src/dto/index.ts | 4 + .../workflow-publish-timeline-query.dto.ts | 11 + .../shared-workflow.repository.test.ts | 34 ++ packages/@n8n/db/src/repositories/index.ts | 5 +- .../shared-workflow.repository.ts | 10 +- .../workflow-publish-history.repository.ts | 60 ++- .../__tests__/execute-workflow.tool.test.ts | 6 + .../get-workflow-details.tool.test.ts | 6 +- .../__tests__/publish-workflow.tool.test.ts | 2 + .../mcp/tools/execute-workflow.tool.ts | 2 +- .../mcp/tools/get-workflow-details.tool.ts | 7 +- .../mcp/tools/publish-workflow.tool.ts | 1 + .../mcp/tools/workflow-history.utils.ts | 2 +- .../mcp/tools/workflow-validation.utils.ts | 3 +- .../workflows.public.controller.ts | 4 +- .../__tests__/workflow-finder.service.test.ts | 43 ++- .../src/workflows/workflow-finder.service.ts | 11 +- .../workflow-history.service.test.ts | 31 +- .../workflow-history.controller.ts | 18 +- .../workflow-history.service.ts | 43 ++- .../cli/src/workflows/workflow.service.ts | 11 +- .../cli/src/workflows/workflows.controller.ts | 3 + ...orkflow-publish-history.repository.test.ts | 69 ++++ .../integration/public-api/workflows.test.ts | 16 + .../integration/workflow-history.api.test.ts | 202 +++++++--- .../workflows/workflow.service.test.ts | 1 + .../workflows/workflows.controller.test.ts | 10 +- .../src/api/workflowHistory.ts | 2 + .../WorkflowPublishTimelineContent.test.ts | 349 +++++++++++++++++- .../WorkflowPublishTimelineContent.vue | 109 +++++- .../views/WorkflowHistory.test.ts | 19 +- .../workflowHistory/views/WorkflowHistory.vue | 2 + .../workflowHistory.store.test.ts | 18 +- .../workflowHistory/workflowHistory.store.ts | 7 +- 34 files changed, 981 insertions(+), 140 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/workflow-history/workflow-publish-timeline-query.dto.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 68718d0bd17..69b7df68fb6 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/dto/workflow-history/workflow-publish-timeline-query.dto.ts b/packages/@n8n/api-types/src/dto/workflow-history/workflow-publish-timeline-query.dto.ts new file mode 100644 index 00000000000..43789b1deed --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflow-history/workflow-publish-timeline-query.dto.ts @@ -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), +}) {} diff --git a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts index 9d8920aa169..a87b2f036f8 100644 --- a/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/shared-workflow.repository.test.ts @@ -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({ 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({ 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(); trx.findOne.mockResolvedValueOnce( @@ -320,6 +353,7 @@ describe('SharedWorkflowRepository', () => { expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith( 'workflow-1', 'version-1', + 'all', trx, ); }); diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 8b4541ace73..8206abfb3b6 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -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, diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 1f3503832f5..cbc79236e12 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -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 { 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 { includeTags = false, includeParentFolder = false, includeActiveVersion = false, + publishHistory = 'all', ctx = {}, em = this.managerFor(ctx), } = options; @@ -376,11 +381,12 @@ export class SharedWorkflowRepository extends BaseRepository { }); const activeVersion = sharedWorkflow?.workflow.activeVersion; - if (activeVersion) { + if (activeVersion && publishHistory !== 'none') { activeVersion.workflowPublishHistory = await this.workflowPublishHistoryRepository.findByVersion( workflowId, activeVersion.versionId, + publishHistory, em, ); } diff --git a/packages/@n8n/db/src/repositories/workflow-publish-history.repository.ts b/packages/@n8n/db/src/repositories/workflow-publish-history.repository.ts index cd45d8345a3..022fa05e2ab 100644 --- a/packages/@n8n/db/src/repositories/workflow-publish-history.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-publish-history.repository.ts @@ -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 { @@ -39,15 +42,64 @@ export class WorkflowPublishHistoryRepository extends BaseRepository = '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 { const record = await this.findOne({ select: ['userId'], diff --git a/packages/cli/src/modules/mcp/__tests__/execute-workflow.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/execute-workflow.tool.test.ts index a55ade09000..1be83a5c56c 100644 --- a/packages/cli/src/modules/mcp/__tests__/execute-workflow.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/execute-workflow.tool.test.ts @@ -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 () => { diff --git a/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts index 393f910aea9..3c26eb94474 100644 --- a/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/get-workflow-details.tool.test.ts @@ -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(); diff --git a/packages/cli/src/modules/mcp/__tests__/publish-workflow.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/publish-workflow.tool.test.ts index 6a793f57dad..f4d5ea4ff4c 100644 --- a/packages/cli/src/modules/mcp/__tests__/publish-workflow.tool.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/publish-workflow.tool.test.ts @@ -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', }); }); }); diff --git a/packages/cli/src/modules/mcp/tools/execute-workflow.tool.ts b/packages/cli/src/modules/mcp/tools/execute-workflow.tool.ts index 2fa9c62f86a..a5045db165b 100644 --- a/packages/cli/src/modules/mcp/tools/execute-workflow.tool.ts +++ b/packages/cli/src/modules/mcp/tools/execute-workflow.tool.ts @@ -198,7 +198,7 @@ export const executeWorkflow = async ( user, ['workflow:execute'], workflowFinderService, - { includeActiveVersion: true }, + { includeActiveVersion: true, publishHistory: 'none' }, ); const runData = await buildRunData( workflow, diff --git a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts index 06d20ed90ce..c43368c49aa 100644 --- a/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts +++ b/packages/cli/src/modules/mcp/tools/get-workflow-details.tool.ts @@ -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 diff --git a/packages/cli/src/modules/mcp/tools/publish-workflow.tool.ts b/packages/cli/src/modules/mcp/tools/publish-workflow.tool.ts index c327a818bc2..359ec17d4fd 100644 --- a/packages/cli/src/modules/mcp/tools/publish-workflow.tool.ts +++ b/packages/cli/src/modules/mcp/tools/publish-workflow.tool.ts @@ -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(() => {}); diff --git a/packages/cli/src/modules/mcp/tools/workflow-history.utils.ts b/packages/cli/src/modules/mcp/tools/workflow-history.utils.ts index 62d073bcd2e..e8853be563d 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-history.utils.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-history.utils.ts @@ -18,7 +18,7 @@ export async function getMcpWorkflowVersion( ): Promise { try { return await workflowHistoryService.getVersion(user, workflowId, versionId, { - includePublishHistory: false, + publishHistory: 'none', }); } catch (error) { if (error instanceof WorkflowHistoryVersionNotFoundError) { diff --git a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts index d132f4159e1..8199a538604 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-validation.utils.ts @@ -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. */ diff --git a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts index 21536ac1560..b1edbad9caa 100644 --- a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts @@ -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', { diff --git a/packages/cli/src/workflows/__tests__/workflow-finder.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-finder.service.test.ts index 458b7bd8230..0977afab2aa 100644 --- a/packages/cli/src/workflows/__tests__/workflow-finder.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-finder.service.test.ts @@ -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(); - 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() } : { ctx: mock() }; + 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 () => { diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index 9ddf2979f8f..df7b27f0be7 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -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 }), }); diff --git a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts index 47ed1f0b5b2..29e891d299b 100644 --- a/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts +++ b/packages/cli/src/workflows/workflow-history/__tests__/workflow-history.service.test.ts @@ -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, + }); }); }); diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts index e7e866f5920..6cb7cf1cef2 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.controller.ts @@ -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'); diff --git a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts index a727bf2dd97..84a09ef1661 100644 --- a/packages/cli/src/workflows/workflow-history/workflow-history.service.ts +++ b/packages/cli/src/workflows/workflow-history/workflow-history.service.ts @@ -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 { 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, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index a96114b6c72..a13ceaafa0a 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -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 { 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) { diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 76f9739bc7d..07bfd4af03d 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -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); diff --git a/packages/cli/test/integration/database/repositories/workflow-publish-history.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow-publish-history.repository.test.ts index d5609c0161c..12ad37b8543 100644 --- a/packages/cli/test/integration/database/repositories/workflow-publish-history.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow-publish-history.repository.test.ts @@ -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(); diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 14485abc526..0d5ad18cc77 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -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', () => { diff --git a/packages/cli/test/integration/workflow-history.api.test.ts b/packages/cli/test/integration/workflow-history.api.test.ts index b3642c52af6..fb2c354ded9 100644 --- a/packages/cli/test/integration/workflow-history.api.test.ts +++ b/packages/cli/test/integration/workflow-history.api.test.ts @@ -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' }); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index d451c48d059..44e9168de65 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -371,6 +371,7 @@ describe('update()', () => { expect(activateSpy).toHaveBeenCalledWith(owner, workflow.id, { versionId: workflow.activeVersionId, source: 'ui', + publishHistory: 'none', }); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 1f7d169923e..1575a77a2d5 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -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' }), ]); }); diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts index 139fe6a1080..b71a9ee7cee 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts @@ -92,10 +92,12 @@ export type { PublishTimelineEvent } from '@n8n/api-types'; export const getPublishTimeline = async ( context: IRestApiContext, workflowId: string, + queryParams: WorkflowHistoryRequestParams, ): Promise => { const { data } = await get( context.baseUrl, `/workflow-history/workflow/${workflowId}/publish-timeline`, + queryParams, ); return data; }; diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.test.ts index 53ae454d412..540272aca9e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.test.ts @@ -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(); + 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(); + 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(); + 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(); + const newPage = createDeferredPromise(); + const oldAdoption = createDeferredPromise(); + 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(); + const newPage = createDeferredPromise(); + 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); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.vue b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.vue index 0f7f4a726a4..14f3ce14615 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.vue +++ b/packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowPublishTimelineContent.vue @@ -1,9 +1,11 @@