Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cannot type extended client in function arguments #20326

Open
franky47 opened this issue Jul 21, 2023 · 5 comments
Open

Cannot type extended client in function arguments #20326

franky47 opened this issue Jul 21, 2023 · 5 comments
Labels
domain/client Issue in the "Client" domain: Prisma Client, Prisma Studio etc. kind/improvement An improvement to existing feature and code. tech/typescript Issue for tech TypeScript. topic: client types Types in Prisma Client topic: clientExtensions topic: typescript

Comments

@franky47
Copy link

franky47 commented Jul 21, 2023

Bug description

Some context

I maintain a middleware that provides field-level encryption, which I'm porting to the client extension mechanism (PR 47ng/prisma-field-encryption#66).

It can also be used as a generator to generate data migration files to rotate encryption keys. Those generated files look like the following: a function that takes a Prisma client as an argument to perform the work.

import { PrismaClient } from '.location/to/generated/prisma/client/to/allow/custom/locations'

export async function migrate(client: PrismaClient) {
  // generated migration code
}

The reason why it's accepting a client rather than creating one itself is because of configuration of the middleware/extension, and to potentially include other middleware and extensions. The idea is to make the data migration process as transparent as possible: reading & updating records in an iterative manner, just as it would be done in application code.

The problem

The issue I encounter is with defining the type of client and connecting it to extended client type definitions. I'd like to maintain both a middleware and extension API for this library, for retrocompatibility, until Prisma drops middleware support entirely.

In integration tests, it seems like the only way to have a client type be either middleware-based or extension-based is to explicitly cast the extended client as PrismaClient, see 47ng/prisma-field-encryption#63 (comment).

There are also some discrepancies between using the PrismaClient type imported from @prisma/client vs .custom/client/location. The former doesn't seem to support interactive transaction types (the client argument of the transaction callback is typed as any), but the latter seems to fit the bill for all model operations, which is what we're using to allow supporting custom client locations anyway.

That being said, this PrismaClient type is not type-compatible with extended clients. Runtime compatibility is fine, all model operations work if using directives like explicit type casts or // @ts-ignore.

TL;DR: There doesn't seem to be a way at the moment to type extended clients as arguments of standalone functions.

cc @millsp

How to reproduce

Reproduction repository: https://github.com/franky47/prisma-field-encryption-sandbox

Steps to reproduce:

  1. Clone the repository
  2. Install dependencies with yarn install
  3. Generate migration files with prisma generate
  4. Open the migrate.ts file, and remove the as PrismaClient type cast
  5. Observe an error

Expected behavior

There should be a type definition for extended clients that allows them to be passed to external functions.

While the extended API can include anything added by extensions, having at least the ability to get a working type for the basic functionality (model operations, but not allowing $use or $on) would already be a great start. Those types could then be augmented by users if need be at specific call site locations with added methods from extensions.

Prisma information

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
  output   = "../prisma-client" // needs to support custom client locations
}

generator migrations {
  provider = "prisma-field-encryption"
  output   = "./data-migrations"
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String? /// @encrypted <- annotate fields to encrypt (must be a String)
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade)
  authorId  Int?
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String? /// @encrypted
  posts Post[]
}
import { fieldEncryptionExtension } from 'prisma-field-encryption'
import { PrismaClient } from './prisma-client'
import { migrate } from './prisma/data-migrations'

async function main() {
  const prisma = new PrismaClient().$extends(fieldEncryptionExtension())
  await migrate(prisma)
}

main()

Environment & setup

  • OS: macOS 12.6.7
  • Database: SQLite (but irrelevant to the issue)
  • Node.js version: 18.12.0

Prisma Version

5.0.0
@millsp
Copy link
Member

millsp commented Jul 21, 2023

Thanks for the detailed issue and summarizing our conversation with all the needed references. I don't expect this to be a high priority in the short term, but this is an improvement that we will be able to bring in the longer term, and just to re-state it here:

