Papooch/nestjs-cls

Can't disconnect from DB while testing and using transactional plugin

Closed this issue · 4 comments

Hello, I'm trying to integrate nestjs-cls into my project for transactions and faced issue with disconnecting from DB while testing repositories.
There code of tests:

describe(`POSITIVE: UserRepository`, () => {
    let repository: IUserRepository;
    let prismaService: PrismaService;

    beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
            imports: [
                PrismaModule.forRoot({
                    isGlobal: true,
                }),
                ClsModule.forRoot({
                    plugins: [
                        new ClsPluginTransactional({
                            imports: [
                                PrismaModule
                            ],
                            adapter: new TransactionalAdapterPrisma({
                                prismaInjectionToken: PrismaService,
                            }),
                        }),
                    ],
                    global: true,
                }),
            ],
            providers: [
                {
                    provide: IUserRepository,
                    useClass: UserRepository,
                },
                UserMapper,
            ],
        }).compile();

        repository = module.get<IUserRepository>(IUserRepository);
        prismaService = module.get<PrismaService>(PrismaService);
    });

    afterEach(async () => {
        await prismaService.$disconnect();
    });

    it(`Should be defined`, () => {
        expect(repository).toBeDefined();
    });

    it(`Should create user`, async () => {
        const user = UserEntity.create();
        const createdUser = await repository.create(user);

        expect(createdUser).toBeDefined();
        expect(createdUser.id).toBeDefined();
        expect(createdUser.createdAt).toEqual(user.createdAt);
        expect(createdUser.updatedAt).toEqual(user.updatedAt);

        await prismaService.user.delete({
            where: {
                id: createdUser.id
            }
        });
    });

   // More test cases
});

In repository I've provided both private prisma: PrismaService and private readonly txHost: TransactionHost<TransactionalAdapterPrisma> since I need transactions in few methods like creating, updating, but not for searching.
So after tests are done, I'm dropping test DB and there occurs that error Error dropping database: error: database "..." is being accessed by other users and I can't finish testing properly.

Hi, thanks for the report.

There should be nothing preventing the use of txHost along with the original PrismaService (although you can also use txHost.withoutTransaction if you need to run a piece of code outside of the current transaction).

The issue sounds more akin to forgetting an await somewhere.

Would you be able to provide a minimal reproduction of the issue that I can run on my side? The test that you shared doesn't really tell the whole story and I don't know why it shouldn't work.

Sure, need time for creating reproduction so I'll do it in next few days

Hello, there reproduction goes and in Readme I've provided details how to prepare for reproduction

Hi, thank you for the repository, I was able to reproduce it locally, and find the issue.

Investigation

Initially, I thought the issue was caused by the 3rd party library nestjs-prisma, because when I removed it and replaced with setting up Prisma directly according to the NestJS docs, the issue was gone.

Then, I reverted back and thought that maybe the PrismaService was being instantiated twice, so I put a simple console.log into the constructor of the nestjs-prisma library's PrismaService (by editing the compiled javasript file in node_modules) and sure enough, two logs appeared in the output.

The problem

That led me to finally notice the issue with your setup, where you actually register PrismaModule twice.

imports: [
    PrismaModule.forRoot({ // registration #1 - dynamic version
        isGlobal: true,
    }),
    ClsModule.forRoot({
        plugins: [
            new ClsPluginTransactional({
                imports: [
                    PrismaModule // registration #2 - static version
                ],
                adapter: new TransactionalAdapterPrisma({
                    prismaInjectionToken: PrismaService,
                }),
            }),
        ],
        global: true,
    }),
],

The problem is that the first dynamic registration creates a new PrismaModule instance, but so does the second static registration - and because those are two different instances of the module, each provides their own version of PrismaModule - but the static one is only available within the context of ClsPluginTransactional, so you never call $disconnect on it in your test suite.

Another issue you would have noticed is that if you provided some more configuration to the forRoot registration, it would have not been respected within the transactional plugin.

There's a simple fix to this on your side (actually multiple ones):

Potential Fix 1: Don't import PrismaModule in ClsPluginTransactional.

Since you already marked PrismaModule.forRoot as global, there's no need to additionally import it - the PrismaService is available globally

new ClsPluginTransactional({
-    imports: [
-        PrismaModule
-    ],
    adapter: new TransactionalAdapterPrisma({
        prismaInjectionToken: PrismaService,
    }),
}),

Potential Fix 2: Save the dynamic instance to a variable and reuse that

To refer to an instance of a dynamic module, one has to save it into a variable first. If you don't want to make the dynamic instance global, you can do this instead:

+ const prismaModuleInstance = PrismaModule.forRoot({ /* config */ });

const module: TestingModule = await Test.createTestingModule({
    imports: [
-       PrismaModule.forRoot({
-           isGlobal: true,
-       }),
+       prismaModuleInstance
        ClsModule.forRoot({
            plugins: [
                new ClsPluginTransactional({
                    imports: [
-                        PrismaModule
+                        prismaModuleInstance
                    ],
                    adapter: new TransactionalAdapterPrisma({
                        prismaInjectionToken: PrismaService,
                    }),
                }),
            ],
            global: true,
        }),
    ],
    providers: [
        {
            provide: IUserRepository,
            useClass: UserRepository,
        },
        UserMapper,
    ],
}).compile();

Potetial fix 3: Use a wrapper module

Dynamic modules that need to be configured and re-used can be wrapped in a static module and re-exported via Module re-exporting feature (bottom of the page)

+ @Module({
+     imports: [PrismaModule.forRoot({/* config */})],
+     exports: [PrismaModule] // module re-exporting
+ })
+ class ConifiguredPrismaModule {}

const module: TestingModule = await Test.createTestingModule({
    imports: [
-       PrismaModule.forRoot({
-           isGlobal: true,
-       }),
+       ConifiguredPrismaModule
        ClsModule.forRoot({
            plugins: [
                new ClsPluginTransactional({
                    imports: [
-                        PrismaModule
+                        ConifiguredPrismaModule
                    ],
                    adapter: new TransactionalAdapterPrisma({
                        prismaInjectionToken: PrismaService,
                    }),
                }),
            ],
            global: true,
        }),
    ],
    providers: [
        {
            provide: IUserRepository,
            useClass: UserRepository,
        },
        UserMapper,
    ],
}).compile();

Conclusion

The issue is not caused by @nestjs-cls/transactional, but by an incorrect use of a 3rd party nestjs-prisma module.

If you have any further questions, feel free to continue this thread, but I'm closing it as completed.