2 min read

Making queries ACID in FaunaDB

FaunaDB allows for complex transactions to reduce multiple network calls. FQL has a learning curve, but mastering it offers advantages.
Making queries ACID in FaunaDB
Photo by Pawel Czerwinski / Unsplash

I was following along with a tutorial on using Next.js and FaunaDB with Magic. And I realized that there are a few areas that can be improved. Before we start, here is the original tutorial which is excellent overall.

https://docs.magic.link/guides/todomvc

The tutorial creates a class that has some needed functionality to createUser, getUserByEmail , and obtainFaunaDBToken for login. The author has these as methods on a class.

export class UserModel {
  async createUser(email) {
    return adminClient.query(q.Create(q.Collection("users"), {
      data: { email },
    }))
  }

  async getUserByEmail(email) {
    return adminClient.query(
      q.Get(q.Match(q.Index("users_by_email"), email))
    ).catch(() => undefined)
  }

  async obtainFaunaDBToken(user) {
    return adminClient.query(
      q.Create(q.Tokens(), { instance: q.Select("ref", user) }),
    ).then(res => res?.secret).catch(() => undefined)
  }

  ...
}

And it makes sense that these are so atomic, so they can be reused. It keeps the code dry after all. In this situation, I need to create a user before I can retrieve it by email. But when I go to get a token, I need to get the user first.

But when we see the code used later, we see multiple await calls.

const userModel = new UserModel()
const user = await userModel.getUserByEmail(email) ?? await userModel.createUser(email);
const token = await userModel.obtainFaunaDBToken(user);

So for a new user, it tries to getUserByEmail and when that fails, it createUser. Then it can use that user reference  obtainFaunaDBToken to get the client's secret. And this is not bad. It is dry, sleek, and clear. But it is extra network requests.

Here is an alternative that creates a user if not found, and retrieves the secret. I am sure it also can be sleeker. This ditches the user model above, in favor of having the FQL directly in the request.

const createUser = q.Create(q.Collection("users"), { data: { email } });
const getUserByEmail = q.Match(q.Index("users_by_email"), email);
const obtainFaunaDBToken = q.Create(q.Tokens(), {
  instance: q.Select("ref", q.Get(q.Var("match"))),
});

const { secret } = await client.query(
  q.Let(
    { match: getUserByEmail },
    q.If(
      q.Exists(q.Var("match")),
      obtainFaunaDBToken,
      q.Do(createUser, q.Let({ match: getUserByEmail }, obtainFaunaDBToken))
    )
  )
);

The idea here is that you have split the concerns out, and then merged them into a single query. createUser still creates a user, similar to the code above. There is a getUserByEmail which tries to find the user by email. And then the obtainFaunaDBToken which gets the FaunaDB auth token.

The secret sauce here is the Let, Exists, Var and Do FQL methods.

  • Let - Allows assignment of variables to be used within the following queries.
  • Exists - Determines if the query returned a match
  • Var - Can retrieve a variable that is set in Let
  • Do - Executes a series of queries in order

FQL can be a little difficult to parse at first. But the general flow can be described as follows —

  1. Assign variable match to the looked up a user by email
  2. If a user exists, obtain token and end
  3. If the user does not exist, create a user
  4. Get user and assign to match
  5. Create token for matched user and end

While this approach is a lot more complicated than the tutorial author's version. It uses more FQL for one thing. It does reduce the number of requests by having the database perform all the needed operations keeping network overhead low. And if the query fails, it is a transaction so we won't have added side effects, like creating partial data.