Papooch / nestjs-cls

A continuation-local storage (async context) module compatible with NestJS's dependency injection.
https://papooch.github.io/nestjs-cls/
MIT License
431 stars 28 forks source link

Use custom prisma client with prisma provider that is using `useFactory` method. #142

Closed adrianlocurciohotovo closed 5 months ago

adrianlocurciohotovo commented 5 months ago

Hi, thanks for this amazing library!.

I'm facing an issue that I hope you can help me to solve.

I have following prisma module that is using useFactory method to be able to use other dependencies like Cache and ConfigService in order to proper build extended prisma client based on my needs.

src/prisma-module/prisma.module.ts

import { Module } from '@nestjs/common';
import { PrismaService } from 'prisma-module/prisma.service';
import { getPrismaClient } from 'prisma-module/prisma.client';
import { Cache } from 'cache-manager';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ConfigService } from 'config/config.service';

@Module({
  providers: [
    {
      provide: PrismaService,
      useFactory: (cacheManager: Cache, configService: ConfigService) => getPrismaClient(cacheManager, configService),
      inject: [CACHE_MANAGER, ConfigService],
    },
  ],
  exports: [PrismaService],
})
export class PrismaModule {
}

getPrismaClient is just a function that builds custom prisma client and returns it.

src/prisma-module/prisma.client.ts


import { PrismaClient } from '@prisma/client';
import { Cache } from 'cache-manager';
import { Logger } from '@nestjs/common';
import { PrismaService } from 'prisma-module/prisma.service';
import {
  createApiFunctionExtension,
  createApiKeyExtension,
  createAuthProviderExtension,
  createAuthTokenExtension,
  createCustomFunctionExtension,
  createCustomFunctionWebhookHandleExtension,
  createEnvironmentExtension,
  createTenantExtension,
  createUserExtension,
  createWebhookHandleExtension,
} from './extensions';
import { ConfigService } from 'config/config.service';

const logger: Logger = new Logger(PrismaService.name);

export const getPrismaClient = (cache: Cache, configService: ConfigService) => {
  const prisma = new PrismaClient();

  if (!configService.usePrismaExtensions) {
    return prisma;
  }

  return prisma
    .$extends(createWebhookHandleExtension(prisma, cache, logger))
  ;
};

And this is the file where I export PrismaService class that is being used as the provider token in src/prisma-module/prisma.module.ts

src/prisma-module/prisma.service.ts

import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

export * from './types';

@Injectable()
export class PrismaService extends PrismaClient {
}

The main purpose of this custom prisma client that I built is caching results in redis. This is an example of how createWebhookHandleExtension used inside getPrismaClient works:

export const createWebhookHandleExtension = (prisma: PrismaClient, cache: Cache, logger: Logger) => {
  return Prisma.defineExtension({
    name: 'webhookHandle',
    model: {
      webhookHandle: {
        async findFirst<T>(
          this: T,
          { useCache, ...args }: Prisma.WebhookHandleFindFirstArgs & CacheOptions,
        ): Promise<ReturnType<PrismaClient['webhookHandle']['findFirst']>> {
          const cacheKey = useCache ? getCacheKey(args) : null;
          if (cacheKey) {
            const cachedResult = await cache.get<WebhookHandle>(cacheKey);
            if (cachedResult) {
              logger.verbose('WebhookHandle cache: HIT');
              return cachedResult;
            } else {
              logger.verbose('WebhookHandle cache: MISS');
            }
          }

          const webhookHandle = await prisma.webhookHandle.findFirst(args);

          if (cacheKey && webhookHandle) {
            await cache.set(cacheKey, webhookHandle);
          }

          return webhookHandle;
        },
      },
    },
    query: {
      webhookHandle: {
        async update({ args, query }) {
          const webhookHandle = await query(args);
          if (args.where.id) {
            await cache.del(getCacheKey(args)!);
            await invalidateCustomFunctionWebhookHandleCache(cache, args.where.id);
          }
          return webhookHandle;
        },
        async delete({ args, query }) {
          const webhookHandle = await query(args);
          if (args.where.id) {
            await cache.del(getCacheKey(args)!);
            await invalidateCustomFunctionWebhookHandleCache(cache, args.where.id);
          }
          return webhookHandle;
        },
      },
    },
  });
};

