diff --git a/packages/@n8n/db/src/entities/execution-data.ts b/packages/@n8n/db/src/entities/execution-data.ts index 3134b2b107f42..7133a7d2a8133 100644 --- a/packages/@n8n/db/src/entities/execution-data.ts +++ b/packages/@n8n/db/src/entities/execution-data.ts @@ -1,11 +1,24 @@ -import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from '@n8n/typeorm'; -import { IWorkflowBase } from 'n8n-workflow'; +import { + BeforeInsert, + BeforeUpdate, + Column, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryColumn, + Relation, +} from '@n8n/typeorm'; +import { IWorkflowBase, UnexpectedError } from 'n8n-workflow'; import { JsonColumn } from './abstract-entity'; import { ExecutionEntity } from './execution-entity'; import { ISimplifiedPinData } from './types-db'; +import { WorkflowHistory } from './workflow-history'; import { idStringifier } from '../utils/transformers'; +type WorkflowData = Omit & { pinData?: ISimplifiedPinData }; + @Entity() export class ExecutionData { @Column('text') @@ -21,17 +34,51 @@ export class ExecutionData { * due to `INodeExecutionData`, so we use a simplified version so `QueryDeepPartialEntity` * can resolve and calls to `update`, `insert`, and `insert` pass typechecking. */ - @JsonColumn() - workflowData: Omit & { pinData?: ISimplifiedPinData }; + @JsonColumn({ nullable: true }) + workflowData: WorkflowData | null; + + @Column({ type: 'varchar', length: 36, nullable: true }) + workflowVersionId: string | null; + + @ManyToOne( + () => WorkflowHistory, + (wh) => wh.versionId, + { onDelete: 'SET NULL', nullable: true }, + ) + workflowHistory: (Omit & { workflow: WorkflowData }) | null; + + @BeforeInsert() + @BeforeUpdate() + validateRelations() { + if (this.workflowData === null && this.workflowVersionId === null) { + throw new Error('Either workflowData or workflowVersionId must be provided'); + } + } + + get workflow(): WorkflowData { + if (this.workflowData) { + return this.workflowData; + } + + if (this.workflowHistory === null) { + throw new UnexpectedError('ExecutionData invariant broken'); + } + + return { ...this.workflowHistory.workflow, ...this.workflowHistory }; + } @PrimaryColumn({ transformer: idStringifier }) executionId: string; - @OneToOne('ExecutionEntity', 'executionData', { - onDelete: 'CASCADE', - }) + @OneToOne( + () => ExecutionEntity, + (ee) => ee.executionData, + { + onDelete: 'CASCADE', + }, + ) @JoinColumn({ name: 'executionId', }) - execution: ExecutionEntity; + execution: Relation; } diff --git a/packages/@n8n/db/src/entities/shared-workflow.ts b/packages/@n8n/db/src/entities/shared-workflow.ts index 147454f60e516..d8648fc7f3ff3 100644 --- a/packages/@n8n/db/src/entities/shared-workflow.ts +++ b/packages/@n8n/db/src/entities/shared-workflow.ts @@ -1,5 +1,5 @@ import { WorkflowSharingRole } from '@n8n/permissions'; -import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import { Column, Entity, ManyToOne, PrimaryColumn, Relation } from '@n8n/typeorm'; import { WithTimestamps } from './abstract-entity'; import { Project } from './project'; @@ -10,8 +10,11 @@ export class SharedWorkflow extends WithTimestamps { @Column({ type: 'varchar' }) role: WorkflowSharingRole; - @ManyToOne('WorkflowEntity', 'shared') - workflow: WorkflowEntity; + @ManyToOne( + () => WorkflowEntity, + (we) => we.shared, + ) + workflow: Relation; @PrimaryColumn() workflowId: string; diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index 026a0efeb1274..b86bc4ef659ec 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -7,6 +7,7 @@ import { ManyToMany, ManyToOne, OneToMany, + Relation, } from '@n8n/typeorm'; import { Length } from 'class-validator'; import { IConnections, IDataObject, IWorkflowSettings, WorkflowFEMeta } from 'n8n-workflow'; @@ -15,11 +16,11 @@ import type { INode } from 'n8n-workflow'; import { JsonColumn, WithTimestampsAndStringId, dbType } from './abstract-entity'; import { type Folder } from './folder'; import type { SharedWorkflow } from './shared-workflow'; -import type { TagEntity } from './tag-entity'; +import { TagEntity } from './tag-entity'; import type { TestRun } from './test-run.ee'; import type { ISimplifiedPinData, IWorkflowDb } from './types-db'; import type { WorkflowStatistics } from './workflow-statistics'; -import type { WorkflowTagMapping } from './workflow-tag-mapping'; +import { WorkflowTagMapping } from './workflow-tag-mapping'; import { objectRetriever, sqlite } from '../utils/transformers'; @Entity() @@ -69,7 +70,7 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl }) meta?: WorkflowFEMeta; - @ManyToMany('TagEntity', 'workflows') + @ManyToMany(() => TagEntity, 'workflows') @JoinTable({ name: 'workflows_tags', // table name for the junction table of this relation joinColumn: { @@ -83,11 +84,11 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl }) tags?: TagEntity[]; - @OneToMany('WorkflowTagMapping', 'workflows') + @OneToMany(() => WorkflowTagMapping, 'workflows') tagMappings: WorkflowTagMapping[]; @OneToMany('SharedWorkflow', 'workflow') - shared: SharedWorkflow[]; + shared: Relation; @OneToMany('WorkflowStatistics', 'workflow') @JoinColumn({ referencedColumnName: 'workflow' }) diff --git a/packages/@n8n/db/src/entities/workflow-history.ts b/packages/@n8n/db/src/entities/workflow-history.ts index cc4aa169bcff7..dc0170d798e93 100644 --- a/packages/@n8n/db/src/entities/workflow-history.ts +++ b/packages/@n8n/db/src/entities/workflow-history.ts @@ -1,8 +1,9 @@ -import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import { Column, Entity, ManyToOne, OneToMany, PrimaryColumn, Relation } from '@n8n/typeorm'; import { IConnections } from 'n8n-workflow'; import type { INode } from 'n8n-workflow'; import { JsonColumn, WithTimestamps } from './abstract-entity'; +import type { ExecutionData } from './execution-data'; import { WorkflowEntity } from './workflow-entity'; @Entity() @@ -22,8 +23,11 @@ export class WorkflowHistory extends WithTimestamps { @Column() authors: string; - @ManyToOne('WorkflowEntity', { + @ManyToOne(() => WorkflowEntity, { onDelete: 'CASCADE', }) - workflow: WorkflowEntity; + workflow: Relation; + + @OneToMany('ExecutionData', 'workflowVersionId') + executionData: Relation; } diff --git a/packages/@n8n/db/src/entities/workflow-statistics.ts b/packages/@n8n/db/src/entities/workflow-statistics.ts index e189462d4221a..c8fab46968a9f 100644 --- a/packages/@n8n/db/src/entities/workflow-statistics.ts +++ b/packages/@n8n/db/src/entities/workflow-statistics.ts @@ -1,7 +1,7 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { DateTimeColumn } from './abstract-entity'; -import { StatisticsNames } from './types-db'; +import type { StatisticsNames } from './types-db'; import { WorkflowEntity } from './workflow-entity'; @Entity() @@ -18,7 +18,7 @@ export class WorkflowStatistics { @PrimaryColumn({ length: 128 }) name: StatisticsNames; - @ManyToOne('WorkflowEntity', 'shared') + @ManyToOne(() => WorkflowEntity, 'shared') workflow: WorkflowEntity; @PrimaryColumn() diff --git a/packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts b/packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts new file mode 100644 index 0000000000000..a79d47c91b1fc --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1762858574621-AddWorkflowVersionIdToExecutionData.ts @@ -0,0 +1,11 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +export class AddWorkflowVersionIdToExecutionData1762858574621 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('execution_data', [column('workflowVersionId').varchar()]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('execution_data', ['workflowVersionId']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index 7d6f4983aa8f3..abdda0b68cfbc 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -112,6 +112,7 @@ import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-Create import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; export const mysqlMigrations: Migration[] = [ @@ -229,4 +230,5 @@ export const mysqlMigrations: Migration[] = [ CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, + AddWorkflowVersionIdToExecutionData1762858574621, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 7ce461953b32a..e4067fa29be3f 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -111,6 +111,7 @@ import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-D import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; import { ChangeDefaultForIdInUserTable1762771264000 } from './1762771264000-ChangeDefaultForIdInUserTable'; @@ -229,4 +230,5 @@ export const postgresMigrations: Migration[] = [ BackfillMissingWorkflowHistoryRecords1762763704614, ChangeDefaultForIdInUserTable1762771264000, AddWorkflowHistoryAutoSaveFields1762847206508, + AddWorkflowVersionIdToExecutionData1762858574621, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 152476e5bfaa0..f76956b208b0c 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -108,6 +108,7 @@ import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-D import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -221,6 +222,7 @@ const sqliteMigrations: Migration[] = [ CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, AddWorkflowHistoryAutoSaveFields1762847206508, + AddWorkflowVersionIdToExecutionData1762858574621, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/execution-data.repository.ts b/packages/@n8n/db/src/repositories/execution-data.repository.ts index 1af6a838a3e42..fdb8ddcc0ca95 100644 --- a/packages/@n8n/db/src/repositories/execution-data.repository.ts +++ b/packages/@n8n/db/src/repositories/execution-data.repository.ts @@ -2,6 +2,9 @@ import { Service } from '@n8n/di'; import { DataSource, In, Repository } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import { IWorkflowBase } from 'n8n-workflow'; + +import { ISimplifiedPinData } from 'entities/types-db'; import { ExecutionData } from '../entities'; @@ -18,12 +21,18 @@ export class ExecutionDataRepository extends Repository { return await transactionManager.insert(ExecutionData, data); } - async findByExecutionIds(executionIds: string[]) { + async findByExecutionIds(executionIds: string[]): Promise< + Array< + Omit & { + pinData?: ISimplifiedPinData; + } + > + > { return await this.find({ - select: ['workflowData'], + select: ['workflowData', 'workflowHistory'], where: { executionId: In(executionIds), }, - }).then((executionData) => executionData.map(({ workflowData }) => workflowData)); + }).then((executionData) => executionData.map(({ workflow }) => workflow)); } } diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 7a9b181560c55..7b04771b6a27e 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -330,7 +330,7 @@ export class ExecutionRepository extends Repository { this.errorReporter.error('Found successful execution where data is empty stringified array', { extra: { executionId: execution.id, - workflowId: executionData?.workflowData.id, + workflowId: executionData?.workflow.id, }, }); } @@ -364,7 +364,12 @@ export class ExecutionRepository extends Repository { // In the non-pooling sqlite driver we can't use transactions, because that creates nested transactions under highly concurrent loads, leading to errors in the database const { identifiers: inserted } = await this.insert({ ...rest, createdAt: new Date() }); const { id: executionId } = inserted[0] as { id: string }; - await this.executionDataRepository.insert({ executionId, workflowData, data }); + await this.executionDataRepository.insert({ + executionId, + workflowData, + data, + workflowVersionId: currentWorkflow.versionId, + }); return String(executionId); } else { // All other database drivers should create executions and execution-data atomically @@ -375,7 +380,7 @@ export class ExecutionRepository extends Repository { }); const { id: executionId } = inserted[0] as { id: string }; await this.executionDataRepository.createExecutionDataForExecution( - { executionId, workflowData, data }, + { executionId, workflowData, data, workflowVersionId: currentWorkflow.versionId }, transactionManager, ); return String(executionId); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts index e47972b9ab54a..b860ebeee7b8c 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.entity.ts @@ -47,7 +47,7 @@ export class ChatHubAgent extends WithTimestamps { /** * The selected credential to use by default with the selected LLM provider (if applicable). */ - @ManyToOne('CredentialsEntity', { onDelete: 'SET NULL', nullable: true }) + @ManyToOne(() => CredentialsEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'credentialId' }) credential?: CredentialsEntity | null; diff --git a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts index e66830d698893..e6dead4900885 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -10,7 +10,7 @@ import { PrimaryGeneratedColumn, } from '@n8n/typeorm'; -import type { ChatHubSession } from './chat-hub-session.entity'; +import { ChatHubSession } from './chat-hub-session.entity'; @Entity({ name: 'chat_hub_messages' }) export class ChatHubMessage extends WithTimestamps { @@ -26,7 +26,11 @@ export class ChatHubMessage extends WithTimestamps { /** * The chat session/conversation this message belongs to. */ - @ManyToOne('ChatHubSession', 'messages', { onDelete: 'CASCADE' }) + @ManyToOne( + () => ChatHubSession, + (chatHubSession) => chatHubSession.messages, + { onDelete: 'CASCADE' }, + ) @JoinColumn({ name: 'sessionId' }) session: Relation; @@ -72,7 +76,7 @@ export class ChatHubMessage extends WithTimestamps { /** * Custom n8n agent workflow that produced this message (if applicable). */ - @ManyToOne('WorkflowEntity', { onDelete: 'SET NULL', nullable: true }) + @ManyToOne(() => WorkflowEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'workflowId' }) workflow?: Relation | null; @@ -92,7 +96,7 @@ export class ChatHubMessage extends WithTimestamps { /** * Execution that produced this message (reset to null when the execution is deleted) */ - @ManyToOne('ExecutionEntity', { onDelete: 'SET NULL', nullable: true }) + @ManyToOne(() => ExecutionEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'executionId' }) execution?: Relation | null; @@ -105,18 +109,25 @@ export class ChatHubMessage extends WithTimestamps { /** * The previous message this message is a response to, NULL on the initial message. */ - @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.responses, { - onDelete: 'CASCADE', - nullable: true, - }) + @ManyToOne( + () => ChatHubMessage, + (m: ChatHubMessage) => m.responses, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) @JoinColumn({ name: 'previousMessageId' }) previousMessage?: Relation | null; /** * Messages that are responses to this message. This could branch out to multiple threads. */ - @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.previousMessage) - responses?: Array>; + @OneToMany( + () => ChatHubMessage, + (m: ChatHubMessage) => m.previousMessage, + ) + responses?: Relation; /** * ID of the message that this message is a retry of (if applicable). @@ -127,18 +138,25 @@ export class ChatHubMessage extends WithTimestamps { /** * The message that this message is a retry of (if applicable). */ - @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.retries, { - onDelete: 'CASCADE', - nullable: true, - }) + @ManyToOne( + () => ChatHubMessage, + (m: ChatHubMessage) => m.retries, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) @JoinColumn({ name: 'retryOfMessageId' }) retryOfMessage?: Relation | null; /** * All messages that are retries of this message (if applicable). */ - @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.retryOfMessage) - retries?: Array>; + @OneToMany( + () => ChatHubMessage, + (m: ChatHubMessage) => m.retryOfMessage, + ) + retries?: Relation; /** * ID of the message that this message is a revision/edit of (if applicable). @@ -149,18 +167,25 @@ export class ChatHubMessage extends WithTimestamps { /** * The message that this message is a revision/edit of (if applicable). */ - @ManyToOne('ChatHubMessage', (m: ChatHubMessage) => m.revisions, { - onDelete: 'CASCADE', - nullable: true, - }) + @ManyToOne( + () => ChatHubMessage, + (m: ChatHubMessage) => m.revisions, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) @JoinColumn({ name: 'revisionOfMessageId' }) revisionOfMessage?: Relation | null; /** * All messages that are revisions/edits of this message (if applicable). */ - @OneToMany('ChatHubMessage', (m: ChatHubMessage) => m.revisionOfMessage) - revisions?: Array>; + @OneToMany( + () => ChatHubMessage, + (m: ChatHubMessage) => m.revisionOfMessage, + ) + revisions?: Relation; /** * Status of the message, e.g. 'running', 'success', 'error', 'cancelled'. diff --git a/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts index a3d12030cc21f..bd9c923ce6456 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-session.entity.ts @@ -33,7 +33,7 @@ export class ChatHubSession extends WithTimestamps { /** * The user that owns this chat session. */ - @ManyToOne('User', { onDelete: 'CASCADE' }) + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'ownerId' }) owner?: Relation; @@ -53,7 +53,7 @@ export class ChatHubSession extends WithTimestamps { /** * The selected credential to use by default with the selected LLM provider (if applicable). */ - @ManyToOne('CredentialsEntity', { onDelete: 'SET NULL', nullable: true }) + @ManyToOne(() => CredentialsEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'credentialId' }) credential?: Relation | null; @@ -78,7 +78,7 @@ export class ChatHubSession extends WithTimestamps { /** * Custom n8n agent workflow to use (if applicable) */ - @ManyToOne('WorkflowEntity', { onDelete: 'SET NULL', nullable: true }) + @ManyToOne(() => WorkflowEntity, { onDelete: 'SET NULL', nullable: true }) @JoinColumn({ name: 'workflowId' }) workflow?: Relation | null; @@ -101,5 +101,5 @@ export class ChatHubSession extends WithTimestamps { * All messages that belong to this chat session. */ @OneToMany('ChatHubMessage', 'session') - messages?: Array>; + messages?: Relation; }