Making queries ACID in FaunaDB

date
Dec 29, 2020
published
slug
making-queries-acid-in-faunadb
description
FaunaDB allows for complex transactions to reduce multiple network calls. FQL has a learning curve, but mastering it offers advantages.
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.
 
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 with obtainFaunaDBToken to get the clients 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 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 user by email
  1. If a user exists, obtain token and end
  1. If user does not exist, create user
  1. Get user and assign to match
  1. Create token for matched user and end
While this approach is a lot more complicated that the tutorial authors 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, its a transaction so we won't have added side effects, like creating partial data.