Skip to content

Feature: Add support to transactions - #2

Draft
luanupe wants to merge 4 commits into
Uanela:mainfrom
luanupe:feature/transactions
Draft

Feature: Add support to transactions#2
luanupe wants to merge 4 commits into
Uanela:mainfrom
luanupe:feature/transactions

Conversation

@luanupe

@luanupe luanupe commented Apr 24, 2026

Copy link
Copy Markdown

Summary

This PR improves transaction support in prisma-smart-cache, specifically addressing cache consistency issues when using Prisma transactions and compatibility issues with array-based transactions.

Problems addressed

Interactive transactions bypassed smart cache

Previously, when using:

await prisma.$transaction(async (tx) => {
  await tx.user.update(...)
})

the tx object was Prisma’s raw transaction client. Because of that:

  • reads inside the transaction did not go through the smart cache wrapper;
  • writes inside the transaction did not trigger cache invalidation;
  • cached data could remain stale after transactional writes.

Array transactions could fail with PrismaPromise validation

When using:

await prisma.$transaction([
  prisma.user.findMany(...),
  prisma.user.create(...),
])

proxied operations returned regular JavaScript promises instead of Prisma Client promises. Prisma requires all array transaction entries to be Prisma Client promises, which could lead to:

All elements of the array need to be Prisma Client promises

What changed

Interactive transaction support

$transaction is now handled explicitly by the proxy.

For interactive transactions, the transaction client passed to the callback is wrapped with smart cache by default:

await prisma.$transaction(async (tx) => {
  await tx.user.update({
    where: { id: 1 },
    data: { name: "Ada" },
  });
});

This allows transactional reads and writes to go through the same cache-aware behavior as the root client.

Deferred invalidation after transaction commit

Writes inside interactive transactions no longer invalidate cache immediately.

Instead, mutations are collected while the transaction callback runs, and invalidation is flushed only after the transaction commits successfully.

If the transaction rolls back or throws, no invalidation is performed.

This avoids invalidating cache before the database changes are actually committed.

Opt-out for raw transaction client

A transaction can explicitly opt out of smart cache wrapping:

await prisma.$transaction(
  async (tx) => {
    await tx.user.update({
      where: { id: 1 },
      data: { name: "Ada" },
    });
  },
  { smartCache: { enabled: false } }
);

In this case, the callback receives Prisma’s original transaction client.

The custom smartCache option is stripped before forwarding transaction options to Prisma.

$raw passthrough client

A $raw property was added to expose the original Prisma client behavior for cases where preserving Prisma Client promises is required.

This is especially useful for array transactions:

await prisma.$transaction([
  prisma.$raw.user.findMany({ where: { active: true } }),
  prisma.$raw.user.create({ data: { name: "Ada" } }),
]);

$raw bypasses caching but strips the custom cache option from arguments before forwarding calls to Prisma.

Public invalidation method

The internal invalidation method was made public so transaction invalidation can call it directly after commit.

When multiple transactional mutations are collected, invalidations are flushed with Promise.all after first checking that there are mutations to process.

Types moved to shared type file

Transaction-related cache mutation types were moved into src/types.ts and exported from the package entrypoint.

This keeps shared proxy/handler types in the same place as the rest of the public/internal type definitions.

Documentation

The README now includes a section explaining:

  • interactive transaction behavior;
  • how to opt out of smart cache inside transactions;
  • how to use $raw for array transactions;
  • why interactive transactions are preferred when automatic invalidation is needed.

Tests

Regression coverage was added for:

  • wrapping interactive transaction clients by default;
  • opting out with smartCache.enabled: false;
  • deferring invalidation until transaction success;
  • avoiding invalidation on rollback;
  • preserving array transaction behavior;
  • using $raw without smart cache interception;
  • direct invalidation behavior in the cache handler.

Implementation note

Some logic was intentionally kept inline and slightly duplicated in the proxy.

The goal was to minimize unnecessary abstraction in areas where small behavior differences matter, such as:

  • regular cached model operations;
  • raw passthrough operations;
  • interactive transactions;
  • array transactions.

Over-reusing the same helper paths here could introduce unnecessary risk, since these flows may need slightly different rules over time.

To avoid duplicating even more code, a small wrapClient function is still used internally to wrap both the root Prisma client and transaction clients consistently.

How to test

Run the test suite:

npm test

Run the package build:

npm run build

Manual scenarios worth validating in a consuming app:

Interactive transaction invalidation

await prisma.user.findMany({
  cache: { ttl: 60 },
});

await prisma.$transaction(async (tx) => {
  await tx.user.update({
    where: { id: 1 },
    data: { name: "Updated" },
  });
});

await prisma.user.findMany();

The final read should not return stale cached data.

Opt out of smart cache inside transaction

await prisma.$transaction(
  async (tx) => {
    await tx.user.findMany();
  },
  { smartCache: { enabled: false } }
);

The transaction callback should receive the raw Prisma transaction client.

Array transaction with $raw

