Migrating your GraphQL schema with Fauna

rtsrob

Rob Sutter

Posted on September 10, 2021

Migrating your GraphQL schema with Fauna

Modern applications frequently change as you deliver features and fixes to customers. In this series, you learn how to implement migrations in Fauna, the data API for modern applications.

The first post in this series introduces a high-level strategy for planning and running migrations. The second post demonstrates how to implement migration patterns using user-defined functions (UDFs) written in the Fauna Query Language (FQL). In this post, you learn specific considerations for migrating Fauna databases that you access via GraphQL and how to apply the techniques from the previous posts.

All of the code in this series is available on GitHub in Fauna Labs.

Migration scenario

This post implements the same migration scenario you used in the previous post. Your Fauna database has a collection for firewall rules and you have an updated requirement to manage inbound traffic from an arbitrary number of IP address ranges. To satisfy this requirement, you must migrate the ipRange field type from an FQL string to an FQL array of strings.

Pre-requisites

To follow along with this post you must have access to a Fauna account. You can register for a free Fauna account and benefit from Fauna’s free tier while you learn and build. You do not need to provide payment information until you upgrade your plan.

You do not need to install any additional software or tools. All examples in this post can be run in the web shell and the GraphQL playground in the Fauna dashboard.

This post assumes you have read and understood the previous posts in this series.

Create and populate your database

Create another new database in the Fauna dashboard. Do not select the "Pre-populate with demo data" checkbox, and do not re-use the database from the previous post.

Save initial-schema.graphql to your computer. In your new database in the Fauna dashboard, select the GraphQL tab, choose Import Schema, and upload initial-schema.graphql.

type FirewallRule {
  action: String!
  port: Int!
  ipRange: String!
  description: String
}
Enter fullscreen mode Exit fullscreen mode

Navigate to the Collections tab and notice that Fauna creates an empty FirewallRule collection.

Navigate to the Functions tab and choose New Function. Enter migrate_firewall_rule as the Function Name and leave the Role set to the default None. Paste the following FQL in the field Function Body and choose Save to create your UDF. Note that this is the same UDF you created in the previous post.

migrate_firewall_rule

