Vulcan 1.16: apiSchema & dbSchema

This version of Vulcan brings a big improvement in how schemas are defined.

Sacha Greif
VulcanJS
8 min readJul 29, 2020

--

The Problem

Vulcan is built around the very powerful idea of having a central schema that governs the behavior of your entire app, from the database to your API to forms.

But the downside of this approach is that up to now, there was no elegant way to get more granular and specify server-only, database-only, or API-only fields.

To illustrate the issue, imagine you want one of your API fields to hit the Stripe API: you won’t be able to define this field in the same place as the rest of your shared schema, since it will call a server-only Stripe npm package.

Vulcan 1.16 replaces the previous hacks and workarounds to define server-only fields with a much simpler, unified approach.

First of all, don’t worry: all changes are backwards-compatible, and you can keep using the previous syntax for the foreseeable future until you have time to switch over.

Introducing apiSchema

The createCollection and extendCollection functions now support a new apiSchema option in addition to the existing schema .

Similarly to the existing resolveAs, this option lets you define schema fields that only exist in your GraphQL API (meaning these “virtual” fields don’t appear in your forms and don’t get saved to your database).

// shared between client and server
const Posts = createCollection({

schema,
});// server only
extendCollection(Posts, {

apiSchema,
});

Typically, you’ll want to used createCollection in your modules directory shared with the client to define your main schema; and then pass apiSchema to extendCollection on the server only.

This lets us avoid sending unnecessary code to the client, since apiSchema schema definitions will only be used to define the GraphQL layer and are not used inside the client.

Defining apiSchema Fields

apiSchema fields follow a different, simplified syntax compared to “regular” main schema fields:

