blade.im
261

Triggers

To perform additional operations when a particular kind of query is executed on your database, you can define database triggers.

Database triggers are TypeScript functions that are executed for incoming Blade queries, and can be used to validate those queries, run additional queries within the same transaction, run asynchronous code, or load data from a different data source.

Defining Triggers

To get started with locally developing database triggers, create a triggers directory at the root of your project. Within it, create a file with the slug of the model that you would like to address with your triggers.

For example, if the slug of your model is account, the command for creating the file would look like this:

touch ./triggers/account.ts

Afterward, you can export a trigger from the file:

import type { AddTrigger } from 'blade/types';

export const add: AddTrigger = (query) => {
  query.with.handle = query.with.name.toLowerCase();

  // Run code here
  return query;
}

As an example, the trigger above would automatically run for every query of type add and auto-generate a value for the handle field based on the value of the name field. In other words, the handle of every newly created account record would not contain the name of the account, in lower-case text.

To test it, you could run the following query with the useMutation hook:

await add.account.with.name('Engineering');

When to use Triggers

In general, you should refrain from using triggers for anything that can be accomplished using model definitions, to ensure the best performance and maintainability of your queries.

For example, if you need to provide any form of static defaults for your queries (meaning defaults that are the same for every query), you should define them in the model definition itself, using the defaultValue attribute of fields, for example. Like that, no unnecessary computational step will happen for your queries.

Even if a value is dynamic, such as a mathematic equation, as long as it does not depend on the value of another field, and assuming that it always produces the same output (meaning it is deterministic), you should still define it in your model definition.

For any use case that is highly dynamic, or that simply cannot be accomplished using the model definition, you can use database triggers, which perform computation for every query that is affected.

Types of Triggers

Blade supports 2 types of triggers:

  • Synchronous Triggers: These functions return queries that are executed within the same transaction as the original query. Either before the original query, after it, or instead of it.
  • Asynchronous Triggers: These functions are executed separately from the database transaction, and can be used to load data from a third-party data source, or run asynchronous code.

In the majority of cases, you will want to use Synchronous Triggers, which let you write arbitrary synchronos code that does not perform any I/O (e.g. no network requests) and purely validate queries, transform queries, or augment them with additional surrounding queries.

In certain cases, if you really need to run asynchronous code, you can do so using Asynchronous Triggers, but in such cases, you should be careful to write efficient code that, for example, doesn't cause any unnecessary Promise waterfalls.

Synchronous Triggers

The following methods are available for defining synchronous triggers:

Before

These triggers provide queries that are executed before the original query is executed, within the same transaction. They can be used to create additional resources in the database. For example, if a query depends on a parent record to get created first, you may create that parent record in a before* trigger.

They can return multiple queries and must return at least one query.

beforeAdd: (query, multiple, options) => {
  return [
    { add: { team: { with: { name: 'Engineering' } } } },
  ];
}

Methods

  • beforeGet: Executed before a get query.
  • beforeCount: Executed before a count query.
  • beforeAdd: Executed before an add query.
  • beforeSet: Executed before an set query.
  • beforeRemove: Executed before a remove query.

During

These triggers are executed instead of the original query, within the same transaction. They can be used to validate the original query, or transform it by modifying its query instructions.

They must return exactly one query.

add: (query, multiple, options) => {
  // Modify the query
  return query;
}

Methods

  • get: Executed instead of a get query.
  • count: Executed instead of a count query.
  • add: Executed instead of an add query.
  • set: Executed instead of an set query.
  • remove: Executed instead of a remove query.

After

These triggers provide queries that are executed after the original query is executed, within the same transaction. They can be used to create additional resources in the database. For example, if a query depends on a child record to get created after it, you may create that child record in a after* trigger.

They can return multiple queries and must return at least one query.

afterAdd: (query, multiple, options) => {
  return [
    { add: { member: { with: { account: '1234' } } } },
  ];
}