Ass you can see I override findFirst (and this is very important) method and apply cache logic into it. Based on this I'm having an issue when I start a transaction from transactionHost object in the normal way as docs explain by ruinning:

this.txHost.withTransaction(async => {
// Logic here...
})

Everything is working fine until I use the findFirst method from webhookHandle model, I have realized that call await prisma.webhookHandle.findFirst(args); that is being used inside extension is not being executed in the context of a transaction.

This is how I register the ClsModule in my app.module.ts as docs say here https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter#custom-client-type

ClsModule.forRoot({
      plugins: [
        new ClsPluginTransactional({
          imports: [PrismaModule],
          adapter: new TransactionalAdapterPrisma({
            prismaInjectionToken: PrismaService,
          }),
        }),
      ],
    })

I'm suspecting the transactional adapter does not currently support passing providers that are using useFactory method. Am I right ? Or there is something I'm missing here ?

Thanks

Papooch commented 5 months ago

Hi, I'll investigate this further when I get to a computer, but there shouldn't be a problem with passing a prisma client created by a factory.

What I see as a problem is the line here:

const webhookHandle = await prisma.webhookHandle.findFirst(args);

Here, prisma is always referring to the original instance and not to the proxied transaction instance, since you injected the non-proxied PrismaClient.

You might try using this instead of prisma, although I'm not sure if that won't create a recursive call (since you overwrote a similarly-named method)

Although fetching the cache in the query extension instead the model extension might also work.

Passing in the transaction host instead of PrismaClient woild probably also not work due to a circular dependency.

adrianlocurciohotovo commented 5 months ago

@Papooch Hey, thanks for answering!, effectively thanks to your answer I have realized I was using prisma extension in the wrong way. I have changed my approach and I have created new model method called findFirstUsingCache instead of overwriting built-in findFirst.

And I have also used Prisma.getExtensionContext(this) as docs say here https://www.prisma.io/docs/orm/prisma-client/client-extensions/model#call-a-custom-method-from-another-custom-method

That method returns me a valid prisma model and this prisma model is the one that is modified by @nestjs-cls/transactional-adapter-prisma plugin so now when I execute findFirst from extension context, it is executed within Transaction context!.

Thanks so much for helping me!, just in case I leave you the modified working code here:

export const createWebhookHandleExtension = (cache: Cache, logger: Logger) => {
  return Prisma.defineExtension({
    name: 'webhookHandle',
    model: {
      webhookHandle: {
        async findFirstUsingCache<T>(
          this: T,
          args: Prisma.WebhookHandleFindFirstArgs,
        ): Promise<ReturnType<PrismaClient['webhookHandle']['findFirst']>> {
          const cacheKey = getCacheKey(args);
          if (cacheKey) {
            const cachedResult = await cache.get<WebhookHandle>(cacheKey);
            if (cachedResult) {
              logger.verbose('WebhookHandle cache: HIT');
              return cachedResult;
            } else {
              logger.verbose('WebhookHandle cache: MISS');
            }
          }

          // Extension context operations like `findFirst` are executed within Transaction object if exists.
          const extensionContext = (Prisma.getExtensionContext(this) as any);

          const webhookHandle = await extensionContext.findFirst(args);

          if (cacheKey && webhookHandle) {
            await cache.set(cacheKey, webhookHandle);
          }

          return webhookHandle;
        },
      },
    },
    query: {
      webhookHandle: {
        async update({ args, query }) {
          const webhookHandle = await query(args);
          if (args.where.id) {
            await cache.del(getCacheKey(args)!);
            await invalidateCustomFunctionWebhookHandleCache(cache, args.where.id);
          }
          return webhookHandle;
        },
        async delete({ args, query }) {
          const webhookHandle = await query(args);
          if (args.where.id) {
            await cache.del(getCacheKey(args)!);
            await invalidateCustomFunctionWebhookHandleCache(cache, args.where.id);
          }
          return webhookHandle;
        },
      },
    },
  });
};

Definitively everything is working fine with providers that make use of useFactory method!.