Vulcan 1.16: apiSchema & dbSchema
This version of Vulcan brings a big improvement in how schemas are defined.
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 schemacanRead
, with one notable exception: forapiSchema
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 toPlease log in first.
).check
: optionally, a function that takes thecurrentUser
andcurrentRoute
as arguments and returnstrue
orfalse
(replacesgroups
).
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 newinput
. - Do not use
registerComponent
for your own project components. Instead, use the more standardimport
andexport
. - Do not use higher-order components (
withSingle
,withMulti
). Instead, use the corresponding hooks (useSingle2
,useMulti2
). - Do not use
resolveAs
. Instead, userelation
andapiSchema
. - Do not use
withAccess
. Instead, use route-level access control. - Do not use
foo.$
field definitions for array fields. Instead, usearrayItem
.