Query(
  Lambda(
    ["firewall_rule_ref"],
    Let(
      { 
        doc: Get(Var("firewall_rule_ref")),
        ipRange: Select(["data", "ipRange"], Var("doc"))
      },
      If(
        IsArray(Var("ipRange")),
        Var("doc"),
        Update(
          Var("firewall_rule_ref"),
          { data: { ipRange: [Var("ipRange")] } }
        )
      )
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

Return to the GraphQL tab, copy the mutation from populate-firewall-rules.graphql, and paste it into the query editor. Choose the "play" button and Fauna runs your mutation and returns the _id and description for each sample firewall rule.

Return to the Collections tab and confirm that your FirewallRule collection now contains three documents.

Encapsulation

Encapsulation is built into GraphQL via strongly typed queries and mutations. Choose Schema in the GraphQL Playground and review the modified schema that Fauna generates for your database. Note that Fauna makes the following relevant modifications to your schema:

  • Copies the type FirewallRule you provide to an input FirewallRule.
  • Adds _id and _ts fields to the type FirewallRule you provide.
  • Generates one GraphQL query - findFirewallRuleById.
  • Generates three GraphQL mutations - createFirewallRule, updateFirewallRule, and deleteFirewallRule.

You can override or redefine everything that Fauna generates for you, including GraphQL input types. You can also specify UDFs as custom resolvers for both queries and mutations. Together, these characteristics form the basis of your migration strategy with GraphQL.

Migrating in steps

You must create UDFs for each relevant query and mutation the first time you perform a migration with GraphQL. Except for the final step, each individual step leaves your database in an equivalent state that does not require any downtime.

For your first migration, you perform the following steps in order:

  1. Specify a resolver for your query.
  2. Specify your input.
  3. Specify a resolver for each mutation.
  4. Migrate in one step:
    • Modify your UDFs to accept the new shape of your data.
    • Modify your type and input definitions in your GraphQL schema and replace your schema in Fauna.

You perform subsequent migrations by modifying the relevant UDFs and uploading a new schema in a single step.

Tip: Use tooling for the final step! Fauna provides the Fauna Schema Migrate tool and a Serverless Framework plugin to help you manage your resources in Fauna as code.

For all migrations, it is less risky if you perform your migration during an application downtime period. If you are using infrastructure as code (IaC) tools, a single migration should typically take only a few seconds.

Step one - Specify your first resolver

The first change you make to your database is to explicitly specify a UDF as a resolver for your query. You modify your query before your mutations because it does not require specifying an input. This way you change only one thing at a time and check for correctness, supporting the principle of migrating in steps.

Return to the Functions tab and again choose New Function. This time enter find_firewall_rule_by_id as the Function Name and leave the Role set to the default None. Paste the following FQL in the field Function Body and choose Save to create your UDF.

find_firewall_rule_by_id

Query(
  Lambda(
    ["id"],
    Get(
      Ref(Collection("FirewallRule"), Var("id"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

Open your schema and explicitly define the findFirewallRuleByID query by adding the following definition:

type Query {
  findFirewallRuleByID(id: ID!): FirewallRule @resolver(name: "find_firewall_rule_by_id")
}
Enter fullscreen mode Exit fullscreen mode

There are two points to note about this definition:

  1. Everything except for @resolver(name: "find_firewall_rule_by_id") is copied directly from the schema that Fauna generates. This means you are not creating a new query, but are directing an existing query to use a specific UDF with the @resolver directive.
  2. The @resolver directive takes one parameter, name, whose value is the name of the UDF Fauna invokes to process the query.

Save your schema and return to the GraphQL tab. Choose Replace Schema, choose Replace, and select your modified schema.

Step two - Create an input

When you upload a GraphQL schema, Fauna copies any type definitions to a corresponding input definition. If you explicitly define this input in your schema, Fauna uses the definition you specify.

Copy the following input FirewallRuleInput definition and add it to your schema:

input FirewallRuleInput {
  action: String!
  port: Int!
  ipRange: String!
  description: String
}
Enter fullscreen mode Exit fullscreen mode

Note this definition is identical to the generated schema in your Fauna dashboard. For your own migrations, you can copy the input definition from the schema Fauna displays in the GraphQL tab.

Save your schema again, return to the GraphQL tab, and replace your schema with the newest version.

Step three - Specify resolvers for mutations

Now that you have an input definition, you can specify resolvers for your mutations. Create UDFs for each mutation using the following FQL:

create_firewall_rule

Query(
  Lambda(
    ["data"],
    Create(
      Collection("FirewallRule"),
      Var("data")
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

update_firewall_rule

Query(
  Lambda(
    ["id", "firewall_rule_input"],
    Update(
      Ref(Collection("FirewallRule"), Var("id")),
      { data: Var("firewall_rule_input") }
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

delete_firewall_rule

Query(
  Lambda(
    "id",
    Delete(
      Ref(Collection("FirewallRule"), Var("id"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

Add the following to your schema to specify resolvers for each mutation:

type Mutation {
  createFirewallRule(data: FirewallRuleInput): FirewallRule @resolver(name: "create_firewall_rule")
  updateFirewallRule(id: ID!, data: FirewallRuleInput!): FirewallRule @resolver(name: "update_firewall_rule")
  deleteFirewallRule(id: ID!): FirewallRule @resolver(name: "delete_firewall_rule")
}
Enter fullscreen mode Exit fullscreen mode

Save your schema and replace the schema in the GraphQL tab of the Fauna dashboard. At this point, you have exactly the same functionality you started with. Because each query and mutation has an explicitly defined UDF as its resolver, you are now ready to perform your migration.

Final schema - migrate all at once

The final step in a migration is to update the logic in your UDFs, modify your type and input definitions in your schema, and upload your schema to Fauna. Because GraphQL is strongly typed, you must complete these actions in a single step. The simplest way to do this is by accepting some small amount of downtime for your application while you migrate, typically less than one minute. Techniques for migrating with zero downtime are beyond the scope of this post.

First, update the logic in your UDFs to handle the new shape of your data, reusing the same UDFs from the previous post.

Note: You do not have to update the create_firewall_rule UDF! Because GraphQL enforces the schema, any call to the createFirewallRule mutation already has the proper updated shape.

find_firewall_rule_by_id

Query(
  Lambda(
    ["id"],
    Call(
      "migrate_firewall_rule", 
      Ref(Collection("firewall_rules"), Var("id"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

update_firewall_rule_by_id

Query(
  Lambda(
    ["id", "new_rule"],
    Let(
      {
        ref: Ref(Collection("migrate_firewall_rule"), Var("id")),
        doc: Update(
          Var("ref"),
          Var("new_rule")
        )
      },
      Call("migrate_firewall_rule", Var("ref"))
    )        
  )
)
Enter fullscreen mode Exit fullscreen mode

delete_firewall_rule

Query(
  Lambda(
    ["id"],
    Let(
      {
        ref: Ref(Collection("migrate_firewall_rule"), Var("id")),
        doc: Call("migrate_firewall_rule", Var("ref"))
      },
      Delete(Var("ref"))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

Next, modify the type and input declarations in your GraphQL schema to reflect the new shape of your data:

type FirewallRule {
  action: String!
  port: Int!
  ipRange: [String]!
  description: String
}

input FirewallRuleInput {
  action: String!
  port: Int!
  ipRange: [String]!
  description: String
}
Enter fullscreen mode Exit fullscreen mode

Finally, save your schema and replace the schema in the GraphQL tab of the Fauna dashboard. That's it!

Validating the migration

You can follow the steps laid out in the section Confirming zero defects in the previous post to verify that your migration was successful.

You can also use GraphQL queries and mutations to confirm that you observe the expected behavior. Navigate to the Collections tab in the Fauna dashboard and copy the id of an existing, unmigrated document in the FirewallRule collection. Return to the GraphQL tab and run the following query, replacing <FIREWALL_RULE_ID> with the id you copied:

query {
  findFirewallRuleByID(id:"<FIREWALL_RULE_ID>"){
    description
    ipRange
    port
    action
  }
}
Enter fullscreen mode Exit fullscreen mode

The firewall rule should be displayed with an array value for ipRange. Return to the Collections tab and verify that the document now has an array value for ipRange.

Conclusion

When performing migrations with GraphQL, you apply the same principles that you apply when migrating with FQL. Before you perform your first GraphQL migration, you must specify input, type, and @resolver definitions in your GraphQL schema. Because GraphQL strongly enforces types via the schema definition, GraphQL migrations typically require some small amount of downtime as you replace your UDFs and schema.

This post leaves your underlying data unchanged until you access it, a pattern known as "just-in-time" (JIT) data migration. The final post in this series discusses JIT and two additional techniques for migrating your data and indexes with Fauna, along with sample code and guidance on choosing an appropriate approach for your application.

💖 💪 🙅 🚩
rtsrob
Rob Sutter

Posted on September 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related