Documentation
Basics
Nodes

Nodes

The node function is a core abstraction in fuse, it allows you to express a way to load a key-able* entity and its shape. By defining the way to load the entity we enable a few use-cases

  • We can return the key of the node at any point in our graph and the load function defined on the node will take care of resolving the full entity
  • We can return a list of keys when our list endpoint does not return the full entity and the load function will take care of loading these in parallel.

A few things this endpoint does for you is create an automatic entry-point for the node query-field as well as the lower-cased type query-field (in this case user) and we'll ensure that the id of this entity is globally unique.

A node will define its fields, this is what it will expose to the outside world, fields can be a translation of properties on the Resource, computed properties or just plainly expose the property as-is. Every field is nullable by default, unless you explicitly define it as non-nullable by means of nullable: false.

Let's look at the example of the Getting Started guide:

import { node } from 'fuse'
 
type UserSource = {
  id: string
  name: string
  avatarUrl: string
}
 
export const UserNode = node<
  UserSource,
  // This is the default, you can change this when you use a different type, like number.
  // string
>({
  name: 'User',
  // This is the default, however if you use a different key-field property you can change this.
  // key: 'id',
  load: async (ids) => getUsers(ids),
  fields: (t) => ({
    name: t.exposeString('name'),
    avatarUrl: t.exposeString('avatarUrl'),
    firstName: t.string({
      // resolve here allows us to compute this property with
      // the data we have available from the resource.
      resolve: (user) => user.name.split(' ')[0],
    }),
  }),
})

If we were to have a list endpoint that only returned the name and was missing avatarUrl we could do the following:

import { addQueryFields } from 'fuse'
 
addQueryFields((t) => ({
  users: t.field({
    type: [UserNode],
    resolve: async () => {
      const result = await listUsers()
      return result.map(user => user.id)
    }
  }),
}))

Now the underlying API knows it needs to go back to the load function and resolve all the details for these keys. Similarly if we were to have an objectField that needs to return a UserNode we can just return the id and the dataloader (opens in a new tab) will ensure that these are loaded in parallel.

*key-able: A key-able entity is an entity that has a unique identifier that can be used to load the entity.

Example of querying the automatically generated entry-points:

query {
  node(id: x) {
    ... on User { id firstName }
  }
  user(id: x) {
    id
    firstName
  }
}

Globally unique identifiers

You might notice when querying your node that the value for your key is slightly different than what you expect. This is because we ensure that the key is globally unique, in our case we will take the name of the type (in the above case User) and base64 encode it with the key (in the above case id).

This results in having a globally unique identifier that we can easily decode to be of a certain type and if we so choose we can keep an entity-independent cache across types that won't result in collisions.

When you use t.arg.id and the ID output field to signal that we potentially dealing with an encoded key we will do the translation back for you.

This means when we'd get a list back as a result of query { users { id firstName } } that it might look like

{
  "users": [
    { "id": "VXNlcjox", "firstName": "John" },
    { "id": "VXNlcjoy", "firstName": "Jane" }
  ]
}

Both ids respectively translate to User:1 and User:2, now we can ues that in for instance our user(id: ID!) or node(id: ID!) queries.

Let's expand the example from above, we can query the node field on Query with the globally unique key we retrieved from our list:

query ($id: ID!) {
  node(id: $id) {
    ... on User { id firstname }
  }
}

Now we can invoke that with VXNlcjox and we'll get our user named John as a result.

Connecting nodes

In the datalayer we want our data to be connected so we can do a single API call to retrieve all the resources we need to show the data on the screen.

Referencing other nodes

For 1:1 relationships you can add a field to your node that returns the key of the related node. In doing so it will take the key and invoke the load function of the related node.

When doing so you'll see that we have a resolve function similar to the firstName field above, rather than computing a new property we are telling our field the key that we want loaded. Returning this key will make the UserNode load the full User object. In resolvers we can see the first property being named parent in our case this is the BlogPostSource which is the object we loaded from our external datasource.

import { node } from 'fuse'
 
export const BlogPostNode = node<BlogPostSource>({
  name: 'BlogPost',
  load: async (ids) => fetchBlogPosts(ids),
  fields: (t) => ({
    // Exose a field as-is
    title: t.exposeString('title'),
    author: t.field({
      // This refers to the UserNode we created earlier,
      // the id will be used to load the full object.
      type: UserNode,
      resolve: (parent) => parent.author_id
    })
  }),
})

The benefit here is that when your parent is a list that all of the related nodes will be loaded in parallel.

Extending nodes

When we have a more complex relationship or don't have the key of the related entity at hand we can choose to extend the node.

import { addNodeFields } from 'fuse'
 
addNodeFields(UserNode, t => ({
  blogPosts: t.field({
    type: [BlogPostNode],
    resolve: (parent) => fetchBlogPostsByAuthor(parent.id)
  })
}))

This adds a new field to the node named blogPosts and the resolver will fetch all the blog posts of the user.

If we want to optmise this to load in parallel as well we can use t.loadableList instead of t.field.

How to handle authorization

Authorization can be hard to reason about in these cases, this is why we thought about a few ways to handle this.

You can centralise authorization to the node of an entity, this would mean implementing the logic in the load function. This means that every time you need to go to the load function to resolve an entity it will run through the authorization logic by default. This does mean that if you need a different contextual authority you can't just return the identifier.

Alternatively if you know that the underlying datasource is already running authorization you can choose to defer that logic to the datasource.