In Gadget, actions are used to modify data, perform business logic, and handle API requests. Actions can be defined on models and executed using transactions to ensure data consistency.
Action basics
Through What's in an action, you have learned the types of exports produced by an action. This section will provide more details on the basics of writing an action, as well as the actions your gadget models come pre-equipped with.
Default actions
By default, any models you create come pre-packaged with three default actions, create, update, and delete. These actions will interact with your application's GraphQL API to handle record mutations.
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 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:
Add a new line of code to create
JavaScript
export const run: ActionRun = async ({ record }) => {
applyParams(params, record);
record.startDate ??= new Date();
await save(record);
};
export const run: ActionRun = async ({ record }) => {
applyParams(params, record);
record.startDate ??= new Date();
await save(record);
};
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.
upsert meta Action
Models that have both a create and update action will also have a meta upsert action. The create and update actions must exist for the upsert action to be available on the model. The upsert action doesn't need to be created by you, nor will it be found in the actions folder. When run, upsert will do one of the following:
Call the create action if the given record does not exist in the database.
Call the update action if the record does exist in the database.
The upsert action uses a record's id field to determine if the record exists in the database. A custom upsert field can be provided with the on: ["customField"] option.
This will upsert the record based on the 'uniqueCode' field
JavaScript
await api.gizmo.upsert({
name: "XZ-77",
uniqueCode: "112233",
on: ["uniqueCode"],
});
await api.gizmo.upsert({
name: "XZ-77",
uniqueCode: "112233",
on: ["uniqueCode"],
});
If a combination of two fields on a model is used to determine a record's uniqueness, you can provide an array of fields to the on option.
This will upsert the record based on the 'uniqueCode' field and the 'user' relationship
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-scoped 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.
Globally-scoped 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-scoped 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
export const run: ActionRun = async ({ record, logger }) => {
// will log the record's id
logger.info(record.id);
// will log the value of the firstName field loaded from the database
logger.info(record.firstName);
};
export const run: ActionRun = async ({ record, logger }) => {
// will log the record's id
logger.info(record.id);
// will log the value of the firstName field loaded from the database
logger.info(record.firstName);
};
You can change properties of the record like normal JavaScript objects:
JavaScript
export const run: ActionRun = async ({ record }) => {
record.firstName = "New name";
};
export const run: ActionRun = async ({ 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";
export const run: ActionRun = async ({ record }) => {
await save(record);
};
import { save } from "gadget-server";
export const run: ActionRun = async ({ record }) => {
await 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
// example: send a notification to the user who owns the record if they've opted in
import twilio from "../../twilio-client";
export const run: ActionRun = async ({ api, record }) => {
const creator = await api.user.findOne(record.creatorId);
if (creator.wantsNotifications) {
await twilio.sendSMS({
to: creator.phoneNumber,
from: "+11112223333",
body: "Notification: " + record.title + "was updated",
});
}
};
// example: send a notification to the user who owns the record if they've opted in
import twilio from "../../twilio-client";
export const run: ActionRun = async ({ api, record }) => {
const creator = await api.user.findOne(record.creatorId);
if (creator.wantsNotifications) {
await twilio.sendSMS({
to: creator.phoneNumber,
from: "+11112223333",
body: "Notification: " + record.title + "was updated",
});
}
};
Or, you can use the api object to change other data in your application as a result of the current action:
JavaScript
// example: save an audit log record when this record changes
export const run: ActionRun = async ({ api, record, model }) => {
const changes = record.changes();
// run a nested `create` action on the `auditLog` model
await api.auditLogs.create({
action: "Update",
model: model.apiIdentifier,
record: { _link: record.id },
changes: changes.toJSON(),
});
};
// example: save an audit log record when this record changes
export const run: ActionRun = async ({ api, record, model }) => {
const changes = record.changes();
// run a nested `create` action on the `auditLog` model
await api.auditLogs.create({
action: "Update",
model: model.apiIdentifier,
record: { _link: record.id },
changes: changes.toJSON(),
});
};
For more information on the api's use in actions, see actions and api.
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.
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.
JavaScript
export const onSuccess: ActionOnSuccess = async ({ api, record, connections }) => {
// only do the remote update if the body field has changed
if (record.changed("body")) {
const shopifyClient = await connections.shopify.forShopDomain(
"best-sneakers.myshopify.com"
);
await shopifyClient.graphql(
`mutation ($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
}
userErrors {
message
}
}
}`,
{
input: {
id: `gid://shopify/Product/${productId}`,
...productParams,
},
}
);
}
};
export const onSuccess: ActionOnSuccess = async ({ api, record, connections }) => {
// only do the remote update if the body field has changed
if (record.changed("body")) {
const shopifyClient = await connections.shopify.forShopDomain(
"best-sneakers.myshopify.com"
);
await shopifyClient.graphql(
`mutation ($input: ProductInput!) {
productUpdate(input: $input) {
product {
id
}
userErrors {
message
}
}
}`,
{
input: {
id: `gid://shopify/Product/${productId}`,
...productParams,
},
}
);
}
};
request
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.
api/models/someModel/actions/someAction.js
JavaScript
export const run: ActionRun = async ({ api, record, trigger, request, logger }) => {
// will log the client making this API requests's IP
logger.info(request.id);
// will log the incoming Authorization request header
logger.info(request.headers["authorization"]);
};
export const run: ActionRun = async ({ api, record, trigger, request, logger }) => {
// will log the client making this API requests's IP
logger.info(request.id);
// will log the incoming Authorization request header
logger.info(request.headers["authorization"]);
};
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.
Async requests made to your app's API or an external API can be aborted. Common reasons for a request to be aborted include a timeout or an expected error on the request. An AbortSignal is a read-only representation of a request's status and allows you to handle these aborted requests gracefully.
Example: aborting an action call early
JavaScript
export const run: ActionRun = async ({ api, record, request, signal }) => {
for (const dog of await api.internal.dog.findMany()) {
if (dog.age < 2) {
await api.internal.dog.update({
id: dog.id,
description: `${dog.name} is a young dog`,
});
}
// Use throwIfAborted() to rollback transactions that have not yet been committed
signal.throwIfAborted();
}
// This is a feature of fetch which allows you to pass a signal
// If we timeout on the gadget server (the call took too long), fetch will immediately cancel
const cats = fetch("someAPI/cats", { signal });
// it is okay if we don't create cats, so we return and have the action succeed
if (signal.aborted) return;
await api.internal.cats.bulkCreate(cats);
};
export const run: ActionRun = async ({ api, record, request, signal }) => {
for (const dog of await api.internal.dog.findMany()) {
if (dog.age < 2) {
await api.internal.dog.update({
id: dog.id,
description: `${dog.name} is a young dog`,
});
}
// Use throwIfAborted() to rollback transactions that have not yet been committed
signal.throwIfAborted();
}
// This is a feature of fetch which allows you to pass a signal
// If we timeout on the gadget server (the call took too long), fetch will immediately cancel
const cats = fetch("someAPI/cats", { signal });
// it is okay if we don't create cats, so we return and have the action succeed
if (signal.aborted) return;
await api.internal.cats.bulkCreate(cats);
};
Handling errors and Rollbacks
Errors thrown during the run or onSuccess functions will cause the action to stop processing and will be logged. These error descriptions will also be sent back from your application's auto-generated API. When using the api object, errors are thrown as JavaScript Error objects that you can catch.
Throwing an error will:
Stop any sibling actions from continuing.
Prevent nested actions from starting.
Handling Expected Errors
If you anticipate that a certain part of your action might fail, wrap it in a try/catch block to handle it gracefully.
While Gadget does not have its own onFailure function, you can create one to handle any errors encountered by your actions. However, unlike onSuccess, you will need to call this function in the run function as needed. For example, this function could be called in the catch block of a try/catch.
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:
Transactionality is on by default for model-scoped action run functions, and off by default for globally-scoped action run functions. Transactions currently only group API calls made with the Internal API, so invoking other actions within a transactional action run function will start a new, different transaction. If you need nested action transactions, please contact us on Discord.
Transaction rollback from errors
If your action is transactional and an error occurs in the run function:
The database transaction will be rolled back automatically.
Any changes made to the database won’t take effect.
However, if your action is not transactional, or if errors occur in the onSuccess function:
Any changes made so far will be committed and won’t be rolled back.
For debugging, errors are also logged when they occur. These logs are visible in the Gadget Log Viewer.
Advanced action code
You can further customize your actions with some advanced features, such as transactionality, nestled actions, and converge actions.
Using api.transaction()
Sometimes, you may want to add a transaction to a function that doesn't already have it, such as in onSuccess or HTTP routes. You can do this by wrapping the body of these functions in the api.transaction function. Transactions are defined as such:
Breaking this down, we can see that transaction accept an argument callback. This is used to call different, predefined, Gadget functions which may be useful during a transaction. For example, the following code makes use of a rollback, which can undo any changes made to a database in case of action failure.
JavaScript
export const onSuccess: ActionOnSuccess = async ({
record,
params,
logger,
api,
}) => {
await api.transaction(async ({ rollback }) => {
// Example of a rollback use case
try {
await api.internal.someModel.create({ name: "new record" });
// Error occurs in call to external service
await fetch("https://example.com/api", {
method: "POST",
body: JSON.stringify({ name: "new record" }),
});
} catch (error) {
logger.error({ error }, "error encountered");
// Rollback the transaction
await rollback();
}
});
};
export const onSuccess: ActionOnSuccess = async ({
record,
params,
logger,
api,
}) => {
await api.transaction(async ({ rollback }) => {
// Example of a rollback use case
try {
await api.internal.someModel.create({ name: "new record" });
// Error occurs in call to external service
await fetch("https://example.com/api", {
method: "POST",
body: JSON.stringify({ name: "new record" }),
});
} catch (error) {
logger.error({ error }, "error encountered");
// Rollback the transaction
await rollback();
}
});
};
GadgetTransaction callback params
The following params are available in the callback function:
Parameter
Type
Description
client
Client
Information about the underlying urql client. This is not the same as the API parameter in action context.
start
Promise<void>
A function that starts the transaction and returns a void promise.
commit
Promise<void>
A function that commits the transaction. Returns a void promise.
rollback
Promise<void>
A function that explicitly rolls back the transaction. It prevents any changes from the transaction from being committed. Uncaught errors will automatically rollback the transaction. Returns a void promise.
close
() => void
A function that shuts down the transaction, closing the connection to the backend. Void return.
open
boolean
Returns a boolean.
subscriptionClient
SubscriptionClient
The instance of the underlying transport object. This is the transaction websocket manager.
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 by setting transactional: false in the action options
split up a single transactional action into several smaller actions, each of which is transactional'
Example of splitting an action into multiple actions:
JavaScript
export const onSuccess: ActionOnSuccess = async ({ record, api }) => {
// ... do some work
await api.enqueue(api.someModel.someAction, { id: record.id });
// ... do some more work
};
export const onSuccess: ActionOnSuccess = async ({ record, api }) => {
// ... do some work
await api.enqueue(api.someModel.someAction, { id: record.id });
// ... do some more work
};
Nested actions and execution order
Every action supports calling other 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. If all the run functions are successful, then all the actions' onSuccess will be executed. 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.
As a rule of thumb, 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 simplifies the process of creating, updating, and deleting records in a database to match an incoming specified list. Instead of manually tracking changes, _converge intelligently determines the required operations to transform the current database state into the desired new list.
This is particularly useful when updating a parent object alongside a list of child objects. For example, in a form where users edit a blog post and its associated images, _converge allows developers to pass the updated list of images directly without worrying about which images were added, removed, or modified.
How _converge Works
When executed, _converge processes records using the following default API actions:
Create new records using create.
Update existing records using update.
Delete records not present in the new list using delete.
These operations ensure that all modifications happen within a single transaction, maintaining data integrity.
Example Usage
To run a _converge call, specify _converge as the action name in a nested action input and pass the new list of records in the values property:
json
{
"_converge": {
"values": [
{ "id": "1", "name": "first" }, // Updates existing record with ID 1
{ "name": "second" } // Creates a new record
// Any records missing from this list will be deleted
]
}
}
Use Case: Editing a Blog Post with Images
If a user is editing a blog post that includes multiple images, _converge can automatically handle additions, updates, and deletions of images based on the new list provided. Without _converge, developers would need to manually track changes and construct the corresponding API calls.
Instead, _converge allows us to synchronize the list of images seamlessly:
await api.post.update("123", {
_converge: {
values: [
{ id: "10", caption: "Oceans" }, // Updates an existing image
{ caption: "Mountains" }, // Creates a new image
],
},
});
await api.post.update("123", {
_converge: {
values: [
{ id: "10", caption: "Oceans" }, // Updates an existing image
{ caption: "Mountains" }, // Creates a new image
],
},
});
In this case:
A new image with the caption "Mountains" is created using Image.create.
The existing image with ID "10" is updated to "Oceans" using Image.update.
Any images not present in the new list are deleted using Image.delete.
Invoking Custom Actions in _converge
By default, _converge uses create, update, and delete actions, but developers can override these with custom actions using the actions property:
json
{
"_converge": {
"values": [
// List of records to create, update, or delete
],
"actions": {
"create": "publicCreate", // Uses a custom 'publicCreate' action for new records
"update": "specialUpdate" // Uses 'specialUpdate' instead of 'update'
}
}
}
This override mechanism allows flexibility in defining how records are processed, making _converge adaptable to different business logic requirements.
Optimizing actions
So far you've learned how actions work and how to write them. However, there are some tips you should keep in mind to optimize action performance.
Handling infinite loops in actions
Infinite loops in actions can cause performance issues and excessive API calls, making it crucial to prevent them. These loops often occur when an action unintentionally triggers itself or another action recursively. The most common causes include triggering an action inside itself, circular dependencies between actions, and event-based triggers that fire continuously.
Preventing infinite loops with conditional checks
Use conditional logic to ensure you're not updating a record unnecessarily. This is especially important in actions that are generated on its own (update, create) or within a custom action's run or onSuccess hook.
This ensures the update runs only if the user's status is not already "active," preventing an infinite loop.
Using flags or metadata to prevent recursion
Add a custom field or metadata (like processed) to track whether logic has already been executed. This can be used in onSuccess, or custom actions to prevent duplicate behavior.
Checking if an action has been run before running another one
Here, the processed flag ensures that this block runs only once per record.
Using time-based conditions
In serverless environment, delaying execution using setTimeout() is not possible. Instead, you can use timestamps to control the timing of when actions should be triggered. This is useful for rate-limiting or ensuring that updates happen only after a certain amount of time.
JavaScript
const now = new Date();
if (
!order.lastProcessedAt ||
Date.now() - new Date(order.lastProcessedAt).getTime() > 1000
) {
await api.order.update(order.id, {
lastProcessedAt: now.toISOString(),
status: "pending",
});
}
const now = new Date();
if (
!order.lastProcessedAt ||
Date.now() - new Date(order.lastProcessedAt).getTime() > 1000
) {
await api.order.update(order.id, {
lastProcessedAt: now.toISOString(),
status: "pending",
});
}
In this example, the order is only updated if it hasn’t been processed within the last second. This avoids repeated triggers within a short time frame.
Debugging with logging
Logging inside your run or onSuccess function helps identify if an action is being repeatedly triggered.
JavaScript
logger.info("Order processing started:", new Date());
await api.order.update("789", {
status: "completed",
});
logger.info("Order processing finished:", new Date());
logger.info("Order processing started:", new Date());
await api.order.update("789", {
status: "completed",
});
logger.info("Order processing finished:", new Date());
If you see logs appearing too frequently for the same action, that indicates an unintended loop or excess triggers.
Limiting event-based triggers
For event-based triggers like update, always add checks to ensure an update won’t recursively trigger another update. These checks ensure that actions won’t cause infinite loops due to their event-handling mechanism.
JavaScript
api.order.update(async ({ record }) => {
if (record.status !== "confirmed") {
await api.order.update(record.id, {
status: "confirmed",
});
}
});
api.order.update(async ({ record }) => {
if (record.status !== "confirmed") {
await api.order.update(record.id, {
status: "confirmed",
});
}
});
This ensures that an update doesn’t occur recursively if the status is already set to the desired value.
By combining these strategies—conditional checks, flags, rate limits, logging, and event handling—you can effectively prevent infinite loops in Gadget actions, ensuring smooth performance and reliable data handling.