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.
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');
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.
Blade supports 2 types of triggers:
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.
The following methods are available for defining synchronous triggers:
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' } } } }, ]; }
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.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; }
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.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' } } } }, ]; }
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.The following methods are available for defining asynchronous triggers:
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' }; }
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.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.
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.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.
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; }
Multiple databases per RONIN space are currently in private beta and not yet publicly available.
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.