Thanks for the report. I just gave it a try, and can confirm what you said. My understanding is that the types are functionally equivalent (between regular client and extended client, as we've tested it), but somehow are reported to be structurally different probably because of some elaborate conditional types applied to generic types.

The reason for that is that extended clients actually mimic the generated client types, and are fully dynamic. It is a brand new approach to how we expose and construct the client types. I expect that at some point in the future we will refactor and leverage the extension mechanisms and fully unify the type logic for extended client vs. regular client, and thus resolve your issue.

My tl;dr; is that type assignability rules prevent you to assign an extended client to a non-extended client and vice-versa. So the workaround is to cast either the one or the other to a common PrismaClient type.

@millsp millsp added kind/improvement An improvement to existing feature and code. topic: client types Types in Prisma Client tech/typescript Issue for tech TypeScript. domain/client Issue in the "Client" domain: Prisma Client, Prisma Studio etc. topic: clientExtensions topic: typescript and removed kind/bug A reported bug. labels Jul 21, 2023
@jo-warren
Copy link

This might be better to make as a separate issue but related: The Transaction type created by an extended client is not the same as the transaction type created by a standard prisma client, so also cannot pass the extended transaction to a standalone function using the Prisma.TransactionClient that's already exposed.

@shawnjones253
Copy link

Running into the same issue in our project, we pass the Prisma.TransactionClient created by prisma.$transaction around and introducing extensions broke that

@janpio
Copy link
Contributor

janpio commented Aug 22, 2023

@jo-warren We have two issues about that, so leave your 👍 on these please:

@saboorajat
Copy link

Error: Property '$on' does not exist on type 'DynamicClientExtensionThis<TypeMap<InternalArgs & { result: {}; model: {}; query: {}; client: { $begin: () => () => Promise; }; }>, TypeMapCb, { ...; }>'.ts(2339)

we're basically using prisma client extension to get control over transaction with commit and rollback. Until the log was configured
something like this everything was working.

public readonly prisma = new PrismaClient({ log: process.env.NODE_ENV === SharedTypes.NODE_ENV.PRODUCTION ? ["error"] : ["query", "info", "warn", "error"] }).$extends({ 
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { DynamicClientExtensionThis, InternalArgs } from "@prisma/client/runtime/library";

export type PrismaFlatTransactionClient = Prisma.TransactionClient & {
    $commit: () => Promise<void>;
    $rollback: () => Promise<void>;
};

export type ExtendedPrismaClient = DynamicClientExtensionThis<
    Prisma.TypeMap<
        InternalArgs & {
            result: object;
            model: object;
            query: object;
            client: {
                $begin: () => () => Promise<PrismaFlatTransactionClient>;
            };
        }
    >,
    Prisma.TypeMapCb,
    {
        result: object;
        model: object;
        query: object;
        client: {
            $begin: () => () => Promise<PrismaFlatTransactionClient>;
        };
    }
>;

@Injectable()
export class PrismaService extends PrismaClient<Prisma.PrismaClientOptions, "query" | "info" | "warn" | "error"> implements OnModuleInit {
    public readonly logger: Logger;

    readonly rollback = {
        [Symbol.for("prisma.client.extension.rollback")]: true,
    };

    constructor() {
        super();
        this.logger = new Logger("PrismaService");

        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        this.prisma.$on("query", (event: any) => {
            this.logger.debug(`Query - [${event.query}}] \n with Parameters ${event.params}} \n time-taken ${event.duration}ms \n`);
        });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        this.prisma.$on("error", (event: any) => {
            this.logger.error(`Prisma ERROR :- \n ${JSON.stringify(event, null, 2)} \n `);
        });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        this.prisma.$on("info", (event: any) => {
            this.logger.log(`Prisma INFO :- ${JSON.stringify(event, null, 2)} \n `);
        });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        this.prisma.$on("warn", (event: any) => {
            this.logger.warn(`Prisma WARNING :- ${JSON.stringify(event, null, 2)} \n`);
        });
    }

    public readonly prisma = new PrismaClient({
        log: [
            {
                emit: "event",
                level: "query",
            },
            {
                emit: "stdout",
                level: "error",
            },
            {
                emit: "stdout",
                level: "info",
            },
            {
                emit: "stdout",
                level: "warn",
            },
        ],
    }).$extends({
        client: (() => {
            // eslint-disable-next-line @typescript-eslint/no-this-alias
            const self = this; // Capture the `this` value from the outer scope

            return {
                async $begin() {
                    const prisma = Prisma.getExtensionContext(this);
                    let setTxClient: (txClient: Prisma.TransactionClient) => void;
                    let commit: () => void;
                    let rollback: () => void;

                    // a promise for getting the tx inner client
                    const txClient = new Promise<Prisma.TransactionClient>((res) => {
                        // eslint-disable-next-line jsdoc/require-returns
                        /**
                         * Sets the transaction client.
                         * @param txClient - The transaction client.
                         */
                        // eslint-disable-next-line @typescript-eslint/no-shadow
                        setTxClient = (txClient) => res(txClient);
                    });

                    // a promise for controlling the transaction
                    const txPromise = new Promise((res, rej) => {
                        /**
                         * Commits the transaction.
                         * @returns - A promise that resolves when the transaction is committed.
                         */
                        commit = () => {
                            return res(undefined);
                        };

                        /**
                         * Rolls back the transaction.
                         * @returns - A promise that rejects with the rollback symbol.
                         */
                        rollback = () => {
                            return rej(self.rollback);
                        };
                    });

                    // opening a transaction to control externally
                    if ("$transaction" in prisma && typeof prisma.$transaction === "function") {
                        const tx = prisma
                            .$transaction((newTxClient) => {
                                // Should be avoided, but we need to set the txClient
                                const localClient = newTxClient as Prisma.TransactionClient;
                                setTxClient(localClient);

                                return txPromise;
                            })
                            .catch((e) => {
                                if (e === self.rollback) return;
                                throw e;
                            });

                        // return a proxy TransactionClient with `$commit` and `$rollback` methods
                        return new Proxy(await txClient, {
                            // eslint-disable-next-line @typescript-eslint/no-explicit-any
                            get(target, prop: any) {
                                if (prop === "$commit") {
                                    return () => {
                                        commit();
                                        return tx;
                                    };
                                }
                                if (prop === "$rollback") {
                                    return () => {
                                        rollback();
                                        return tx;
                                    };
                                }
                                return target[prop];
                                // return target[prop as keyof typeof target];
                            },
                        }) as PrismaFlatTransactionClient;
                    }

                    throw new Error("Transactions are not supported by this client");
                },
            };
        })(),
    });

    async onModuleInit() {
        await this.connect();
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
domain/client Issue in the "Client" domain: Prisma Client, Prisma Studio etc. kind/improvement An improvement to existing feature and code. tech/typescript Issue for tech TypeScript. topic: client types Types in Prisma Client topic: clientExtensions topic: typescript
Projects
None yet
Development

No branches or pull requests

7 participants
@janpio @franky47 @shawnjones253 @jo-warren @millsp @saboorajat and others