refactor(core): Extract Workflow Builder session persistence into a backend module (#41075)

This commit is contained in:
Charlie Kolb 2026-10-15 10:56:13 +00:00 committed by GitHub
parent e45a65a9bf
commit cdeb405948
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 438 additions and 13 deletions

2
OWNERS
View File

@ -118,7 +118,7 @@ packages/cli/src/modules/data-table/ @n8n-io/knowledge
packages/cli/src/evaluation.ee/ @n8n-io/ai-trust
packages/cli/src/chat/ @n8n-io/ai
packages/cli/src/tool-generation/ @n8n-io/ai
packages/cli/src/modules/workflow-builder/ @n8n-io/ai-assistant
packages/modules/workflow-builder/ @n8n-io/ai-assistant
packages/cli/src/modules/mcp/ @n8n-io/context
packages/modules/quick-connect/ @n8n-io/ai
packages/cli/src/modules/chat-hub/ @n8n-io/ai-assistant

View File

@ -159,6 +159,7 @@
"@n8n/backend-common": "workspace:*",
"@n8n/backend-module-insights": "workspace:*",
"@n8n/backend-module-quick-connect": "workspace:*",
"@n8n/backend-module-workflow-builder": "workspace:*",
"@n8n/backend-network": "workspace:*",
"@n8n/backend-services": "workspace:*",
"@n8n/blob-storage": "workspace:*",

View File

@ -0,0 +1,21 @@
import { WorkflowBuilderSession } from '@n8n/backend-module-workflow-builder';
import { ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { packagedModules } from '../modules.manifest';
describe('packaged Workflow Builder', () => {
it('loads the package entrypoint through the CLI manifest', async () => {
const metadata = Container.get(ModuleMetadata);
expect(metadata.get('workflow-builder')).toBeUndefined();
const load = packagedModules['workflow-builder'];
expect(load).toBeTypeOf('function');
await load?.();
const entry = metadata.get('workflow-builder');
expect(entry?.instanceTypes).toEqual(['main']);
const module = entry && Container.get(entry.class);
expect(await module?.entities?.()).toEqual([WorkflowBuilderSession]);
});
});

View File

@ -7,4 +7,5 @@ import type { PackagedModules } from '@n8n/backend-common';
export const packagedModules: PackagedModules = {
insights: async () => await import('@n8n/backend-module-insights/module'),
'quick-connect': async () => await import('@n8n/backend-module-quick-connect/module'),
'workflow-builder': async () => await import('@n8n/backend-module-workflow-builder/module'),
};

View File

@ -1,6 +1,7 @@
import type { Mock, MockedClass } from 'vitest';
import { AiWorkflowBuilderService } from '@n8n/ai-workflow-builder';
import type { Logger } from '@n8n/backend-common';
import { WorkflowBuilderSessionRepository } from '@n8n/backend-module-workflow-builder';
import type { HttpTransport, OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import type { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
@ -14,7 +15,6 @@ import { join } from 'node:path';
import type { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { WorkflowBuilderSessionRepository } from '@/modules/workflow-builder';
import type { Push } from '@/push';
import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
@ -138,6 +138,14 @@ describe('WorkflowBuilderService', () => {
});
describe('constructor', () => {
it('uses the public repository constructor for dependency injection', () => {
const dependencies: unknown[] = Reflect.getMetadata(
'design:paramtypes',
WorkflowBuilderService,
);
expect(dependencies).toContain(WorkflowBuilderSessionRepository);
});
it('should initialize without creating the service immediately', () => {
expect(MockedAiWorkflowBuilderService).not.toHaveBeenCalled();
});

View File

@ -5,6 +5,7 @@ import {
ChatPayload,
} from '@n8n/ai-workflow-builder';
import { Logger } from '@n8n/backend-common';
import { WorkflowBuilderSessionRepository } from '@n8n/backend-module-workflow-builder';
import { OutboundHttp, SsrfProtectionService } from '@n8n/backend-network';
import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import { BUILTIN_NODES_PACKAGES } from '@n8n/constants';
@ -24,7 +25,6 @@ import * as path from 'path';
import { N8N_VERSION } from '@/constants';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { WorkflowBuilderSessionRepository } from '@/modules/workflow-builder';
import { Push } from '@/push';
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
import { UrlService } from '@n8n/backend-services';

View File

@ -42,6 +42,7 @@
{ "path": "../@n8n/backend-services/tsconfig.build.json" },
{ "path": "../modules/insights/backend/tsconfig.build.json" },
{ "path": "../modules/quick-connect/backend/tsconfig.build.json" },
{ "path": "../modules/workflow-builder/backend/tsconfig.build.json" },
{ "path": "../@n8n/backend-test-utils/tsconfig.build.json" },
{ "path": "../@n8n/blob-storage/tsconfig.build.json" },
{ "path": "../@n8n/di/tsconfig.build.json" },

View File

@ -30,6 +30,14 @@ const alias = {
__dirname,
'../modules/insights/backend/src/index.ts',
),
'@n8n/backend-module-workflow-builder/module': path.resolve(
__dirname,
'../modules/workflow-builder/backend/src/workflow-builder.module.ts',
),
'@n8n/backend-module-workflow-builder': path.resolve(
__dirname,
'../modules/workflow-builder/backend/src/index.ts',
),
'@n8n/mcp-apps/server': path.resolve(__dirname, '../@n8n/mcp-apps/src/server/index.ts'),
'@n8n/telemetry': path.resolve(__dirname, '../@n8n/telemetry/src'),
'@': path.resolve(__dirname, 'src'),

View File

@ -0,0 +1,17 @@
# Workflow Builder backend
This package stores Workflow Builder sessions. It owns the session entity and repository.
Import `WorkflowBuilderSession`, `IWorkflowBuilderSession`, and
`WorkflowBuilderSessionRepository` from `@n8n/backend-module-workflow-builder`.
The public API does not register the backend module.
The CLI manifest loads `@n8n/backend-module-workflow-builder/module` on demand.
The module registers its entity before the database starts. Migrations stay in `@n8n/db`.
Run `pnpm build > build.log 2>&1` before you use the package from the CLI. The build writes JavaScript and
declarations to `dist`. It excludes tests.
Run `pnpm test --maxWorkers=1` for unit tests and SQLite persistence tests.
Run `pnpm test:postgres --maxWorkers=1` for persistence tests with the shared PostgreSQL harness.
The PostgreSQL harness needs Docker.

View File

@ -0,0 +1,8 @@
import { backendConfig } from '@n8n/oxlint-config/backend';
import { defineConfig } from 'oxlint';
export default defineConfig({
extends: [backendConfig],
options: { typeAware: true },
ignorePatterns: ['coverage/**', 'dist/**'],
});

View File

@ -0,0 +1,71 @@
{
"name": "@n8n/backend-module-workflow-builder",
"version": "0.1.0",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
},
"./module": {
"types": "./dist/workflow-builder.module.d.ts",
"import": "./dist/workflow-builder.module.js",
"require": "./dist/workflow-builder.module.js"
}
},
"files": [
"dist/**/*"
],
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"build:unchecked": "tsc -p tsconfig.build.json --noCheck",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "oxlint --type-aware . --quiet",
"lint:fix": "oxlint --type-aware . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --silent=false",
"test:postgres": "N8N_LOG_LEVEL=silent vitest run --config vitest.config.postgres.ts"
},
"dependencies": {
"@n8n/ai-workflow-builder": "workspace:*",
"@n8n/db": "workspace:*",
"@n8n/decorators": "workspace:*",
"@n8n/di": "workspace:*",
"@n8n/typeorm": "workspace:*"
},
"peerDependencies": {
"@langchain/core": "catalog:"
},
"devDependencies": {
"@langchain/core": "catalog:",
"@n8n/backend-common": "workspace:*",
"@n8n/backend-test-utils": "workspace:*",
"@n8n/oxlint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"n8n-containers": "workspace:*",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"typescript": "catalog:typescript",
"vite": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
"license": "LicenseRef-n8n-sustainable-use",
"typesVersions": {
"*": {
"module": [
"dist/workflow-builder.module.d.ts"
]
}
}
}

View File

@ -0,0 +1,113 @@
import { AIMessage, HumanMessage } from '@langchain/core/messages';
import { ModuleRegistry } from '@n8n/backend-common';
import { createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
import { DbConnection, User, UserRepository, WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { WorkflowBuilderSession, WorkflowBuilderSessionRepository } from '../index';
let repository: WorkflowBuilderSessionRepository;
let dataSource: DataSource;
beforeAll(async () => {
await testModules.loadModules(['workflow-builder'], {
'workflow-builder': async () => await import('../workflow-builder.module.js'),
});
expect(Container.get(ModuleRegistry).entities).toContain(WorkflowBuilderSession);
expect(Container.get(DbConnection).connectionState.connected).toBe(false);
await testDb.init();
dataSource = Container.get(DataSource);
repository = Container.get(WorkflowBuilderSessionRepository);
});
beforeEach(async () => {
await repository.delete({});
});
afterAll(async () => {
await testDb.terminate();
});
async function createSessionOwner() {
const user = await Container.get(UserRepository).save({ role: { slug: 'global:owner' } });
const workflow = await createWorkflow();
return { user, workflow, threadId: `workflow-${workflow.id}-user-${user.id}` };
}
describe('packaged session persistence', () => {
it('resolves relations to the shared database entities', () => {
const metadata = dataSource.getMetadata(WorkflowBuilderSession);
expect(metadata.tableNameWithoutPrefix).toBe('workflow_builder_session');
expect(metadata.findRelationWithPropertyPath('workflow')?.inverseEntityMetadata.target).toBe(
WorkflowEntity,
);
expect(metadata.findRelationWithPropertyPath('user')?.inverseEntityMetadata.target).toBe(User);
});
it('saves, restores, and updates one session for both thread formats', async () => {
const { user, workflow, threadId } = await createSessionOwner();
const messages = [new HumanMessage('Build a workflow'), new AIMessage('Workflow ready')];
await repository.saveSession(threadId, {
messages,
previousSummary: 'Workflow summary',
updatedAt: new Date(),
activeVersionCardId: 'version-card',
resumeAfterRestoreMessageId: 'resume-message',
});
expect(await repository.getSession(`${threadId}-code`)).toEqual({
messages,
previousSummary: 'Workflow summary',
updatedAt: expect.any(Date),
activeVersionCardId: 'version-card',
resumeAfterRestoreMessageId: 'resume-message',
});
const original = await repository.findOneByOrFail({ workflowId: workflow.id, userId: user.id });
expect(original.id).toMatch(/^[0-9a-f-]{36}$/);
expect(original.createdAt).toBeInstanceOf(Date);
expect(original.updatedAt).toBeInstanceOf(Date);
expect(original.messages.map((message) => message.type)).toEqual(['human', 'ai']);
await repository.saveSession(`${threadId}-code`, {
messages: [new HumanMessage('Update the workflow')],
updatedAt: new Date(),
});
expect(await repository.count()).toBe(1);
expect(await repository.findOneByOrFail({ id: original.id })).toMatchObject({
createdAt: original.createdAt,
previousSummary: null,
activeVersionCardId: null,
resumeAfterRestoreMessageId: null,
});
expect(await repository.getSession(threadId)).toMatchObject({
messages: [new HumanMessage('Update the workflow')],
previousSummary: undefined,
});
await repository.deleteSession(`${threadId}-code`);
expect(await repository.getSession(threadId)).toBeNull();
});
it('generates an ID and enforces the workflow and user uniqueness rule', async () => {
const { user, workflow } = await createSessionOwner();
const values = { workflowId: workflow.id, userId: user.id, messages: [] };
const entity = await repository.save(repository.create(values));
expect(entity.id).toMatch(/^[0-9a-f-]{36}$/);
await expect(repository.save(repository.create(values))).rejects.toThrow(/unique/i);
});
it.each(['workflow', 'user'] as const)(
'removes sessions when the %s is removed',
async (owner) => {
const { user, workflow, threadId } = await createSessionOwner();
await repository.saveSession(threadId, { messages: [], updatedAt: new Date() });
if (owner === 'workflow') {
await Container.get(WorkflowRepository).delete(workflow.id);
} else {
await Container.get(UserRepository).delete(user.id);
}
expect(await repository.getSession(threadId)).toBeNull();
},
);
});

View File

@ -1,7 +1,7 @@
import { AIMessage, HumanMessage } from '@langchain/core/messages';
import { mockClear } from 'vitest-mock-extended';
import { mockEntityManager } from '@test/mocking';
import { mockInstance } from '@n8n/backend-test-utils';
import { DataSource, EntityManager, type EntityMetadata } from '@n8n/typeorm';
import { mock, mockClear } from 'vitest-mock-extended';
import { WorkflowBuilderSession } from '../workflow-builder-session.entity';
@ -25,7 +25,12 @@ vi.mock('@n8n/ai-workflow-builder', () => ({
import { WorkflowBuilderSessionRepository } from '../workflow-builder-session.repository';
describe('WorkflowBuilderSessionRepository', () => {
const entityManager = mockEntityManager(WorkflowBuilderSession);
const entityManager = mockInstance(EntityManager);
const dataSource = mockInstance(DataSource, {
manager: entityManager,
getMetadata: () => mock<EntityMetadata>({ target: WorkflowBuilderSession }),
});
Object.assign(entityManager, { connection: dataSource });
let repository: WorkflowBuilderSessionRepository;
const mockExecute = vi.fn().mockResolvedValue(undefined);
@ -46,11 +51,7 @@ describe('WorkflowBuilderSessionRepository', () => {
mockQueryBuilder.values.mockClear().mockReturnThis();
mockQueryBuilder.orUpdate.mockClear().mockReturnThis();
// Create repository with mocked data source
const mockDataSource = {
manager: entityManager,
};
repository = new WorkflowBuilderSessionRepository(mockDataSource as never);
repository = new WorkflowBuilderSessionRepository(dataSource);
vi.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQueryBuilder as never);
});

View File

@ -0,0 +1,15 @@
import { ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { WorkflowBuilderSession } from '../index';
import { WorkflowBuilderModule } from '../workflow-builder.module';
describe('WorkflowBuilderModule', () => {
it('registers the main instance entrypoint with the public entity', async () => {
expect(Container.get(ModuleMetadata).get('workflow-builder')).toMatchObject({
class: WorkflowBuilderModule,
instanceTypes: ['main'],
});
expect(await new WorkflowBuilderModule().entities()).toEqual([WorkflowBuilderSession]);
});
});

View File

@ -0,0 +1,17 @@
import { ModuleMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
describe('public API', () => {
it('does not register the module entrypoint', async () => {
const metadata = Container.get(ModuleMetadata);
expect(metadata.get('workflow-builder')).toBeUndefined();
const publicApi = await import('./index.js');
expect(metadata.get('workflow-builder')).toBeUndefined();
expect(Object.keys(publicApi).sort()).toEqual([
'WorkflowBuilderSession',
'WorkflowBuilderSessionRepository',
]);
});
});

View File

@ -3,4 +3,3 @@ export {
type IWorkflowBuilderSession,
} from './workflow-builder-session.entity';
export { WorkflowBuilderSessionRepository } from './workflow-builder-session.repository';
export { WorkflowBuilderModule } from './workflow-builder.module';

View File

@ -0,0 +1,6 @@
import './setup-test-folder';
import { createTestDatabaseGlobalSetup } from 'n8n-containers/test-db-global-setup';
// Run migrations after the suite registers this package's entity.
export const { setup, teardown } = createTestDatabaseGlobalSetup();

View File

@ -0,0 +1,19 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
process.env.N8N_ENCRYPTION_KEY = 'test_key';
const baseDir = join(tmpdir(), 'n8n-workflow-builder-tests');
mkdirSync(baseDir, { recursive: true });
const testDir = mkdtempSync(join(baseDir, 'run-'));
const settingsDir = join(testDir, '.n8n');
mkdirSync(settingsDir);
process.env.N8N_USER_FOLDER = testDir;
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true';
writeFileSync(join(settingsDir, 'config'), JSON.stringify({ encryptionKey: 'test_key' }), {
encoding: 'utf-8',
mode: 0o600,
});

View File

@ -0,0 +1,10 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": [
"@n8n/typescript-config/tsconfig.common.go.json",
"@n8n/typescript-config/tsconfig.backend.go.json"
],
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"lib": ["es2023", "dom"],
"types": ["node", "vitest/globals"],
"strict": true,
"strictFunctionTypes": false,
"strictPropertyInitialization": false,
"useUnknownInCatchVariables": false
},
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config*.ts"]
}

View File

@ -0,0 +1,12 @@
import baseConfig from './vitest.config';
export default {
...baseConfig,
test: {
...baseConfig.test,
include: ['src/**/*.integration.test.ts'],
testTimeout: 30_000,
hookTimeout: 30_000,
globalSetup: ['./test/setup-postgres.ts'],
},
};

View File

@ -0,0 +1,19 @@
import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators';
import { tscDecoratorTransform } from '@n8n/vitest-config/tsc-decorator-transform';
import { mergeConfig } from 'vite';
import { configDefaults } from 'vitest/config';
export default mergeConfig(
createVitestConfigWithDecorators({}, { pinCjs: ['@n8n/backend-common', '@n8n/decorators'] }),
{
plugins: [
tscDecoratorTransform({ filePredicate: (fileName) => /\.entity\.ts$/.test(fileName) }),
],
test: {
pool: 'forks',
setupFiles: ['./test/setup-test-folder.ts'],
exclude: [...configDefaults.exclude, '**/dist/**'],
hookTimeout: 30_000,
},
},
);

View File

@ -4835,6 +4835,9 @@ importers:
'@n8n/backend-module-quick-connect':
specifier: workspace:*
version: link:../modules/quick-connect/backend
'@n8n/backend-module-workflow-builder':
specifier: workspace:*
version: link:../modules/workflow-builder/backend
'@n8n/backend-network':
specifier: workspace:*
version: link:../@n8n/backend-network
@ -7719,6 +7722,64 @@ importers:
specifier: ^2.2.8
version: 2.2.8(patch_hash=e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f)(typescript@6.0.2)
packages/modules/workflow-builder/backend:
dependencies:
'@n8n/ai-workflow-builder':
specifier: workspace:*
version: link:../../../@n8n/ai-workflow-builder.ee
'@n8n/db':
specifier: workspace:*
version: link:../../../@n8n/db
'@n8n/decorators':
specifier: workspace:*
version: link:../../../@n8n/decorators
'@n8n/di':
specifier: workspace:*
version: link:../../../@n8n/di
'@n8n/typeorm':
specifier: workspace:*
version: link:../../../@n8n/typeorm
devDependencies:
'@langchain/core':
specifier: 'catalog:'
version: 1.2.8(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
'@n8n/backend-common':
specifier: workspace:*
version: link:../../../@n8n/backend-common
'@n8n/backend-test-utils':
specifier: workspace:*
version: link:../../../@n8n/backend-test-utils
'@n8n/oxlint-config':
specifier: workspace:*
version: link:../../../@n8n/oxlint-config
'@n8n/typescript-config':
specifier: workspace:*
version: link:../../../@n8n/typescript-config
'@n8n/vitest-config':
specifier: workspace:*
version: link:../../../@n8n/vitest-config
n8n-containers:
specifier: workspace:*
version: link:../../../quality/environments/containers
oxlint:
specifier: 'catalog:'
version: 1.78.0(oxlint-tsgolint@0.21.1)
oxlint-tsgolint:
specifier: 'catalog:'
version: 0.21.1
typescript:
specifier: catalog:typescript
version: 7.0.2
vite:
specifier: 'catalog:'
version: 8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)
vitest:
specifier: 'catalog:'
version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@5.0.1)(@vitest/coverage-istanbul@5.0.1)(@vitest/coverage-v8@5.0.1)(@vitest/ui@5.0.1)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3))
vitest-mock-extended:
specifier: 'catalog:'
version: 3.1.0(typescript@7.0.2)(vitest@5.0.1)
packages/node-dev:
dependencies:
'@n8n/di':