Skip to content

Commit

Permalink
Merge pull request #69 from cerebruminc/lucianbuzzo/nest-transactions…
Browse files Browse the repository at this point in the history
…-limitation

chore: Add info on known limitations wrt nested transactions
  • Loading branch information
LucianBuzzo authored Oct 11, 2023
2 parents 2eabaa4 + 616f508 commit fcc8fcf
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 1 deletion.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ When defining an ability you need to provide the following properties:

- `operation`: The operation that the ability is being applied to. This can be one of `CREATE`, `READ`, `UPDATE` or `DELETE`.

## Known limitations

### Nested transactions

Yates uses a transaction to apply the RLS policies to each query. This means that if you are using transactions in your application, rollbacks will not work as expected. This is because [Prisma has poor support for nested transactions](https://github.com/prisma/prisma/issues/15212) and will `COMMIT` the inner transaction even if the outer transaction is rolled back.
If you need this functionality and you are using Yates, you can return `null` from the `getContext()` setup method to bypass the internal transaction, and therefore the RLS policies for the current request. see the `nested-transactions.spec.ts` test case for an example of how to do this.

## License

The project is licensed under the MIT license.
Expand Down
11 changes: 11 additions & 0 deletions prisma/migrations/20231011122225_add_account_model/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "Account" (
"id" SERIAL NOT NULL,
"amount" INTEGER NOT NULL DEFAULT 0,
"email" TEXT NOT NULL,

CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "Account_email_key" ON "Account"("email");
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `amount` on the `Account` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "amount",
ADD COLUMN "balance" INTEGER NOT NULL DEFAULT 0;
6 changes: 6 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ model Hat {
userId Int? @unique
}

model Account {
id Int @id @default(autoincrement())
balance Int @default(0)
email String @unique
}

enum Role {
USER
ADMIN
Expand Down
2 changes: 1 addition & 1 deletion test/integration/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("setup", () => {
expect(getRoles.mock.calls).toHaveLength(1);
const abilities = getRoles.mock.calls[0][0];

expect(Object.keys(abilities)).toStrictEqual(["User", "Post", "Item", "Tag", "Hat"]);
expect(Object.keys(abilities)).toStrictEqual(["User", "Post", "Item", "Tag", "Hat", "Account"]);
expect(Object.keys(abilities.User)).toStrictEqual(["create", "read", "update", "delete"]);
expect(Object.keys(abilities.Post)).toStrictEqual(["create", "read", "update", "delete"]);
expect(Object.keys(abilities.Item)).toStrictEqual(["create", "read", "update", "delete"]);
Expand Down
149 changes: 149 additions & 0 deletions test/integration/nested-transactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { PrismaClient } from "@prisma/client";
import { v4 as uuid } from "uuid";
import { setup } from "../../src";

// This function is setup to demonstrate the behaviour of nested transactions and rollbacks in Prisma.
// This example is based on interactive transaction docs on the Prisma website:
// https://www.prisma.io/docs/concepts/components/prisma-client/transactions#interactive-transactions
async function transfer(client: PrismaClient, from: string, to: string, amount: number) {
return await client.$transaction(async (tx) => {
// 1. Decrement amount from the sender.
const sender = await tx.account.update({
data: {
balance: {
decrement: amount,
},
},
where: {
email: from,
},
});

// 2. Verify that the sender's balance didn't go below zero.
if (sender.balance < 0) {
throw new Error(`${from} doesn't have enough to send ${amount}`);
}

// 3. Increment the recipient's balance by amount
const recipient = await tx.account.update({
data: {
balance: {
increment: amount,
},
},
where: {
email: to,
},
});

return recipient;
});
}

describe("nested transactions", () => {
it("is expected to NOT rollback transactions if the outer transaction fails", async () => {
const role = `USER_${uuid()}`;
const client = await setup({
prisma: new PrismaClient(),
getRoles(_abilities) {
return {
[role]: "*",
};
},
getContext: () => ({
role,
context: {},
}),
});

const email1 = `alice-${uuid()}@example.com`;
const account1 = await client.account.create({
data: {
email: email1,
balance: 100,
},
});
const email2 = `bob-${uuid()}@example.com`;
const account2 = await client.account.create({
data: {
email: email2,
balance: 100,
},
});

// This transfer is successful
await transfer(client as PrismaClient, email1, email2, 100);
// This transfer fails because Alice doesn't have enough funds in her account
await expect(transfer(client as PrismaClient, email1, email2, 100)).rejects.toThrow();

// Due to lack of nested transaction support, the first transfer is not rolled back
// and the "from" account is still debited
const result1 = await client.account.findUniqueOrThrow({
where: {
id: account1.id,
},
});

expect(result1.balance).toBe(-100);

const result2 = await client.account.findUniqueOrThrow({
where: {
id: account2.id,
},
});

expect(result2.balance).toBe(200);
});

it("should rollback transactions if the outer transaction fails if you bypass yates", async () => {
const role = `USER_${uuid()}`;
const client = await setup({
prisma: new PrismaClient(),
getRoles(_abilities) {
return {
[role]: "*",
};
},
// Returning null here bypasses yates
getContext: () => null,
});

const email1 = `alice-${uuid()}@example.com`;
const account1 = await client.account.create({
data: {
email: email1,
balance: 100,
},
});
const email2 = `bob-${uuid()}@example.com`;
const account2 = await client.account.create({
data: {
email: email2,
balance: 100,
},
});

// This transfer is successful
await transfer(client as PrismaClient, email1, email2, 100);
// This transfer fails because Alice doesn't have enough funds in her account
await expect(transfer(client as PrismaClient, email1, email2, 100)).rejects.toThrow();

// Because we bypassed the Yates internal transaction, the rollback is successful
// and the "from" account is never debited.
const result1 = await client.account.findUniqueOrThrow({
where: {
id: account1.id,
},
});

expect(result1.balance).toBe(0);

const result2 = await client.account.findUniqueOrThrow({
where: {
id: account2.id,
},
});

expect(result2.balance).toBe(200);
});
});

0 comments on commit fcc8fcf

Please sign in to comment.