Most of the logic of your application will end up living as code in action files. action code can change the database, change the requester's session, make outgoing API calls, and really, do anything!
An example action file
JavaScript
1import{ applyParams, save }from"gadget-server";
2
3exportasyncfunctionrun({ api, record, params }){
4applyParams({ record, params });
5awaitsave({ record });
6}
7
8exportconst options ={
9actionType:"update",
10};
Action file exports
An action file can export four pieces: a run function, an onSuccess function, an options object, and a params object.
The run function executes the main body of the action
The optional onSuccess function executes when the whole action group's run functions complete
The optional options object configures the action's details, like action type, transactionality and timeouts.
The optional params object configures the schema of any extra parameters the action accepts.
run function
run functions are for the main body of logic that an action should run. Calls to write data to the database generally belong in a run function.
JavaScript
1import{ save }from"gadget-server";
2
3exportasyncfunctionrun({ record }){
4 record.title??="New Record Title";
5awaitsave(record);
6}
The run function is passed an action context object describing everything Gadget knows about this action execution, like the incoming parameters, the record the action is running on, the current connections, a logger, and more.
For example, if we have a model named post, we can apply the incoming parameters from an API call to the record instance when a new post is created:
The onSuccess function runs once the run function has executed successfully. onSuccess is not run if your action's run function throws, or if one of the other actions being executed at the same time throws. Note that onSuccess functions are not added to custom actions by default, and must be added manually.
onSuccess functions are often useful for sending data to other systems, like an ecommerce platform, a hosted search service, or any other third party. Similarly, sending emails or push notifications is generally done in an onSuccess function so you can be those notifications are talking about events that actually saved to the database.
JavaScript
1importtwiliofrom"some-twilio-client";
2exportasyncfunctionrun(){
3// ...
4}
5
6exportasyncfunctiononSuccess({ record }){
7await twilio.sendSMS({
8to: record.phoneNumber,
9from:"+11112223333",
10body:"Notification from a Gadget app!",
11});
12}
Why not just do everything in run?
It's generally important to ensure that the changes you are making to your local Gadget database have been committed successfully before informing those other services that changes have been made. If third-party service API calls are made in the run function, there is still a chance that the database transaction will be aborted and rolled back if something else fails. If this happens, the third-party service may have been sent data that doesn't exist in the database, which can be a major bug.
A good rule of thumb is: changes to your Gadget database belong in run function, and changes to a third-party system belong in onSuccess.
Errors in actions
Errors thrown during the run or onSuccess function will cause the action to stop processing and will appear in your logs. Errors descriptions will be sent back from your application's auto-generated API as well. When using the api object, they'll be thrown as JavaScript Error objects that you can catch. Throwing an error will also stop any sibling actions, and will prevent any nested actions from starting.
If you are running code that you expect might error in your action, you can wrap it in a try/catch statement:
If your action is transactional and an error is thrown in the run function, the database transaction will be rolled back, and any changes made to the database won't take effect.
If your action is not transactional, or if errors are thrown in the onSuccess function, any changes made so far in the action will have been committed.
For debugging, errors are also logged when they occur. These logs are visible in the Gadget Log Viewer.
Action transactions
By default, the run function of your action is wrapped in a database transaction. This means that either all of the changes made to any record in the run function will commit to the database, or none will if an error is encountered.
To control if transactionality is on or off, export the transactional option:
9transactional:false,// disable transactions for this action
10};
Transactionality is on by default for run functions. To ensure performance, Gadget will time out any transaction after 5 seconds. If an Action exceeds the transaction timeout, a GGT_TRANSACTION_TIMEOUT error will be thrown, and the transaction will be aborted and rolled back.
To avoid hitting transaction timeouts, you can disable transactionality for an action that takes longer than 5 seconds, or split it up into several smaller actions, each of which is transactional.
Action timeouts
Gadget actions have a default maximum execution duration of 3 minutes. Time spent executing the run and onSuccess functions must total under 3 minutes, or Gadget will abort the action's execution. Actions that run over the timeout will throw the GGT_ACTION_TIMEOUT error and return an error to the API caller.
Note that a timeout error does not guarantee that your code will stop running. If you want to make sure that your code will terminate
gracefully, take advantage of the signal property from the action context.
This timeout applies to nested actions as well, such that all nested actions executed simultaneously must complete within the 3-minute timeout.
Action timeouts can be increased by exporting the timeoutMS option:
13timeoutMS:600000,// 10 minutes (600,000 milliseconds) for a really long action
14};
Action timeouts can be a maximum of 15 minutes (900,000 milliseconds).
If your action is set to be transactional (which is the default), the run function must finish within 5 seconds. This limit can't be
changed. If you want to use longer timeouts, put your logic in onSuccess, or export the transactional: false option.
Default model actions (CRUD)
Each model starts with three base actions: a create, an update, and a delete action. These actions allow you to immediately add, change, and remove records from the database without customization. You can leave these actions in place, remove them, or customize their behavior by adding and removing code within their file.
A model with these base actions will generate three mutations in your application's GraphQL API. For example, a model named Post will generate the three createPost, updatePost, and deletePost mutations, which will each trigger their respective action when called.
create Action
The default create action for your model does a few things:
Creates a new record instance with the default values for any fields set
Applies incoming parameters from the GraphQL mutation to the record instance with the applyParams function
Saves the record instance to the database with the save function.
You can add more code to extend the functionality to the create action to manipulate the record or do other things. For example, if you want to add logic to default a certain field to a dynamically generated value. In that case, you can add some code to the run function and update the record instance before the save utility runs:
JavaScript
exportdefaultasyncfunction({ record }){
record.startDate??=newDate();
// ... rest of the action
}
update Action
The default update action for your model does this:
Finds the given record instance based on the passed-in id parameter
Applies incoming parameters from the GraphQL mutation to the record instance with the applyParams utility
Saves the changed record instance fields to the database
You can add more code to the update action's code to manipulate the record or do other things.
delete Action
The default delete action for your model does this:
Finds the given record instance based on the passed-in id parameter
Permanently deletes the record from the database with the deleteRecord utility.
If you want to log more details or update other records as a result of the delete, you can add more code to the delete action.
Action context
Each time an action runs, it gets passed one argument describing the current execution. Most Gadget users use JavaScript destructuring to pull out just the context keys that they require in their action function.
The context object passed into each function has the following keys:
api: A connected, authorized instance of the generated API client for the current Gadget application. See the API Reference for more details on this object's interface.
params: The incoming data from the API call invoking this action.
record: The root record this action is operating on (only available in model actions).
session: A record representing the current user's session, if there is one.
config: An object of all the environment variables created in Gadget's Environment Variables editor.
connections: An object containing client objects for all connections. Read the connections guide to see what each connection provides.
logger: A logger object suitable for emitting log entries viewable in Gadget's Log Viewer.
model: An object describing the metadata for the model currently being operated on, like the fields and validations this model applies.
request: An object describing the incoming HTTP request that triggered this action, if it was an HTTP request that triggered it.
currentAppUrl: The current URL for the environment. e.g. https://my-app.gadget.app
trigger: An object containing what event caused this action to run.
Global actions will be passed everything in the context object except a record or a model.
If you have a computed field associated with your model, it is not included within the context, and must be manually loaded into the
action. For more information on how to do so, see our computed fields guide.
record
For model actions, Gadget automatically loads the record that the action is being taken on from the database at the start of the action. The record is stored in the record key on the action context, so it is there if you'd like to access it.
JavaScript
exportasyncfunctionrun({ record, logger }){
logger.info(record.id);// will log the record's id
logger.info(record.firstName);// will log the value of the firstName field loaded from the database
}
You can change properties of the record like normal JavaScript objects:
JavaScript
exportasyncfunctionrun({ record }){
record.firstName="New name";
}
Note that the record is not automatically saved. To save the record, use the save function from gadget-server:
JavaScript
import{ save }from"gadget-server";
exportasyncfunctionrun({ record }){
save(record);
}
The save function will validate the record and persist it into your Gadget app's database.
The record passed to an action may not be persisted yet. In the case of an actionType: "create" action, there will be a record
in the context, but it won't have an id assigned yet. To persist the record and assign it an id, call save(record).
api
The api object that gets passed into an action is an instance of the generated JavaScript client for your Gadget application. It works the same way in an action as it does elsewhere and has all the same functions documented in the API Reference. You can use the api object to fetch other data in an action:
JavaScript
1// example: send a notification to the user who owns the record if they've opted in
Or, you can use the api object to change other data in your application as a result of the current action:
JavaScript
1// example: save an audit log record when this record changes
2exportconstrun=async({ api, record, model })=>{
3const changes = record.changes();
4
5// run a nested \`create\` action on the \`auditLog\` model
6await api.auditLogs.create({
7action:"Update",
8model: model.apiIdentifier,
9record:{_link: record.id},
10changes: changes.toJSON(),
11});
12};
Using the Public API vs the Internal API
Your application has two different levels of API: the Public API and the Internal API. The Public API runs the high-level actions you've defined in your app, and the Internal API runs low-level persistence operations that just change the database.
Depending on what you're trying to accomplish with your action, it is sometimes appropriate to use the Public API of your application via api.someModel.create, and sometimes it's appropriate to use the Internal API via api.internal.someModel.create.
Generally, the Public API should be preferred. The Public API for a Gadget application invokes model actions and global action, which means your business logic runs. This means that if you call api.someModel.create() within an action, you are invoking another action from within the currently invoking one. This is supported by Gadget and is often the right thing to do if your business logic needs to trigger other high-level logic.
Sometimes, though, you may want to skip executing all the business logic, and just persist data. The Internal API does just this. The Internal API can be used by running api.internal.someModel.<action name>. You can think of the Internal API like raw SQL statements in other frameworks where you might run explicit INSERT or UPDATE statements that skip over the other bits of the framework.
The Internal API is significantly faster and is often used for building high-volume scripts that need to import data or touch many records quickly. However, because the Internal API skips important logic for actions, it is generally not recommended for use unless you have an explicit reason.
Action infinite loops
Be careful when invoking actions within other actions, especially if invoking the same action from within itself.
Invoking actions within actions can cause infinite loops, so care must be taken to avoid this. If you invoke the currently underway action from within itself, your run function will run again, potentially invoking the action again in a chain. Or, if you invoke an action in model B from model A, but model B invokes an action in model A, you can create a chain of action invocations that never ends. Action chains like this can show up in the logs as timeouts or errors that happen at an arbitrary point in an action.
To avoid infinite loops, you must break the loop somehow. There are a couple of options for this:
Use record.changed('someField') to only run a nested action some of the time when data you care about has changed
Use the Internal API instead of the Public API (api.internal.someModel.update instead of api.someModel.update) to make changes, skipping the action that retriggers the loop
Connection Action infinite loops
Be careful when invoking remote APIs within actions if those remote APIs might fire webhooks that call back to your application,
re-invoking the same action.
Calling third-party APIs from your actions is common, but it's important to be aware that third-party APIs may trigger subsequent actions in your Gadget app.
For example, if you have a Gadget app connected to a Shopify store with a connected shopifyProduct model, every time Shopify sends the app a product/updated webhook, Gadget will run the update action. If the business logic inside the update action makes API calls back to Shopify to update the product again (say to update the tags of a product in response to a change in the other fields), the API call back to Shopify will trigger a second webhook sent back to Gadget. Without care, this can cause an infinite loop of actions triggering webhooks that retrigger the actions.
Read more about the logger object in the Logging guide.
trigger
Each action is passed a trigger object that contains information related to what event triggered the action to run. The trigger object is different for each type of trigger that can call an action and has a type object describing which type of trigger fired.
logger.info(trigger);// will log an object like { "type": "api", "rootModel": "someModel", "rootAction": "someAction", etc ... }
}
See the Triggers guide for more information on the different types of triggers.
connections
The connections object in an action context contains prebuilt client objects for making remote API calls. This is useful for easily reading or writing data in supported Connection systems like Shopify without having to manage API clients or credentials.
For example, suppose you have a Shopify connection to your store best-sneakers.myshopify.com and you want to update a product. You could write an action that creates a Shopify client for this shop, and makes an API call to Shopify like so:
Visit the Connections guide to learn more about each of the specific clients.
It's important to keep in mind that changing your remote data could result in an update webhook being sent back to Gadget. Care will need to be taken to ensure you don't get into an infinite feedback loop with the remote system. One good way to ensure this doesn't happen is to make remote updates in the onSuccess function. This guarantees the record has been committed to the database, so you can check the incoming webhook to see if a relevant field has changed before making the remote call.
Each action is passed a request object describing the HTTP request which triggered this action. Not all actions are triggered by HTTP requests (like scheduled actions), but for those that are, this will be present. request has the requester's ip (also known as the x-forwarded-for header), the userAgent of the requester, and all the other request headers sent in the request.
logger.info(request.id);// will log the client making this API requests's IP
logger.info(request.headers["authorization"]);// will log the incoming Authorization request header
}
See all the available properties on the RequestData type in the Reference.
emails
Each action is passed an emails object, which is an instance of the GadgetMailer (a Gadget wrapper for NodeMailer). emails is primarily used to handle and facilitate the sending of emails.
signal
Each action is passed a signal object, which is an instance of AbortSignal. signal is primarily used to identify when the underlying request object becomes closed or aborted.
Why/How?
To reiterate, when we discuss signals aborting we are talking about the idea that a request would abort (in an async context) and an AbortSignal
is a readonly representation of that. As a result, whenever you make a request (i.e. a call to your api) it could abort and it is encouraged
to check for this. Also, when making an external api call it could also abort.
In practice the reasons for aborting could be timeout (i.e. calling the external api could take longer than your timeout) or any expected error on the request.
Example: aborting an action call early
JavaScript
1exportasyncfunctionrun({ api, record, request, signal }){
2for(const dog ofawait api.internal.dog.findMany()){
3await api.internal.dog.update({
4id: dog.id,
5description:`${dog.name} is a young dog`,
6});
7
8// Use throwIfAborted() to rollback transactions that have not yet been committed
9 signal.throwIfAborted();
10}
11
12// This is a feature of fetch which allows you to pass a signal
13// If we timeout on the gadget server (the call took too long), fetch will immediately cancel
14const cats =fetch("someAPI/cats",{ signal });
15
16// it is okay if we don't create cats, so we return and have the action succeed
17if(signal.aborted)return;
18
19await api.internal.cats.bulkCreate(cats);
20}
Action options
actionType
The actionType option set for each action determines how a model action is exposed in the GraphQL API.
models/someModel/someAction.js
JavaScript
exportconst options ={
// mark this action as a record creator
actionType:"create",
};
The actionType option can be one of the following:
create: The action is exposed as a create mutation in the GraphQL API. creates don't accept an id parameter, but do accept parameters for each model's field. If any model fields have a Required validation, those fields must be passed to the create mutation. creates return the newly created record.
update: The action is exposed as an update mutation in the GraphQL API. updatess require being passed an id parameter, and also accept optional parameters for each model's field. updates return the updated record.
delete: The action is exposed as a delete mutation in the GraphQL API. deletess require being passed an id parameter, and don't accept or also accept optional parameters for each model's field. Deletes don't return any data.
custom The action is exposed in the GraphQL API, with the rest of the params up to you. customs require being passed an id parameter, and take no other params by default. customs return the record they were invoked on.
Global Actions don't have an actionType option, since they aren't exposed in the GraphQL API.
timeoutMS
Allows specifying the maximum amount of time (in milliseconds) an action can run before being terminated. This is useful for preventing runaway actions from consuming too many resources. The default timeout is 3 minutes.
models/someModel/someAction.js
JavaScript
exportconst options ={
timeoutMS:1000,// allow this action to take 1s before throwing a GGT_TIMEOUT_ERROR
};
transactional
Specifies if the action's run function should execute within a database transaction or not. Is true by default.
models/someModel/someAction.js
JavaScript
exportconst options ={
transactional:false,// disable transactions for this action
};
returnType
Specifies whether the action should return the result of the run function. Defaults to false for model actions and true for global actions.
api/models/someModel/actions/someAction.js
JavaScript
exportconst options ={
returnType:true,// enable the ability to return a value from the `run` function of the action
};
Let's take a look at a simple example of how to return a value from a run function when setting returnType to true.
14returnType:true,// Setting returnType to `true` as it is false by default on a model action
15triggers:{
16sendResetPassword:true,
17},
18};
Accepting more parameters
You can add new parameters to your actions beyond the auto-generated defaults that Gadget adds to your action by exporting the params object from your action file:
models/someModel/someAction.js
JavaScript
1exportconst params ={
2sendNotifications:{type:"boolean"},
3};
4
5exportasyncfunctionrun({ params }){
6if(params.sendNotifications){
7// ...
8}
9}
This is useful for actions that require parameters to change their behavior, but that you don't want to store on the model.
Gadget expects the exported params object to be a subset of a JSON schema specification. For example, if you want to expose a boolean flag and name object in your action or global action:
models/customer/create.js
JavaScript
1exportconst params ={
2fullName:{
3type:"object",
4properties:{
5first:{type:"string"},
6last:{type:"string"},
7},
8},
9emailMarketing:{type:"boolean"},
10};
11
12exportasyncfunctionrun({ record, params }){
13// params.emailMarketing will be a boolean
14// params.fullName will be an object like { first: "Jane", last: "Dough" }
15if(params.emailMarketing){
16// ...
17}
18}
With that in place in your action code, you can now make the following api call:
JavaScript
await api.customer.create({
emailMarketing:true,
fullName:{first:"Jane",last:"Dough"},
});
Gadget currently supports the following types from JSON schema:
object
string
integer
number
boolean
array
Only these primitive types are currently supported by Gadget. No other features of JSON schema are currently available, so don't use
validations, required and/or schema composition primitives like allOf. If you want to validate parameter values, you can do so in the
code of your action.
Nested Actions
Every action supports calling actions on associated models in its input. This is useful, for example, to create a model and some associated models without having to make API calls for each individual record.
For example, suppose you're modeling a blog where each post has many comments. If we wanted to create a blog post with some comments, it would look something like this with nested actions.
Note that there's no need for a { _link: "…" } parameter to link the blog post and comments.
How are nested actions executed?
When you invoke an action with an input that has nested actions, Gadget will execute the actions in the right order to feed the right values for relationship fields of subsequent inner actions. In the above example, Gadget would create the blog post first and get its ID value to then send into the comment create actions.
When a group of nested actions is executed, all the constituent actions' run functions are executed together within one database transaction, and then the actions' onSuccess are executed second if all the run functions successfully execute. This means that Gadget runs all the run functions for the root action and nested actions first, then commits the transaction, then executes onSuccess functions.
If any actions throw an error during the run function, like say if a record is invalid or a code snippet throws an error, all the actions will be aborted, and no onSuccess function for any action will run.
The rule of thumb is, a run function that fails on any action rolls back changes from all other actions.
Converge actions
Gadget supports a special nested action called _converge that creates, updates, or deletes records in the database as needed to match an incoming specified list. Often when updating a parent object and list of child objects at the same time, it is cumbersome to exactly specify the individual operations you want, and instead, it's easier to just specify the new list. _converge figures out the individual operations that take you from the current state in the database to the new specified list. This is most frequently used on forms that edit a parent and child records at the same time so you can just send the new list of child records specified in the form, instead of tracking which things have been added, removed, and changed.
The _converge action will process creates, updates, and deletes using actions with the api identifiers create, update, and delete by default, so any effects added to those actions will run as Gadget converges the record list. You can override which actions Gadget uses to implement the converge using the actions property, see Invoking custom actions to converge for more details.
Run a converge call by specifying _converge as the action name in a nested action input, and passing a new list of records in the values property like so:
json
1{
2"_converge":{
3"values":[
4// will update record with id 1
5{"id":"1","name":"first"},
6// will create a new record
7{"name":"second"}
8// other records not present in this list will be deleted
9]
10}
11}
For example, suppose you're editing a blog post where each post has many images. If you wanted to build a client side form where users could remove some of the images that exist already, and then add a couple new ones, you can use _converge to create, update, and delete the images as needed to get to the new list. Without _converge, you'd have to manually track the image ids that have been deleted or updated to build the nested actions data format yourself.
Instead, we can converge the list of child images for the post to the newly specified list.
The _converge call above will create a new image with the caption "Mountains" using the Image.create action, and update an existing image with id "10" to have the caption "Oceans" using the Image.update action. It will also delete any images in the database which aren't specified in this list using Image.delete, so if there was previously an image with id "9" labelled "Skies", it'd be deleted.
Invoking custom actions to converge
The _converge action will process creates, updates, and deletes using actions with the api identifiers create, update, and delete by default. You can override which actions Gadget uses to implement the converge using the actions property of the passed object like so:
json
1{
2"_converge":{
3"values":[
4// ...
5],
6"actions":{
7// will create any new records using the publicCreate action of the child model
8"create":"publicCreate",
9
10// will update any existing records using the specialUpdate action of the child model
11"update":"specialUpdate"
12}
13}
14}
The actions override list is optional and partial, so you can just specify the actions you want to override, or just omit the actions property entirely.