await prisma.$transaction([
  prisma.$raw.user.findMany(),
  prisma.$raw.user.count(),
]);

This should preserve Prisma Client promises and avoid the array transaction validation error.

@luanupe
luanupe marked this pull request as draft April 24, 2026 20:58
@luanupe

luanupe commented Apr 24, 2026

Copy link
Copy Markdown
Author

Hey @Uanela, this is still a draft while I run some local tests, but I’d like to get your thoughts on these changes. The goal is to improve $transaction support. #1

@Uanela

Uanela commented Apr 25, 2026

Copy link
Copy Markdown
Owner

It was faster than I thought. I will get hands on this ASAP.

But for the code itself I was reading so far seems GREAT but I need to make a deep dive and test it.

@Uanela Uanela left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job @luanupe you went deeper into this codebase (even thought not that large, but somehow dealing with a kind complex scenario) and the job you did was exactly what was blind to me on the benchmark repo (I will migrate to this as monorepo instead) see https://github.com/Uanela/smart-cache-benchmark

});

test("should intercept write operations and call handleWrite", async () => {
mockHandlerInstance.isReadOperation.mockReturnValue(false);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • This is not needed and does nothing you can remove

Comment thread README.md
data: { name: "Ada" },
});
},
{ smartCache: { enabled: false } }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See https://github.com/Uanela/prisma-smart-cache/pull/2/changes#r3144714942 before tackling those 2

  • Let's rename smartCache -> cache
  • Allow pass cache: false as shorthand and keep the current behavior also because it allows expansion

Comment thread README.md

```ts
await prisma.$transaction([
prisma.$raw.user.findMany({ where: { active: true } }),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loved this one

Comment thread src/proxy.ts
? ((...args: A) => R) &
(<TResult>(
fn: (tx: PrismaWithCache<any>) => Promise<TResult>,
options?: SmartCacheTransactionOptions & Record<string, any>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • options must be strictly typed

Regarding SmartCacheTransactionOptions I think we should stick to CacheQueryOptions it will stay consistent with the current API, and using it would be a clear sign of meaning apply those CacheQueryOptions to the global transaction. e.g:

await prisma.$transaction(
  async (tx) => {
    await tx.user.update({
      where: { id: 1 },
      data: { name: "Ada" },
    });
  },
  { cache: ... } // CacheQueryOptions | false

Comment thread src/proxy.ts
}

interface ProxyRuntimeOptions {
deferInvalidation?: (mutation: CacheMutation) => void;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • What you think about renaming deferInvalidation to simply defer

Comment thread src/proxy.ts

// wrap interactive transactions so tx can use smart cache and defer invalidation
if (modelName === "$transaction" && typeof modelDelegate === "function") {
return (firstArg: any, transactionOptions?: any) => {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Let's add the types that we already have here I guess will make it much clear for future readers

Comment thread src/proxy.ts
smartCache: _smartCache,
...prismaTransactionOptions
} = (transactionOptions ?? {}) as SmartCacheTransactionOptions &
Record<string, any>;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • As we already casted to SmartCacheTransactionOptions maybe Record<string, any> is unneeded what you think?

Comment thread src/proxy.ts
)
.then(async (result: unknown) => {
// flush transaction invalidations only after commit succeeds
if (useSmartCache && mutations?.length) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Been loving your work @luanupe

Comment thread README.md
);
```

For array transactions, Prisma requires every array element to be a Prisma Client promise. Cached operations return regular promises, so use `$raw` when building the array:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The correct name is sequential transactions I just saw now on their website you could put sequential transactions (array transactions).
  • I think also for this one would be great if we added a https://www.prisma.io/docs/orm/prisma-client/queries/transactions#sequential-operations so that users can quickly grasp why the $raw. I am trying to check if would could go without $raw but up until now shows impossible, and for now the solution you brought is the GREATEST

mockPrisma.user.findMany.mockReturnValue(prismaPromise);

const proxy = smartCache(mockPrisma, mockBento);
const result = proxy.$raw.user.findMany({ cache: { ttl: 100 } } as any);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • is the cache object need on this situation, I think not at all. what are you thoughts on this one?

@luanupe

luanupe commented Apr 28, 2026

Copy link
Copy Markdown
Author

Hey @Uanela ,
Thank you for your review. I will address the points you mentioned next Friday (May 1st is a holiday here in Brazil).

I believe your points are indeed valid. 🙂

@Uanela

Uanela commented Apr 28, 2026

Copy link
Copy Markdown
Owner

Glad you got those, in Mozambique May 1st is also an holiday.

@Uanela

Uanela commented May 2, 2026

Copy link
Copy Markdown
Owner

Any update?

@luanupe

luanupe commented May 2, 2026

Copy link
Copy Markdown
Author

Any update?

Hey @Uanela I haven't forgotten about this PR, but I ended up finding a huge bug in one of my projects, requiring data migration and some other major changes that are taking up a lot of my time. But I'll get back to fixing it ASAP.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants