Migrating your GraphQL schema with Fauna
Rob Sutter
Posted on September 10, 2021
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
}
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")] } }
)
)
)
)
)
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 aninput FirewallRule
. - Adds
_id
and_ts
fields to thetype FirewallRule
you provide. - Generates one GraphQL query -
findFirewallRuleById
. - Generates three GraphQL mutations -
createFirewallRule
,updateFirewallRule
, anddeleteFirewallRule
.
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:
- Specify a resolver for your query.
- Specify your
input
. - Specify a resolver for each mutation.
- Migrate in one step:
- Modify your UDFs to accept the new shape of your data.
- Modify your
type
andinput
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"))
)
)
)
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")
}
There are two points to note about this definition:
- 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. - 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
}
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")
)
)
)
update_firewall_rule
Query(
Lambda(
["id", "firewall_rule_input"],
Update(
Ref(Collection("FirewallRule"), Var("id")),
{ data: Var("firewall_rule_input") }
)
)
)
delete_firewall_rule
Query(
Lambda(
"id",
Delete(
Ref(Collection("FirewallRule"), Var("id"))
)
)
)
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")
}
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 thecreateFirewallRule
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"))
)
)
)
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"))
)
)
)
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"))
)
)
)
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
}
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
}
}
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.
Posted on September 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.