Methods

  • afterGet: Executed after a get query.
  • afterCount: Executed after a count query.
  • afterAdd: Executed after an add query.
  • afterSet: Executed after an set query.
  • afterRemove: Executed after a remove query.

Asynchronous Triggers

The following methods are available for defining asynchronous triggers:

Resolving

These triggers are executed instead of the original query and prevent the original query from reaching the database. They can therefore be used to load data from a third-party data source via a network request, and return it from the trigger.

resolvingAdd: async (query, multiple, options) => {
  return { testField: 'testValue', anotherField: 'anotherValue' };
}

Methods

  • resolvingGet: Executed for a get query.
  • resolvingCount: Executed for a count query.
  • resolvingAdd: Executed for an add query.
  • resolvingSet: Executed for an set query.
  • resolvingRemove: Executed for a remove query.

Following

These triggers are executed after the original query is executed, meaning after the transaction of the original query was fully committed to the database. They can be used to run asynchronous code, such as sending a notification to a third-party service every time a record is added to the database.

followingAdd: async (query, multiple, before, after, options) => {
  // Run code that should not slow down the original query
}

As you can see, the triggers of type following are provided two extra function arguments, which all other types of triggers do not receive: The arguments before and after contain the state of the records that were affected by the original query, before and after the query was executed.

This is useful for generating custom audit logs for your application, for example, since it allows for diffing the field values of a record.

Methods

Unlike the other types of triggers, triggers of type following can only be defined for query types that write data. They cannot be defined for query types that read data, such as get or count.

  • followingAdd: Executed after an add query.
  • followingSet: Executed after an set query.
  • followingRemove: Executed after a remove query.

Trigger Arguments

The following arguments are available for all triggers:

  • query: An object containing the instructions of the query that is being executed. The argument neither contains the type of the query, nor the target of the query. It only contains the instructions of the query. The type and target are already evident from the name of the trigger and the model for which it was defined.
  • multiple: A boolean indicating whether the query targets multiple records or a single record. For example, a query such as get.team() would result in this argument being false, while a query such as get.teams() would result in this argument being true.
  • before: Only available for triggers of type Following.
  • after: Only available for triggers of type Following.
  • options.implicit: Indicates whether the query was automatically/implicitly generated by a trigger instead of arriving from the outside.
  • options.client: Contains methods for running Nested Queries.
  • options.waitUntil: A function to which a promise can be passed. Blade will keep the worker alive until the promise has either been resolved or rejected.

Additional options are available for Sink triggers.

Nested Queries

To run additional queries whenever a particular query is executed, you can define before and after triggers, which allow for executing additional queries in the same transaction as the original query.

Doing so avoids I/O waterfalls, since the database is only invoked once, executes all queries of the transaction and then returns all results at once. This ensures that your original query is executed as quickly as possible.

In the rare cases in which you do need to query additional data within triggers, however, you can do so using the client property of the options argument that is passed to the trigger. This property contains the instance of the client that is currently being used to execute the query, and can be used to run additional queries within the trigger:

add: (query, multiple, options) => {
  const { get } = options.client;
  const anotherRecord = use.team.with.id(query.with.team);
  
  // Modify the query
  return query;
}

Trigger Sink

When executing a query, the client allows for providing a config option named database, which can be used to target a specific database within the RONIN space:

await add.team.with.name('Engineering', { database: 'my-database' });

Queries for which this config option was defined do not cause any triggers to be executed by default. Instead, such queries must be explicitly captured using a special trigger named "sink":

const triggers = {
  sink: {
    get: (query, multiple, options) => {
      // This trigger is executed for queries that target a specific database.
      return query;
    },
  }
};

Specifically, as you can see above, sink triggers are using the word "sink" in place of the model slug. This is because sink triggers capture all queries of all models.

Additionally, sink queries receive additional properties within the options object that allow for identifying the original query more easily. A model property contains the slug of the model that was targeted, and a database property contains the slug of the database that was targeted.