Skip to content

Support ambient interactive transactions using AsyncLocalStorage #17215

@itsgiacoliketaco

Description

@itsgiacoliketaco

Problem

A typical way to access the Prisma Client is from a top-level module export:

// db.ts
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();

// post.ts
import { prisma } from "./db";

export function createPost() {
  return prisma.post.create(...)
}

This presents arguably surprising behavior when using helper functions, like createPost, inside of interactive transactions:

import { prisma } from "./db";
import { createPost } from "./post";

async function main() {
  await prisma.$transaction((tx) => {
    await createPost();

    throw new Error("Rollback");
  });
}

Despite throwing the error inside the interactive transaction, the post is still created, because createPost() references the global PrismaClient object (prisma) instead of accepting a TransactionClient (tx) as an argument. Although it is clear why this happens, I would argue that (a) one basically never wants this behavior; (b) it's easy to make this mistake; (c) it's hard to catch this error in code reviews.

Suggested solution

PrismaClient could manage internal state using Node's AsyncLocalStorage such that all calls to prisma.* originating from an interactive transaction callback are scoped to the transaction.

This is similar to #12458, but not the same, because it still uses a callback to define the scope of the interactive transaction. IMHO this makes transactions easier to reason about, and remains consistent with Prisma's existing API. Also, doing it this way only requires AsyncLocalStorage#run and AsyncLocalStorage#getStore, which, as far as I can tell, are not experimental, unlike other AsyncLocalStorage methods.

AsyncLocalStorage was added in Node.js v 13.10.

Although arguably desirable as the default behavior, this feature would be a breaking change and would likely need to be opt-in via an argument to PrismaClient.

Alternatives

One alternative is simply: don't do this; don't reference a global PrismaClient object and always explicitly pass the Prisma client to any function/class that needs to access the database. Fair enough, although I personally find that somewhat painful. Also, if this is the recommended approach, it maybe be good for the docs to reflect it.

As another alternative, this can be accomplished fairly cleanly in user-land by wrapping the global PrismaClient instance with a Proxy. However, it seems preferable for this to be a first-party feature. For example, what if changes are made to PrismaClient that make it more difficult to proxy?

Here is an implementation:

// db.ts
import { Prisma, PrismaClient } from "@prisma/client";
import { AsyncLocalStorage } from "node:async_hooks";

const asyncLocalStorage = new AsyncLocalStorage<Prisma.TransactionClient>()

const _prisma = new PrismaClient();

// Patch PrismaClient.$transaction()
const _prisma$transaction = _prisma.$transaction;
_prisma.$transaction = (...args: any[]) => { 
  if (typeof args[0] === "function") {
    const fn = args[0];
    args[0] = (txClient: Prisma.TransactionClient) => {
      return asyncLocalStorage.run(txClient, () => fn(txClient));
    };
  }
  
  return _prisma$transaction.apply(_prisma, args);
}

// Export a `prisma` object that forwards property access to whichever client
// object is relevant for the current async context. 
export const prisma = new Proxy(_prisma, {
  get(_, p, receiver) {
    const client = asyncLocalStorage.getStore() || _prisma;

    // NOTE: you may want to handle functions like `$transaction` that the
    // `TransactionClient` is missing from the regular `PrismaClient`.  If
    // you're using Postgres you could possibly implement support for
    // nested transactions using SAVEPOINTs.
    
    // In case you do want to access the root PrismaClient inside of
    // a transaction, it's easy enough:
    if (p === "$root") {
      return _prisma;
    }

    return Reflect.get(client, p, receiver)
  }

  // If you mock database calls in tests via `jest.spyOn()` or the like, you
  // will need to implement `set` and `defineProperty` traps as well.
}

Additional context

N/A

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions