zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
1.95k stars 83 forks source link

[Feature Request] Custom RPC db operations #1387

Open Ataraxy opened 3 months ago

Ataraxy commented 3 months ago

Would it be possible to allow for custom db ops so that they can be used with the RPC client? It seems that they're hard coded.

For example, If I want to create a prisma client extension that will do a findManyAndCount on a model, I would like to be able to also use it via RPC.

const findManyAndCount = {
  name: 'findManyAndCount',
  model: {
    $allModels: {
      findManyAndCount<Model, Args>(
        this: Model,
        args: Prisma.Exact<Args, Prisma.Args<Model, 'findMany'>>
      ): Promise<
        [
          Prisma.Result<Model, Args, 'findMany'>,
          number,
          Args extends { take: number } ? number : undefined
        ]
      > {
        return prisma.$transaction([
          (this as any).findMany(args),
          (this as any).count({ where: (args as any).where }),
        ]) as any;
      },
    },
  },
};

Though as an aside I'm unsure if this above extension would even work with an enhanced client but that's besides the point.

This example could of course be done with two separate queries client side or if something like a zenstack transactions api is put in such as this thread is discussing: https://github.com/zenstackhq/zenstack/issues/1203

Still such a feature could prove to be useful for other model related extensions.

Maybe it can be as simple as adding an options object that can be passed to the handler (perhaps a customOps property that is an array) and if the op exists in it then it will allow it through and not throw an error. It may also need to skip the part right after that validates the zodschema if this were the case.

https://github.com/zenstackhq/zenstack/blob/3291c6e2641206eac73649c5f292a0c068e2ff2e/packages/server/src/api/rpc/index.ts#L58-L131

ymc9 commented 3 months ago

Hi @Ataraxy , thanks for filing this and the proposal.

First of all, since Prisma client extension is not part of the schema, ZenStack doesn't really have knowledge of it. Plugins like trpc generation are solely based on the schema today.

Do you think what you need can be resolved by implementing a custom trpc route? Trpc allows flexible route merging. You can use ZenStack-enhanced prisma client inside the custom-implemented router, and merge it with ZenStack-generated ones. The benefit is that you have full control of the server-side and don't need to have multiple client-side queries. Trpc is mainly a server-side framework anyway.

juni0r commented 2 months ago

@ymc9 I think custom operations for the RPC client would be a great addition, since using only CRUD ops just isn't sufficient for more complex apps. I have several instances where I need to add custom business logic on the server side, such as transactions or hitting an external API.

Ultimately though it's still a regular update operation as far as the client is concerned. Sure, you could write a custom API handler but then you'll lose all the benefits of the RPC client, such as query invalidation and enhanced serialization.

I implemented a proof of concept which works very well in principle. Say we want to do a purchase by obtaining a payment from an external provider and invoke a custom action /api/model/order/pay. On the server side we confirm the payment, update the order status, maybe create some other artifacts and send a confirmation email.

const { endpoint, fetch } = getHooksContext()

interface PayOrder {
  id: string
  transactionId: string
}

const { mutateAsync: payOrder } = useModelMutation<PayOrder, QueryError, Order, true>(
  'Order',
  'PUT',
  `${endpoint}/order/pay`,
  metadata,
  undefined,
  fetch,
  true,

await payOrder({ id: order.id, transactionId: payment.transactionId })

On the server side we simply create the corresponding handler which performs all necessary logic and eventually returns the updated order object.

It works like a charm except for one problem: When parsing the result on the client, it'll determine the operation by doing a path.split('/').pop() which in the example above will yield pay and this will result in an error.

The workaround involves a little trickery with subclassing string an overriding the split method. In order to make this work more easily, the operation could be carried independently of the path.

On the server side it'd be quite handy to have a utility function such as defineRPCHandler which conveniently handles input validation, errors and sending the result as Superjson.

export default defineRPCHandler(async (event) => {
  const { id, transactionId } = parseBody(PayOrderInputSchema)

  return await prisma.$transaction(async tx => {
    const receipt = await PayBuddy.confirmPayment(transactionId)

    const order = await tx.order.update({ 
      where: { id },
      data: {
        status: 'paid',
        payment: { create: { transactionId, receipt } }
      }
    })

    // ... more business logic

    return order
  })
})