KevinEdry / nestjs-trpc

Providing native support for decorators and implement an opinionated approach that aligns with NestJS conventions.
https://NestJS-tRPC.io
MIT License
73 stars 8 forks source link

Help wanted with data transformer #32

Open RedEagle-dh opened 1 month ago

RedEagle-dh commented 1 month ago

Hey there!

First of all, I like your adapter very much and the idea how you implement trcp in nestjs is great! Sad that it did not came from the trpc devs.

Anyway, I use the T3 techstack with Nestjs as the backend.

My problem is, that I have some complex data structures and without a data transformer trpc seems to have problems with passing data into the frontend.

I tried it with a simple test structure with one field and that worked!

// src/@generated/server.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();
const publicProcedure = t.procedure;

const appRouter = t.router({
  app: t.router({
    findAll: publicProcedure.output(z.object({
      test: z.string(),
    })).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any)
  })
});
export type AppRouter = typeof appRouter;
// app.router.ts
const testschema = z.object({
    test: z.string(),
});

export type User = z.infer<typeof userSchema>;

@Router({ alias: 'app' })
export class AppRouter {
    constructor(@Inject(DbService) private databaseService: DbService) {}

    @Query({ output: testschema })
    async findAll() {
        return { test: 'Hello World' };
    }
}

See, that in the generated server.ts there is no data transformer. Later you'll see that I created one in the app.module.ts

But if I wanted to pass a whole entity with some nested fields and especially date types, I get an error saying: "output validation failed".

My steps to fix this error

  1. I installed the latest superjson package
  2. Added the transformer to the app.module.ts with a little workaround
    
    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { DbModule } from './db/db.module';
    import { ConfigModule } from '@nestjs/config';
    import { TRPCModule } from 'nestjs-trpc';
    import { AppContext } from './app.context';
    import { DbService } from './db/db.service';
    import { AppRouter } from './app.router';
    export const SuperJSON = require('fix-esm').require(
    'superjson'
    ) as typeof import('superjson');

@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, }), TRPCModule.forRoot({ autoSchemaFile: './src/@generated', context: AppContext, transformer: SuperJSON, }), DbModule, ], controllers: [AppController], providers: [AppService, DbService, AppContext, AppRouter], }) export class AppModule {}


I had to import it this way because my nest.js backend is a CommonJS Module and superjson generates require imports on build. I searched for better alternatives but didn't found any. 

This way allows me to start the backend successfully.

4. Added the transformer to the Next.js 14 (app router) frontend
```tsx
// src/lib/trpc/index.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../../../../backend/src/@generated/server'; 

export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
'use client';

import { trpc } from '@/lib/trpc';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

import { httpBatchLink, loggerLink } from '@trpc/client';
import { SuperJSON } from 'superjson';

export const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            staleTime: 1000 * 60 * 2,

            refetchOnWindowFocus: false,

            retry: false,
        },
    },
});

const trpcClient = trpc.createClient({
    links: [
        loggerLink({
            enabled: () => process.env.NODE_ENV === 'development',
        }),
        httpBatchLink({
            url: 'http://localhost:3001/trpc',
        }),
    ],
    transformer: SuperJSON as any,
});

export default function ClientProviders({
    children,
}: Readonly<{ children: React.ReactNode }>) {
    return (
        <trpc.Provider
            client={trpcClient}
            queryClient={queryClient}
        >
            <QueryClientProvider client={queryClient}>
                {children}

                <ReactQueryDevtools position="left" />
            </QueryClientProvider>
        </trpc.Provider>
    );
}

Well, here is a weird thing: The TRPCClient does not see the transformer from the backend. But as you see, I added one. So maybe it must be expose in other way to the AppContext or idk. Unfortunately I didn't found any documentation regarding this.

Could you please help me with that?

RedEagle-dh commented 1 month ago

Here you can see the error from the frontend where the transformer does not match because I should place a transformer in the backend too (which I did) :D grafik

RedEagle-dh commented 1 month ago

Hey again, the issue was on my side because my prisma model was not in sync with the database. The database gave me [] instead of {} which came from the model... Lost literally 5 hours on this one.

Anyway, it worked without an external superjson library! But I am still confused, why the transformer does not get passed into the generated file.

KevinEdry commented 1 month ago

Hey again, the issue was on my side because my prisma model was not in sync with the database. The database gave me [] instead of {} which came from the model... Lost literally 5 hours on this one.

Anyway, it worked without an external superjson library! But I am still confused, why the transformer does not get passed into the generated file.

Hey, Thanks for opening an issue! Sorry you had to debug things for such a long time, you could always ping me in discord and I'll do my best to help if i'm not here, theres a link for the channel in the repo readme.

For your issue, we don't pass the data transform because I didn't know it was needed, but I can write an implementation for it if needed. Do you mind giving me a sample code of what the generated server.ts file would look like with that transformer applied?

RedEagle-dh commented 1 month ago

Sure thing!

When I want to use for example the superjson transformer, I want to add it through the existing field in the app.module.ts:

// app.module.ts

import { TRPCModule } from 'nestjs-trpc';
import { AppContext } from './app.context';
import SuperJSON from 'superjson';

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
        }),
        TRPCModule.forRoot({
            autoSchemaFile: './src/@generated',
            context: AppContext,
            transformer: SuperJSON,
        }),
        DbModule,
    ],
    controllers: [AppController],
    providers: [AppService, DbService, AppContext, AppRouter],
})
export class AppModule {}

The auto generated server.ts file could look like this:

// src/@generated/server.ts

import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { SuperJSON } from "superjson"; // this import depends on the module resolution in the tsconfig but you could just copy the import statement and paste it in the server.ts

const t = initTRPC.create({
  transformer: SuperJSON // the transformer object has a "serialize" and a "deserialize" field which is important. You could create an own transformer and it should be possible to use that too
});

const publicProcedure = t.procedure;

const appRouter = t.router({
  app: t.router({
    findAll: publicProcedure.output(z.array(z.object({
      twitch_id: z.string().max(32),
      username: z.string().max(25),
      join_date: z.date().default(() => new Date()), 
      jdeleted_at: z.date().optional(), 
      email: z.string(),
      publish_stats: z.boolean().default(false), 
      flags: z.array(z.any()).optional().default([]),
    }))).query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any)
  })
});
export type AppRouter = typeof appRouter;

Another way is to pass a self created transformer into the create method, like I mentioned it in the comment.

Here is an example with superjson but split into it's core functions:

// src/@generated/server.ts (snippet)

import { SuperJSON } from "superjson";

const t = initTRPC.create({
  transformer: {
    serialize: SuperJSON.serialize,
    deserialize: SuperJSON.deserialize,
  }
});

PS: I was able to create a little workaround by commenting autoSchemaFile: './src/@generated', out, adding the exact same code you can find here to the generated file and then start the server. After that, it worked as well.

KevinEdry commented 1 month ago

Thats totally doable, I'll try to implement this this week/on the weekend.

RedEagle-dh commented 1 month ago

Sounds nice! Thanks for your work, your adapter is implemented in my project already :D

burithetech commented 3 weeks ago

Glad to find this. I ran into the same issue right now. Thanks for the solving. :)

KevinEdry commented 3 weeks ago

Quick update, I am still working on this, I managed to get a simple import data transformer to work, but I want to make something more generalized, so it will take more time.