const apiSchema = {  latestPost: {
canRead: ['members'],
description,
typeName: 'Post',
arguments: '...',
resolver: () => { //... }
}
}

apiSchema fields support the following properties:

  • canRead: same as main schema canRead, with one notable exception: for apiSchema fields, canRead will default to [guests] (e.g. the field is public).
  • description: this is used as help text for the GraphQL API.
  • typeName: this is the GraphQL type (and not the JavaScript type, unlike “normal” fields!) the field should get resolved to.
  • arguments: the field’s arguments.
  • resolver: the field’s resolver function.

As you can see, typeName , arguments and resolver work exactly the same way as they do inside resolveAs. In fact, you can think of apiSchema as a simplified syntax for defining resolveAs-only fields.

Note that canCreate and canUpdate are not supported on apiSchema fields, since these fields by definition can not be stored in the database.

Introducing dbSchema

To mirror apiSchema, a new dbSchema option has also been introduced. This follows the same general pattern, and lets you define validation for fields that can be inserted into the database but should not be exposed through your API.

// shared between client and server
const Posts = createCollection({

schema,
});// server only
extendCollection(Posts, {

dbSchema,
});

dbSchema fields support the same options as regular field, except for canRead , canCreate , and canUpdate. This is because they are not exposed through the API, so they cannot transmit or receive data to or from the client.

New “Relation” Field Option

You could already define a relation property inside a field’s resolveAs, but because we’re moving away from resolveAs in favor of apiSchema, you can now also define relation on the field itself (and avoid having to define resolveAs altogether).

Before:

const schema = {  featuredPostId: {
optional: true,
label: 'Featured Post',
resolveAs: {
fieldName: 'featuredPost',
typeName: 'Post',
relation: 'hasOne',
}
}
}

Now:

const schema = {featuredPostId: {
optional: true,
label: 'Featured Post',
relation: {
fieldName: 'featuredPost',
typeName: 'Post',
kind: 'hasOne',
}
}
}

Auto-Generated Formatted Date Fields

When dealing with dates, you’ll often want to customize how they are displayed on the client. For example, you might want to show a createdAt timestamp as “May 22nd, 2020” in one place and “three weeks ago” in another.

This usually requires loading a heavy date library such as Moment.js on the client and doing the operation there. But in order to give you one more option, Vulcan will now automatically provide *Formatted helpers for any Date field. For example, defining acreatedAt field will now result in a createdAtFormatted field being defined for you.

That *Formatted field takes a format GraphQL argument that lets you control how you want the date to appear:

registerFragment(/* GraphQL */`
fragment PostFragment on Post {
postedAt
postedAtFormatted(format: "YYYY/MM/DD")
postedAtMonth: postedAtFormatted(format: "MMMM")
postedAtAgo: postedAtFormatted(format: "ago")
}
`);

As you can see, GraphQL aliases (alias: fieldName(argument: value) ) are a great way to rename fields so you can load them multiple times with different values.

Note that the format argument follows the Moment.js format() function convention, with the addition of ago as a shortcut to get time in a x days ago format.

New PaginatedList Component

One of the most common tasks in any web framework is loading and displaying a list of data. Vulcan has long made this easy through its withMulti HoC (and now useMulti hook), but this update takes things one step further by providing a new ready-made component:

<Components.PaginatedList
className="post-list-contents"
options={{
collection: Posts,
fragmentName: 'PostFragment',
input,
}}
/>

The component takes the same options as the hook or the HoC, and gives you a working paginated list complete with a “load more” button, as well as error handling for when things go wrong.

Similar to the Datatable or SmartForm component, you can override any of its sub-components using the components prop:

const PaginatedListItem = ({ document }) => 
<div>{document.title}</div>
<Components.PaginatedList
className="post-list-contents"
components={{
PaginatedListItem,
}}
options={{
collection: Posts,
fragmentName: 'PostFragment',
input,
}}
/>

New Database Debugging Dashboard

A new debugging dashboard is available at/debug/database if you use the vulcan:debug package.

It’s really simple but also really handy: it lets you paste in any document _id (without having to specify the collection) and returns the raw MongoDB document, saving you a trip to your terminal to run meteor mongo when all you want to do is quickly check what a document looks like.

Obviously this isn’t very secure, but since the vulcan:debug package is restricted to local development only this shouldn’t be an issue when deploying to production.

Route-Level Access Control

It can be a bad idea to put too much logic at the route level. For example, it might be tempting to tell the route what data it needs, but this pattern breaks down once you want finer-grained, per-component control over your data loading.

That being said, one area where it does make sense to use the route is access control. In addition to using the withAccess HoC or useAccess hook to allow access on a component-by-component basis, you can now also specify an access option when defining a route:

addRoute([
{
name: 'admin',
path: '/admin',
component: AdminDashboard,
access: {
groups: ['admins'],
redirect: '/'
}
}
]);

The access object accepts the following properties:

  • groups: an array of groups allowed to access the route.
  • redirect: a path to redirect the user to if they can’t access the route.
  • redirectMessage: the message to show when redirecting (defaults to Please log in first.).
  • check: optionally, a function that takes the currentUser and currentRoute as arguments and returns true or false (replaces groups).

In-Memory Caching

Imagine you want to build a simple REST API for your data. You might query your GraphQL API server-side using runGraphQL to get the raw JSON data. The issue is that you now have to run an expensive GraphQL query every single time someone connects to your endpoint…

To fix this we are now using node-cache to enable a useCache option:

const results = await runGraphQL(query, { input }, {}, { useCache: true, key: 'api' });

Note that at the moment this does not work for GraphQL requests originated from the client; only when using runGraphQL for server-to-server queries.

In the future we’ll work on expanding Vulcan’s caching features, including supporting other caching methods (caching in-database, using other databases like Redis, etc.) and requests coming from the client.

Better Errors in Development

Over the years, I’ve noticed a lot of the same issues and problems come back again and again. So I wanted to try and catch them at the root and provide immediate help in the form of debugging suggestions whenever an error happens in your app.

Currently these generic debugging suggestions are pretty simple, but I can imagine improving this feature in the future with dynamic suggestions based on the specific error you got. How cool would that be!

Also, note that these suggestions will only appear during local development, and not in production.

New “contextName” Form Option

Let’s imagine your app lets users submit posts. You don’t want to make the title field required when initially submitting a post draft, but you do want to require it when the users submits their final draft for publication.

Currently this isn’t really possible unless you write a new mutation from scratch, but contextName makes this much easier. It’s basically a way to assign a name execution and validation context of the form (and the corresponding server mutation).

This name can then be retrieved inside a field’s optional function to make it required only in certain situations:

const schema = {
title: {
optional() {
const contextName = ;
return contextName === 'submitDraft' ? true : false;
}
}
}

Updated Getting Started Example

It was time to update the Getting Started example to match the new APIs. There are now two new sections, API Schemas and Custom Mutations. And the progress checks have been improved to give you a breakdown of how many tasks are left to complete each step of the tutorial.

Even if you’re familiar with Vulcan, you might want to give it a quick try just to see what’s new!

New Best Practices

These are not “official” deprecations just yet but generally speaking, the best practices for coding Vulcan apps have evolved. Here are some suggestions you might want to consider following, especially for new projects.

  • Do not use the old querying API (selector). Instead, use the new input.
  • Do not use registerComponent for your own project components. Instead, use the more standard import and export.
  • Do not use higher-order components (withSingle, withMulti). Instead, use the corresponding hooks (useSingle2, useMulti2).
  • Do not use resolveAs. Instead, use relation and apiSchema.
  • Do not use withAccess. Instead, use route-level access control.
  • Do not use foo.$ field definitions for array fields. Instead, use arrayItem.

--

--

Sacha Greif
VulcanJS

Designer/developer from Paris, now living in Osaka. Creator of Sidebar, VulcanJS, and co-author of Discover Meteor.